From 710b7f154513b2a94ba999981baa1bd9c12b2ca3 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 16:57:08 +0000 Subject: [PATCH 01/16] feat(design): exact values under the design's choices A brand sits between the tiles' steps: its headline 48px at weight 300, its buttons 40px tall with 4px corners. A design's tune sets exact values for headlines, section headings, labels, buttons, cards and fields, each bounded so no value can break a page, and each one left out keeps what the tiles choose. Headlines take their own weight, defaulting to the heading weight; a headline's size scales down on a phone. Section headings are a part, so a tune sizes them without touching smaller headings. tuneFromMeasurements turns what a reader measured on a brand's own pages into exact values a person would choose, rounding to steps and holding a site's hero-sized section headings to what a card can carry. --- .../help-center-popular-articles.tsx | 6 +- .../portal/changelog/changelog-entry-card.tsx | 7 +- .../components/public/home/home-blocks.tsx | 6 +- .../design/__tests__/measured-tune.test.ts | 49 ++++++ .../shared/design/__tests__/resolve.test.ts | 2 +- .../lib/shared/design/__tests__/tune.test.ts | 76 ++++++++++ apps/web/src/lib/shared/design/components.ts | 4 + .../src/lib/shared/design/measured-tune.ts | 85 +++++++++++ apps/web/src/lib/shared/design/parts.ts | 13 ++ apps/web/src/lib/shared/design/resolve.ts | 7 +- apps/web/src/lib/shared/design/schema.ts | 3 + apps/web/src/lib/shared/design/tune.ts | 142 ++++++++++++++++++ .../src/routes/_portal/hc/$locale/index.tsx | 6 +- apps/web/src/routes/_portal/hc/index.tsx | 6 +- 14 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts create mode 100644 apps/web/src/lib/shared/design/__tests__/tune.test.ts create mode 100644 apps/web/src/lib/shared/design/measured-tune.ts create mode 100644 apps/web/src/lib/shared/design/tune.ts 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 (
-
-

{title}

+

+ {title} +

diff --git a/apps/web/src/components/public/home/home-blocks.tsx b/apps/web/src/components/public/home/home-blocks.tsx index 75a24122a0..aa4b544ea1 100644 --- a/apps/web/src/components/public/home/home-blocks.tsx +++ b/apps/web/src/components/public/home/home-blocks.tsx @@ -137,7 +137,11 @@ function BlockCard({ span )} > - {title &&

{title}

} + {title && ( +

+ {title} +

+ )} {children} {action && ( { + it('rounds type and shapes to steps a person would choose', () => { + const tune = tuneFromMeasurements({ + headline: { size: 48.4, weight: 310, tracking: -0.0198, leading: 1.1500001 }, + heading: { size: 32, weight: 300, tracking: 0.002, leading: 1.1 }, + label: { size: 14, weight: 425, leading: 1.4 }, + button: { radius: 4, height: 40.2 }, + card: { radius: 6, borderWidth: 0, shadow: false }, + field: { radius: 1000 }, + }) + expect(tune).toEqual({ + headline: { size: 48, weight: 300, tracking: -0.02, leading: 1.15 }, + heading: { size: 32, weight: 300, leading: 1.1 }, + label: { size: 14, weight: 400 }, + button: { radius: 4, height: 40 }, + card: { radius: 6, border: 0, shadow: 'none' }, + field: { radius: 999 }, + }) + expect(tuneSchema.safeParse(tune).success).toBe(true) + }) + + it('holds what it measured within what a page can carry', () => { + const tune = tuneFromMeasurements({ + headline: { size: 260, weight: 1000, leading: 3 }, + heading: { size: 58 }, + button: { height: 90, radius: 60 }, + card: { radius: 80, borderWidth: 9 }, + }) + expect(tune.headline).toEqual({ size: 120, weight: 900, leading: 1.8 }) + // A marketing section's heading titles a card here. + expect(tune.heading).toEqual({ size: 36 }) + expect(tune.button).toEqual({ radius: 60, height: 60 }) + expect(tune.card).toEqual({ radius: 32, border: 4 }) + expect(tuneSchema.safeParse(tune).success).toBe(true) + }) + + it('sets nothing it did not measure', () => { + expect(tuneFromMeasurements({ headline: null, button: { radius: null } })).toEqual({}) + }) +}) diff --git a/apps/web/src/lib/shared/design/__tests__/resolve.test.ts b/apps/web/src/lib/shared/design/__tests__/resolve.test.ts index bf3e755df2..b1e846c421 100644 --- a/apps/web/src/lib/shared/design/__tests__/resolve.test.ts +++ b/apps/web/src/lib/shared/design/__tests__/resolve.test.ts @@ -194,7 +194,7 @@ describe('renderDesignCss', () => { '@layer base { [data-portal-theme] :is(h1, h2, h3) { font-family: var(--qb-font-heading); } }' ) expect(css).toContain( - '@layer design { [data-portal-theme] { letter-spacing: -0.01em; } [data-portal-theme] :is(h1, h2, h3) { font-weight: var(--qb-heading-weight); letter-spacing: -0.02em; } [data-portal-theme] [data-qb-part=headline] { font-family: var(--qb-font-display); letter-spacing: var(--qb-headline-tracking); text-transform: var(--qb-headline-case); } [data-portal-theme] :is([data-qb-part=nav-item], [data-qb-label]) { text-transform: var(--qb-label-case); letter-spacing: var(--qb-label-tracking); } }' + '@layer design { [data-portal-theme] { letter-spacing: -0.01em; } [data-portal-theme] :is(h1, h2, h3) { font-weight: var(--qb-heading-weight); letter-spacing: -0.02em; } [data-portal-theme] [data-qb-part=headline] { font-family: var(--qb-font-display); font-weight: var(--qb-headline-weight); letter-spacing: var(--qb-headline-tracking); text-transform: var(--qb-headline-case); } [data-portal-theme] :is([data-qb-part=nav-item], [data-qb-label]) { text-transform: var(--qb-label-case); letter-spacing: var(--qb-label-tracking); } }' ) }) }) diff --git a/apps/web/src/lib/shared/design/__tests__/tune.test.ts b/apps/web/src/lib/shared/design/__tests__/tune.test.ts new file mode 100644 index 0000000000..4990e6d99b --- /dev/null +++ b/apps/web/src/lib/shared/design/__tests__/tune.test.ts @@ -0,0 +1,76 @@ +/** + * Exact values under the design's choices: each one a design sets replaces + * what the tiles chose, scales sensibly on a phone, and wins over the card + * styles; each one it leaves out changes nothing. + */ +import { describe, expect, it } from 'vitest' +import { renderDesignCss, resolveDesign } from '../resolve' +import { defaultDesign, portalDesign } from '../schema' +import { tuneCss, tuneTokens } from '../tune' + +describe('exact values', () => { + it('change nothing when a design has none', () => { + expect(tuneTokens(undefined)).toEqual({}) + expect(tuneCss(undefined)).toBe('') + expect(renderDesignCss(resolveDesign(defaultDesign()))).not.toContain('--qb-button-height') + }) + + it('set a headline’s exact size, scaled down on a phone, and its own weight, tracking and leading', () => { + const tokens = tuneTokens({ + headline: { size: 48, weight: 300, tracking: -0.02, leading: 1.15 }, + }) + expect(tokens['--qb-headline-size']).toBe('clamp(28px, 3.333vw, 48px)') + expect(tokens['--qb-headline-weight']).toBe('300') + expect(tokens['--qb-headline-tracking']).toBe('-0.02em') + expect(tokens['--qb-headline-leading']).toBe('1.15') + // The other pages' titles sit a step below the headline. + expect(tokens['--qb-page-title-size']).toBe('clamp(24px, 2.067vw, 29.76px)') + }) + + it('give headlines the heading weight until a tune gives them their own', () => { + const doc = defaultDesign() + expect(renderDesignCss(resolveDesign(doc))).toContain('--qb-headline-weight: 600') + expect(renderDesignCss(resolveDesign(doc))).toContain( + '[data-qb-part=headline] { font-family: var(--qb-font-display); font-weight: var(--qb-headline-weight);' + ) + doc.tune = { headline: { weight: 300 } } + expect(renderDesignCss(resolveDesign(doc))).toContain('--qb-headline-weight: 300') + }) + + it('size section headings, labels and buttons only when tuned', () => { + const css = tuneCss({ + heading: { size: 32 }, + label: { weight: 500 }, + button: { height: 40, radius: 4 }, + }) + expect(css).toContain( + '[data-portal-theme] [data-qb-part=section-heading] { font-size: var(--qb-heading-size); }' + ) + expect(css).toContain('[data-slot=button]) { font-weight: var(--qb-label-weight); }') + expect(css).toContain('min-height: var(--qb-button-height)') + expect(tuneTokens({ button: { radius: 4 } })['--qb-radius-button']).toBe('4px') + }) + + it('draw cards exactly, after the card styles and at their weight, so the tune wins', () => { + const doc = defaultDesign() + doc.layout.cards = 'raised' + doc.tune = { card: { radius: 6, border: 0, shadow: 'none' } } + const css = renderDesignCss(resolveDesign(doc)) + const rule = + '[data-qb-cards][data-portal-theme] [data-qb-surface=card] { border-radius: var(--qb-radius-card); border-width: 0px; box-shadow: none; }' + expect(css).toContain(rule) + expect(css.indexOf(rule)).toBeGreaterThan( + css.indexOf('[data-qb-cards=raised][data-portal-theme]') + ) + }) + + it('refuse values outside what any page can carry', () => { + const doc = defaultDesign() + expect(portalDesign.safeParse({ ...doc, tune: { headline: { size: 48 } } }).success).toBe(true) + expect(portalDesign.safeParse({ ...doc, tune: { headline: { size: 900 } } }).success).toBe( + false + ) + expect(portalDesign.safeParse({ ...doc, tune: { button: { height: 4 } } }).success).toBe(false) + expect(portalDesign.safeParse({ ...doc, tune: { card: { glow: 1 } } }).success).toBe(false) + }) +}) diff --git a/apps/web/src/lib/shared/design/components.ts b/apps/web/src/lib/shared/design/components.ts index 20e54f61c4..626eba0f8f 100644 --- a/apps/web/src/lib/shared/design/components.ts +++ b/apps/web/src/lib/shared/design/components.ts @@ -8,6 +8,7 @@ * workspace keeps its look. */ import type { ThemeVariables } from '@/lib/shared/theme/types' +import { tuneTokens } from './tune' import { bandArtImage, EDGE_ART, type BandArt } from './band-art' import { mixColor, tintOf } from './color' import { contrastRatio, solveContrast } from './contrast' @@ -438,6 +439,8 @@ export function baseTokens(doc: PortalDesign): TokenMap { const [button, composer] = buttons === 'match' ? [control, field] : BUTTON_RADII[buttons] const [padding, gap, title] = DENSITY[doc.layout.density] return { + // Headlines take the heading weight unless the design tunes their own. + '--qb-headline-weight': String(doc.brand.fonts.headingWeight), '--qb-radius-control': control, '--qb-radius-field': field, '--qb-radius-panel': panel, @@ -460,6 +463,7 @@ export function baseTokens(doc: PortalDesign): TokenMap { '--qb-font-display': fontStack(doc.brand.fonts.display ?? doc.brand.fonts.heading), '--qb-heading-weight': String(doc.brand.fonts.headingWeight), ...typeTokens(doc.brand.fonts), + ...tuneTokens(doc.tune), } } diff --git a/apps/web/src/lib/shared/design/measured-tune.ts b/apps/web/src/lib/shared/design/measured-tune.ts new file mode 100644 index 0000000000..2f3cbeffd8 --- /dev/null +++ b/apps/web/src/lib/shared/design/measured-tune.ts @@ -0,0 +1,85 @@ +/** + * A brand's measured type and shapes, as exact values. What a reader measured + * on the brand's own pages, such as its headline's rendered size and weight or + * its buttons' height and corners, rounded to steps a person would choose and + * held within what any page can carry. + */ +import type { Tune } from './tune' + +export interface MeasuredType { + size?: number + weight?: number + /** In em. */ + tracking?: number + /** As a multiple of the size. */ + leading?: number +} + +export interface MeasuredBox { + /** In px; 999 or more is a pill. */ + radius?: number | null + height?: number + borderWidth?: number + shadow?: boolean +} + +export interface BrandMeasurements { + headline?: MeasuredType | null + heading?: MeasuredType | null + label?: MeasuredType | null + button?: MeasuredBox | null + card?: MeasuredBox | null + field?: MeasuredBox | null +} + +const within = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) +const step = (value: number, by: number) => Math.round(value / by) * by +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) + +function type(m: MeasuredType | null | undefined, [min, max]: [number, number]) { + if (!m) return undefined + const out: NonNullable = {} + if (finite(m.size)) out.size = within(Math.round(m.size), min, max) + if (finite(m.weight)) out.weight = within(step(m.weight, 100), 100, 900) + if (finite(m.tracking) && Math.abs(m.tracking) >= 0.005) + out.tracking = within(+m.tracking.toFixed(3), -0.1, 0.3) + if (finite(m.leading)) out.leading = within(+m.leading.toFixed(2), 0.9, 1.8) + return Object.keys(out).length ? out : undefined +} + +const corners = (radius: number | null | undefined, max: number) => + !finite(radius) ? undefined : radius >= 999 ? 999 : within(Math.round(radius), 0, max) + +/** The exact values a brand's measurements give. */ +export function tuneFromMeasurements(m: BrandMeasurements): Tune { + const tune: Tune = {} + const headline = type(m.headline, [24, 120]) + if (headline) tune.headline = headline + // A site's section headings are often heroes of their own; in a portal they + // title a card, so they are held to what a card can carry. + const heading = type(m.heading, [16, 36]) + if (heading) tune.heading = heading + const label = type(m.label, [12, 18]) + if (label) { + delete label.leading + tune.label = label + } + if (m.button) { + const radius = corners(m.button.radius, 999) + const height = finite(m.button.height) ? within(Math.round(m.button.height), 28, 60) : undefined + if (radius !== undefined || height !== undefined) + tune.button = { ...(radius !== undefined && { radius }), ...(height && { height }) } + } + if (m.card) { + tune.card = { + ...(corners(m.card.radius, 32) !== undefined && { radius: corners(m.card.radius, 32) }), + ...(finite(m.card.borderWidth) && { border: within(Math.round(m.card.borderWidth), 0, 4) }), + ...(typeof m.card.shadow === 'boolean' && { shadow: m.card.shadow ? 'soft' : 'none' }), + } + if (!Object.keys(tune.card).length) delete tune.card + } + const field = corners(m.field?.radius, 999) + if (field !== undefined) tune.field = { radius: field } + return tune +} diff --git a/apps/web/src/lib/shared/design/parts.ts b/apps/web/src/lib/shared/design/parts.ts index 65f635ca76..69f9e729d0 100644 --- a/apps/web/src/lib/shared/design/parts.ts +++ b/apps/web/src/lib/shared/design/parts.ts @@ -72,6 +72,19 @@ export const PORTAL_PARTS = [ ], files: ['components/public/portal-band.tsx'], }, + { + name: 'section-heading', + description: + "A section's heading: a Home block's title, the help centre's sections, a changelog entry's title.", + states: [], + tokens: ['--qb-font-heading', '--qb-heading-weight'], + files: [ + 'components/public/home/home-blocks.tsx', + 'components/help-center/help-center-popular-articles.tsx', + 'components/portal/changelog/changelog-entry-card.tsx', + 'routes/_portal/hc/index.tsx', + ], + }, { name: 'home-block', description: 'A block on Home. `data-qb-block` names its type, such as status.', diff --git a/apps/web/src/lib/shared/design/resolve.ts b/apps/web/src/lib/shared/design/resolve.ts index f82beb0702..a254d65ce7 100644 --- a/apps/web/src/lib/shared/design/resolve.ts +++ b/apps/web/src/lib/shared/design/resolve.ts @@ -7,6 +7,7 @@ * full list of ways a converted portal can look different. */ import { expandTheme } from '@/lib/shared/theme/expand' +import { tuneCss } from './tune' import { variableMap } from '@/lib/shared/theme/generator' import type { ThemeVariables } from '@/lib/shared/theme/types' import { contrastRatio, solveContrast } from './contrast' @@ -35,6 +36,8 @@ export interface ResolvedDesign { errors: string[] /** Component tokens: sizes for both modes, and each mode's colours. */ tokens: { base: TokenMap; light: TokenMap; dark: TokenMap } + /** The rules the design's exact values need, after the rest. */ + tuneCss: string } const CORNER_RADIUS: Record = { @@ -146,6 +149,7 @@ export function resolveDesign(doc: PortalDesign): ResolvedDesign { light: light ? modeTokens(doc, light, 'light') : {}, dark: dark ? modeTokens(doc, dark, 'dark') : {}, }, + tuneCss: tuneCss(doc.tune), } } @@ -232,7 +236,7 @@ export function renderDesignCss(resolved: ResolvedDesign): string { '@layer base { [data-portal-theme] :is(h1, h2, h3) { font-family: var(--qb-font-heading); } }' ) parts.push( - '@layer design { [data-portal-theme] { letter-spacing: -0.01em; } [data-portal-theme] :is(h1, h2, h3) { font-weight: var(--qb-heading-weight); letter-spacing: -0.02em; } [data-portal-theme] [data-qb-part=headline] { font-family: var(--qb-font-display); letter-spacing: var(--qb-headline-tracking); text-transform: var(--qb-headline-case); } [data-portal-theme] :is([data-qb-part=nav-item], [data-qb-label]) { text-transform: var(--qb-label-case); letter-spacing: var(--qb-label-tracking); } }' + '@layer design { [data-portal-theme] { letter-spacing: -0.01em; } [data-portal-theme] :is(h1, h2, h3) { font-weight: var(--qb-heading-weight); letter-spacing: -0.02em; } [data-portal-theme] [data-qb-part=headline] { font-family: var(--qb-font-display); font-weight: var(--qb-headline-weight); letter-spacing: var(--qb-headline-tracking); text-transform: var(--qb-headline-case); } [data-portal-theme] :is([data-qb-part=nav-item], [data-qb-label]) { text-transform: var(--qb-label-case); letter-spacing: var(--qb-label-tracking); } }' ) // Every card in the portal takes the design's card style: raised cards float // without an edge, list cards are rows divided by rules, filled cards are @@ -252,5 +256,6 @@ export function renderDesignCss(resolved: ResolvedDesign): string { parts.push( '@layer design { [data-qb-status=ink][data-portal-theme] [data-qb-status-dot] { background: var(--qb-ink); } [data-qb-status=pill][data-portal-theme] [data-qb-part=status-badge] { padding: 0.125rem 0.5rem; border-radius: var(--qb-radius-control); background: color-mix(in oklch, var(--qb-status, var(--qb-muted)) 18%, var(--qb-surface)); } [data-qb-status=pill][data-portal-theme] [data-qb-part=status-badge] > [data-qb-status-dot] { display: none; } }' ) + if (resolved.tuneCss) parts.push(resolved.tuneCss) return parts.join(' ') } diff --git a/apps/web/src/lib/shared/design/schema.ts b/apps/web/src/lib/shared/design/schema.ts index 9f683ef119..386c38a19e 100644 --- a/apps/web/src/lib/shared/design/schema.ts +++ b/apps/web/src/lib/shared/design/schema.ts @@ -3,6 +3,7 @@ * AI, REST and MCP read and write. Every choice comes from DESIGN_OPTIONS. */ import { z } from 'zod' +import { tuneSchema } from './tune' import { roadmapIdSchema, typeIdSchema } from '@quackback/ids/zod' import { tiptapContentSchema } from '@/lib/shared/schemas/posts' import { isSafeThemeValue } from '@/lib/shared/theme/safe-theme-value' @@ -308,6 +309,8 @@ export const portalDesignV2 = z vocabulary: z .object({ post: z.enum(DESIGN_OPTIONS.postWord), board: z.enum(DESIGN_OPTIONS.boardWord) }) .strict(), + /** Exact values under the choices above; each one left out keeps the choice's. */ + tune: tuneSchema.optional(), /** The workspace's own CSS, in the tenant layer. */ css: z.string().max(50_000).optional(), }) diff --git a/apps/web/src/lib/shared/design/tune.ts b/apps/web/src/lib/shared/design/tune.ts new file mode 100644 index 0000000000..857d8ef085 --- /dev/null +++ b/apps/web/src/lib/shared/design/tune.ts @@ -0,0 +1,142 @@ +/** + * Exact values under the design's choices. A brand sits between the tiles' + * steps: its headline is 48px at weight 300, its buttons 40px tall with 4px + * corners. A design can say so exactly, and each value it leaves out keeps + * what the tiles choose. Every value is bounded, so no tune can break a page. + */ +import { z } from 'zod' + +const type = z + .object({ + /** Its size on a wide screen, in px; it scales down on a phone. */ + size: z.number().min(10).max(200).optional(), + weight: z.number().int().min(100).max(900).optional(), + /** Letter-spacing, in em. */ + tracking: z.number().min(-0.1).max(0.3).optional(), + /** Line height, as a multiple of the size. */ + leading: z.number().min(0.8).max(2.2).optional(), + }) + .strict() + +export const tuneSchema = z + .object({ + /** Page headlines. */ + headline: type.optional(), + /** Section headings, such as a Home block's title. */ + heading: type.optional(), + /** The menu and buttons. */ + label: type.omit({ leading: true }).optional(), + button: z + .object({ + /** Corners in px; 999 is a pill. */ + radius: z.number().min(0).max(999).optional(), + height: z.number().min(24).max(72).optional(), + }) + .strict() + .optional(), + card: z + .object({ + radius: z.number().min(0).max(48).optional(), + /** Edge width in px; 0 is none. */ + border: z.number().min(0).max(4).optional(), + shadow: z.enum(['none', 'soft', 'lifted']).optional(), + }) + .strict() + .optional(), + field: z + .object({ radius: z.number().min(0).max(999).optional() }) + .strict() + .optional(), + }) + .strict() + +export type Tune = z.output + +const px = (value: number) => `${+value.toFixed(2)}px` + +/** A wide-screen size that scales down on a phone, never below a readable floor. */ +function fluid(size: number, floor: number): string { + const min = Math.min(size, Math.max(floor, size * 0.55)) + return `clamp(${px(min)}, ${+((size / 1440) * 100).toFixed(3)}vw, ${px(size)})` +} + +type TokenMap = Record<`--${string}`, string> + +/** The tokens a tune sets over the tiles' own. */ +export function tuneTokens(tune: Tune | undefined): TokenMap { + if (!tune) return {} + const out: TokenMap = {} + const { headline, heading, label, button, card, field } = tune + if (headline?.size) { + out['--qb-headline-size'] = fluid(headline.size, 28) + // The other pages' titles sit a step below, as they do on the tiles' scale. + out['--qb-page-title-size'] = fluid(Math.max(headline.size * 0.62, heading?.size ?? 0, 24), 24) + } + if (headline?.weight) out['--qb-headline-weight'] = String(headline.weight) + if (headline?.tracking !== undefined) out['--qb-headline-tracking'] = `${headline.tracking}em` + if (headline?.leading) out['--qb-headline-leading'] = String(headline.leading) + if (heading?.size) out['--qb-heading-size'] = fluid(heading.size, 16) + if (heading?.weight) out['--qb-heading-weight'] = String(heading.weight) + if (heading?.tracking !== undefined) out['--qb-heading-tracking'] = `${heading.tracking}em` + if (heading?.leading) out['--qb-heading-leading'] = String(heading.leading) + if (label?.size) out['--qb-label-size'] = px(label.size) + if (label?.weight) out['--qb-label-weight'] = String(label.weight) + if (label?.tracking !== undefined) out['--qb-label-tracking'] = `${label.tracking}em` + if (button?.radius !== undefined) out['--qb-radius-button'] = px(button.radius) + if (button?.height) out['--qb-button-height'] = px(button.height) + if (card?.radius !== undefined) out['--qb-radius-card'] = px(card.radius) + if (field?.radius !== undefined) { + out['--qb-radius-field'] = px(field.radius) + out['--qb-radius-composer'] = px(field.radius) + } + return out +} + +const SHADOW = { + none: 'none', + soft: '0 1px 2px rgb(0 0 0 / 0.06), 0 1px 3px rgb(0 0 0 / 0.08)', + lifted: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', +} as const + +/** The rules a tune needs beyond its tokens, for the parts the tiles do not size. */ +export function tuneCss(tune: Tune | undefined): string { + if (!tune) return '' + const rules: string[] = [] + const { heading, label, button, card } = tune + if (heading?.size || heading?.tracking !== undefined || heading?.leading) + rules.push( + `[data-portal-theme] [data-qb-part=section-heading] { ${[ + heading.size && 'font-size: var(--qb-heading-size)', + heading.tracking !== undefined && 'letter-spacing: var(--qb-heading-tracking)', + heading.leading && 'line-height: var(--qb-heading-leading)', + ] + .filter(Boolean) + .join('; ')}; }` + ) + if (label?.size || label?.weight) + rules.push( + `[data-portal-theme] :is([data-qb-part=nav-item], [data-slot=button]) { ${[ + label.size && 'font-size: var(--qb-label-size)', + label.weight && 'font-weight: var(--qb-label-weight)', + ] + .filter(Boolean) + .join('; ')}; }` + ) + if (button?.height) + rules.push( + '[data-portal-theme] [data-slot=button][data-variant=default] { min-height: var(--qb-button-height); padding-inline: calc(var(--qb-button-height) * 0.45); }' + ) + if (card) { + const parts = [ + card.radius !== undefined && 'border-radius: var(--qb-radius-card)', + card.border !== undefined && `border-width: ${px(card.border)}`, + card.shadow && `box-shadow: ${SHADOW[card.shadow]}`, + ].filter(Boolean) + if (parts.length) + // After the card styles, at their weight, so a tune wins. + rules.push( + `[data-qb-cards][data-portal-theme] [data-qb-surface=card] { ${parts.join('; ')}; }` + ) + } + return rules.length ? `@layer design { ${rules.join(' ')} }` : '' +} diff --git a/apps/web/src/routes/_portal/hc/$locale/index.tsx b/apps/web/src/routes/_portal/hc/$locale/index.tsx index 7ac9871bbe..6fa6b88e73 100644 --- a/apps/web/src/routes/_portal/hc/$locale/index.tsx +++ b/apps/web/src/routes/_portal/hc/$locale/index.tsx @@ -118,7 +118,11 @@ function LocaleHelpCenterLandingPage() { style={{ animationDelay: '100ms' }} >
-

+

{collectionCount > 0 && ( diff --git a/apps/web/src/routes/_portal/hc/index.tsx b/apps/web/src/routes/_portal/hc/index.tsx index 6f1a773bdd..cd681a01d2 100644 --- a/apps/web/src/routes/_portal/hc/index.tsx +++ b/apps/web/src/routes/_portal/hc/index.tsx @@ -165,7 +165,11 @@ function HelpCenterLandingPage() { style={{ animationDelay: '100ms' }} >
-

+

{collectionCount > 0 && ( From a003e4439a69a25855cca687f79c5a604088818f Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 17:12:26 +0000 Subject: [PATCH 02/16] feat(design): the website start measures the brand's type and shapes The reader finds a page's headline, a section heading, its main button, a card and a field in its HTML, and applies the CSS rules that match them in cascade order, utility classes included, with rem, clamp(), min(), max(), vw and the page's custom properties resolved as a wide, light screen sees them. Rules for states, theme scopes, dark themes and small screens are left out, and what cannot be read stays unmeasured. No browser is needed. The faithful look takes what was measured as exact values, and every look's brief says what was measured so its steps start near it. A tracking measured as none is kept as none. --- .../ai/__tests__/design-directions.test.ts | 25 +- .../design/ai/__tests__/site-measure.test.ts | 56 ++++ .../design/ai/__tests__/site-signals.test.ts | 2 + .../domains/design/ai/design-directions.ts | 50 ++- .../server/domains/design/ai/site-measure.ts | 313 ++++++++++++++++++ .../server/domains/design/ai/site-signals.ts | 12 +- .../design/__tests__/measured-tune.test.ts | 2 +- .../src/lib/shared/design/measured-tune.ts | 5 +- 8 files changed, 458 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts create mode 100644 apps/web/src/lib/server/domains/design/ai/site-measure.ts diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts index 1650944d89..2316a3beea 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts @@ -8,7 +8,8 @@ import { defaultDesign } from '@/lib/shared/design/schema' vi.mock('@/lib/server/config', () => ({ config: {} })) -const { DIRECTIONS_EXAMPLE, faithfulAccent, lookDesign } = await import('../design-directions') +const { DIRECTIONS_EXAMPLE, faithfulAccent, lookDesign, withMeasuredTune } = + await import('../design-directions') const every = [ 'welcome', @@ -279,3 +280,25 @@ describe('the faithful look’s accent', () => { expect(faithfulAccent('#3d7a2e', { palette: ['#9fe870'] })).toBe('#9fe870') }) }) + +describe('the faithful look’s measured type and shapes', () => { + it('takes the brand’s own values, exactly as its page sets them', () => { + const site = { + measured: { + headline: { size: 48, weight: 300, tracking: -0.02, leading: 1.15 }, + button: { radius: 4, height: 40 }, + }, + } as never + const design = withMeasuredTune(defaultDesign(), site, allows) + expect(design.tune).toEqual({ + headline: { size: 48, weight: 300, tracking: -0.02, leading: 1.15 }, + button: { radius: 4, height: 40 }, + }) + }) + + it('leaves the design alone when nothing was measured', () => { + const design = defaultDesign() + expect(withMeasuredTune(design, { measured: null } as never, allows)).toBe(design) + expect(withMeasuredTune(design, null, allows)).toBe(design) + }) +}) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts new file mode 100644 index 0000000000..76af74c4cb --- /dev/null +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts @@ -0,0 +1,56 @@ +/** + * A brand's type and shapes, read from its HTML and CSS: the rules that match + * each element apply in cascade order, sizes resolve as a wide screen sees + * them, and what cannot be read stays unmeasured. + */ +import { describe, expect, it } from 'vitest' +import { measureSite } from '../site-measure' + +const PAGE = ` +
Sign up
+

Payments infrastructure

+

Modular solutions

+
A card
+ +` + +describe('measuring a brand from its page', () => { + it('applies the matching rules in cascade order, utility classes included', () => { + const m = measureSite(PAGE, [ + `:root { --hero: clamp(2rem, 4vw, 3rem); --brand-radius: 4px } + h1 { font-size: 2em; font-weight: 700 } + .text-display { font-size: var(--hero); font-weight: 300; letter-spacing: -0.02em; line-height: 1.15 } + .section-title { font: 300 2rem/1.1 sohne, sans-serif } + .btn { border-radius: 999px; min-height: 2.5rem } + .btn--primary { border-radius: var(--brand-radius) } + .card { border-radius: 6px; box-shadow: none; border: 1px solid #eee } + input.field { border-radius: 8px }`, + ]) + expect(m.headline).toEqual({ size: 48, weight: 300, tracking: -0.02, leading: 1.15 }) + expect(m.heading).toEqual({ size: 32, weight: 300, leading: 1.1 }) + expect(m.button).toEqual({ radius: 4, height: 40 }) + expect(m.card).toEqual({ radius: 6, borderWidth: 1, shadow: false }) + expect(m.field).toEqual({ radius: 8 }) + }) + + it('reads a wide, light screen: small-screen and dark rules are left out', () => { + const m = measureSite(PAGE, [ + `.text-display { font-size: 64px } + @media (max-width: 600px) { .text-display { font-size: 28px } } + @media (prefers-color-scheme: dark) { .text-display { font-size: 12px } } + @media (min-width: 1024px) { .text-display { font-size: 72px } }`, + ]) + expect(m.headline?.size).toBe(72) + }) + + it('leaves out what it cannot read rather than guess', () => { + const m = measureSite(PAGE, [ + `.text-display:hover { font-size: 90px } + [data-theme] h1 { font-size: 90px } + .btn { border-radius: var(--missing) }`, + ]) + expect(m.headline).toBeNull() + expect(m.button).toBeNull() + expect(m.card).toBeNull() + }) +}) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/site-signals.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/site-signals.test.ts index a8af7f47be..00cc1ded19 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/site-signals.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/site-signals.test.ts @@ -86,6 +86,8 @@ describe('readSiteSignals', () => { // The header logo with the colour its page gives it, and the link preview image. logo: { svg: '' }, shareImage: 'https://example.com/og.jpg', + // Nothing on this page sizes its type or shapes. + measured: { headline: null, heading: null, button: null, card: null, field: null }, }, }) expect(web.asked[0]!.agent).toMatch(/QuackbackBrandReader/) diff --git a/apps/web/src/lib/server/domains/design/ai/design-directions.ts b/apps/web/src/lib/server/domains/design/ai/design-directions.ts index 88a71f83a6..1b5b4a40b4 100644 --- a/apps/web/src/lib/server/domains/design/ai/design-directions.ts +++ b/apps/web/src/lib/server/domains/design/ai/design-directions.ts @@ -20,6 +20,11 @@ import type { PortalDesign } from '@/lib/shared/design/schema' import { designAiModel, isStructuredOutputError, paletteText } from './design-ai' import type { SiteAssets } from './site-assets' import type { SiteSignals } from './site-signals' +import { + tuneFromMeasurements, + type BrandMeasurements, + type MeasuredType, +} from '@/lib/shared/design/measured-tune' const log = logger.child({ module: 'design-directions' }) @@ -196,6 +201,25 @@ function accentChanges(look: Look, headline: string, title: string) { : [] } +/** + * The faithful look with the brand's own type and shapes, exactly as its page + * sets them, where they could be read. The other looks are directions of + * their own and keep their tiles' steps. + */ +export function withMeasuredTune( + design: PortalDesign, + site: SiteSignals | null | undefined, + allows: Omit +): PortalDesign { + const tune = site?.measured ? tuneFromMeasurements(site.measured) : {} + if (!Object.keys(tune).length) return design + return applyDesignChanges(design, [{ path: 'tune', value: tune }], 'site', { + ...allows, + links: new Set(), + files: new Set(), + }).design +} + /** A look's design: its preset with every choice it makes that the design accepts. */ export function lookDesign( base: PortalDesign, @@ -251,6 +275,23 @@ function assetsBrief(assets: SiteAssets | null, imageFirst: boolean): string | n .join(' ') } +const typeText = (t: MeasuredType | null | undefined) => + t?.size ? `${Math.round(t.size)}px${t.weight ? ` at weight ${t.weight}` : ''}` : null + +/** What was measured on the page, so the looks' steps start near it. */ +function measuredBrief(m: BrandMeasurements | null): string | null { + if (!m) return null + const button = m.button?.radius ?? null + const card = m.card?.radius ?? null + const parts = [ + typeText(m.headline) && `headlines ${typeText(m.headline)}`, + typeText(m.heading) && `section headings ${typeText(m.heading)}`, + button !== null && `buttons with ${button >= 999 ? 'pill' : `${Math.round(button)}px`} corners`, + card !== null && `cards with ${Math.round(card)}px corners`, + ].filter(Boolean) + return parts.length ? `Measured on its page: ${parts.join(', ')}.` : null +} + /** What the website says about its brand, as the model reads it. */ function siteBrief(site: SiteSignals): string { return [ @@ -265,6 +306,7 @@ function siteBrief(site: SiteSignals): string { site.monochrome && 'It is a monochrome brand: black buttons and little colour. Keep the first look monochrome: a near-black accent, white pages and no coloured band.', site.bold && 'It fills large areas, such as its hero, with its brand colour.', + measuredBrief(site.measured), ] .filter(Boolean) .join(' ') @@ -358,7 +400,13 @@ export async function designDirections(input: { support: answered.support && faithfulAccent(answered.support, input), } : answered - const { design, refused } = lookDesign(input.design, look, input.allows, input.assets) + const { design: looked, refused } = lookDesign( + input.design, + look, + input.allows, + input.assets + ) + const design = index === 0 ? withMeasuredTune(looked, input.site, input.allows) : looked if (refused.length) log.info({ look: look.name, refused }, 'design look left choices out') return { name: fit(look.name, 40), diff --git a/apps/web/src/lib/server/domains/design/ai/site-measure.ts b/apps/web/src/lib/server/domains/design/ai/site-measure.ts new file mode 100644 index 0000000000..fd890ecee4 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/ai/site-measure.ts @@ -0,0 +1,313 @@ +/** + * A brand's type and shapes, measured from its page's HTML and CSS without a + * browser: the headline, a section heading, the main button, a card and a + * field are found in the HTML, and the rules whose selectors match them are + * applied in cascade order, utility classes included. Sizes resolve rem, + * clamp(), min(), max(), vw at a wide screen and the page's custom properties. + * Rules for small screens and dark themes are left out. What cannot be read is + * left unmeasured rather than guessed. + */ +import type { + BrandMeasurements, + MeasuredBox, + MeasuredType, +} from '@/lib/shared/design/measured-tune' + +interface Rule { + selectors: string[] + decls: Map + order: number +} + +const ROOT_PX = 16 +const VIEWPORT = 1440 + +/** Rules at the top level and in queries that hold on a wide, light screen. */ +function parseRules(css: string): Rule[] { + const rules: Rule[] = [] + let order = 0 + const walk = (text: string, skip: boolean) => { + let i = 0 + while (i < text.length) { + const open = text.indexOf('{', i) + if (open < 0) break + const prelude = text + .slice(i, open) + .replace(/\/\*[\s\S]*?\*\//g, '') + .trim() + // Find the matching close brace. + let depth = 1 + let j = open + 1 + while (j < text.length && depth > 0) { + if (text[j] === '{') depth++ + else if (text[j] === '}') depth-- + j++ + } + const body = text.slice(open + 1, j - 1) + if (prelude.startsWith('@')) { + const at = prelude.toLowerCase() + const narrow = + /max-width|prefers-color-scheme:\s*dark|print|hover:\s*none|pointer:\s*coarse/.test(at) + if (/^@(media|supports|layer|container)/.test(at)) walk(body, skip || narrow) + } else if (!skip && prelude) { + const decls = new Map() + for (const part of body.split(';')) { + const colon = part.indexOf(':') + if (colon < 0) continue + decls.set( + part.slice(0, colon).trim().toLowerCase(), + part + .slice(colon + 1) + .replace(/!important/i, '') + .trim() + ) + } + rules.push({ selectors: prelude.split(',').map((s) => s.trim()), decls, order: order++ }) + } + i = j + } + } + walk(css, false) + return rules +} + +interface Element { + tag: string + id: string | null + classes: Set +} + +const DYNAMIC = + /:(hover|focus|active|visited|focus-visible|focus-within|disabled|checked|before|after|placeholder|not|has|is|where|nth|first|last|only|empty|target|selection)/ + +/** The specificity of a selector's last compound when it matches, or null. */ +function matches(selector: string, el: Element): [number, number, number] | null { + // States, attribute scopes such as a theme, and dark themes are not the page as it first shows. + if (DYNAMIC.test(selector) || selector.includes('[') || /\.(dark|theme-dark)\b/i.test(selector)) + return null + const last = selector + .split(/\s*[\s>+~]\s*/) + .filter(Boolean) + .pop() + if (!last || last.includes('*')) return null + const tag = /^[a-z][a-z0-9-]*/i.exec(last)?.[0]?.toLowerCase() + if (tag && tag !== el.tag) return null + const ids = [...last.matchAll(/#([\w-]+)/g)].map((m) => m[1]) + if (ids.some((id) => id !== el.id)) return null + const classes = [...last.matchAll(/\.((?:\\.|[\w-])+)/g)].map((m) => + m[1]!.replace(/\\(.)/g, '$1') + ) + if (classes.some((c) => !el.classes.has(c))) return null + if (!tag && !ids.length && !classes.length) return null + return [ids.length, classes.length, tag ? 1 : 0] +} + +/** The declarations that win for an element, by specificity then source order. */ +function cascade(rules: Rule[], el: Element): Map { + const hits: Array<{ spec: [number, number, number]; rule: Rule }> = [] + for (const rule of rules) + for (const selector of rule.selectors) { + const spec = matches(selector, el) + if (spec) hits.push({ spec, rule }) + } + hits.sort( + (a, b) => + a.spec[0] - b.spec[0] || + a.spec[1] - b.spec[1] || + a.spec[2] - b.spec[2] || + a.rule.order - b.rule.order + ) + const out = new Map() + for (const { rule } of hits) for (const [k, v] of rule.decls) out.set(k, v) + return out +} + +/** The page's custom properties, from its root rules. */ +function customProperties(rules: Rule[]): Map { + const props = new Map() + for (const rule of rules) + if (rule.selectors.some((s) => /^(:root|html|body)$/i.test(s))) + for (const [k, v] of rule.decls) if (k.startsWith('--')) props.set(k, v) + return props +} + +function resolveVars(value: string, props: Map, depth = 0): string { + if (depth > 6) return value + return value.replace( + /var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\))?[^()]*))?\)/g, + (_, name: string, fallback?: string) => + resolveVars(props.get(name) ?? fallback ?? '', props, depth + 1) + ) +} + +/** A length in px at a wide screen, or null when it cannot be read. */ +function length(raw: string | undefined, props: Map, em = ROOT_PX): number | null { + if (!raw) return null + const value = resolveVars(raw, props).trim() + const fn = /^(clamp|min|max)\((.*)\)$/i.exec(value) + if (fn) { + const args = splitArgs(fn[2]!).map((a) => length(a, props, em)) + if (args.some((a) => a === null)) + return fn[1]!.toLowerCase() === 'clamp' ? (args[2] ?? null) : null + const nums = args as number[] + if (fn[1]!.toLowerCase() === 'clamp') return Math.min(Math.max(nums[1]!, nums[0]!), nums[2]!) + return fn[1]!.toLowerCase() === 'min' ? Math.min(...nums) : Math.max(...nums) + } + const calc = /^calc\((.*)\)$/i.exec(value) + if (calc) { + const terms = calc[1]!.split(/\s+([+-])\s+/) + let total = length(terms[0], props, em) + for (let i = 1; i < terms.length && total !== null; i += 2) { + const next = length(terms[i + 1], props, em) + total = next === null ? null : terms[i] === '+' ? total + next : total - next + } + return total + } + const unit = /^(-?[\d.]+)(px|rem|em|vw|%)?$/i.exec(value) + if (!unit) return null + const n = parseFloat(unit[1]!) + switch ((unit[2] ?? '').toLowerCase()) { + case 'px': + return n + case 'rem': + return n * ROOT_PX + case 'em': + return n * em + case 'vw': + return (n * VIEWPORT) / 100 + case '': + return n === 0 ? 0 : null + default: + return null + } +} + +function splitArgs(text: string): string[] { + const out: string[] = [] + let depth = 0 + let start = 0 + for (let i = 0; i < text.length; i++) { + if (text[i] === '(') depth++ + else if (text[i] === ')') depth-- + else if (text[i] === ',' && depth === 0) { + out.push(text.slice(start, i).trim()) + start = i + 1 + } + } + out.push(text.slice(start).trim()) + return out +} + +const WEIGHTS: Record = { normal: 400, bold: 700, lighter: 300, bolder: 800 } + +/** The longhands a font shorthand sets: its weight, size and line height. */ +function expandFont(decls: Map, props: Map) { + const font = decls.get('font') && resolveVars(decls.get('font')!, props) + if (!font) return decls + const out = new Map(decls) + const m = + /(?:^|\s)(\d{3}|bold|bolder|lighter|normal)?\s*(?:italic|oblique)?\s*(?:\d{3}|bold)?\s*([\d.]+(?:px|rem|em|vw|%))(?:\s*\/\s*([\d.]+(?:px|rem|em|%)?))?/i.exec( + font + ) + const weight = /(?:^|\s)(\d{3}|bold|bolder|lighter)(?=\s)/i.exec(font)?.[1] + if (m?.[2] && !decls.has('font-size')) out.set('font-size', m[2]) + if (!decls.has('line-height')) out.set('line-height', m?.[3] ?? 'normal') + if (!decls.has('font-weight')) out.set('font-weight', weight ?? 'normal') + return out +} + +function typeOf(decls: Map, props: Map): MeasuredType | null { + const size = length(decls.get('font-size'), props) + if (!size) return null + const rawWeight = decls.get('font-weight') && resolveVars(decls.get('font-weight')!, props).trim() + const weight = rawWeight ? (WEIGHTS[rawWeight] ?? (Number(rawWeight) || undefined)) : undefined + const spacing = decls.get('letter-spacing') + const tracking = spacing + ? /em$/.test(resolveVars(spacing, props).trim()) + ? parseFloat(resolveVars(spacing, props)) + : (length(spacing, props, size) ?? 0) / size + : undefined + const lh = decls.get('line-height') && resolveVars(decls.get('line-height')!, props).trim() + const leading = + !lh || lh === 'normal' + ? undefined + : /^[\d.]+$/.test(lh) + ? parseFloat(lh) + : /%$/.test(lh) + ? parseFloat(lh) / 100 + : (length(lh, props, size) ?? NaN) / size + return { + size, + ...(weight && { weight }), + ...(tracking !== undefined && Number.isFinite(tracking) && { tracking }), + ...(leading !== undefined && Number.isFinite(leading) && { leading }), + } +} + +function boxOf(decls: Map, props: Map): MeasuredBox | null { + const radiusRaw = decls.get('border-radius')?.split(/\s+/)[0] + const radius = radiusRaw && /^(50%|100%)$/.test(radiusRaw.trim()) ? 999 : length(radiusRaw, props) + const height = length(decls.get('min-height') ?? decls.get('height'), props) + const border = decls.get('border-width') ?? /^([\d.]+px)\b/.exec(decls.get('border') ?? '')?.[1] + const shadow = decls.get('box-shadow') + const out: MeasuredBox = { + ...(radius !== null && radius !== undefined && { radius: radius >= 500 ? 999 : radius }), + ...(height && height > 0 && { height }), + ...(border !== undefined && + length(border, props) !== null && { borderWidth: length(border, props)! }), + ...(shadow !== undefined && { shadow: !/^none$/i.test(shadow.trim()) }), + } + return Object.keys(out).length ? out : null +} + +/** The opening tags in a page, as elements. */ +function elements(html: string): Array { + const out: Array = [] + const body = html.replace(/<(script|style|svg|noscript|template)[\s\S]*?<\/\1>/gi, '') + for (const m of body.matchAll(/<([a-z][a-z0-9]*)\b([^>]*)>/gi)) { + const attrs = m[2] ?? '' + out.push({ + tag: m[1]!.toLowerCase(), + id: /\bid\s*=\s*["']([^"']+)/i.exec(attrs)?.[1] ?? null, + classes: new Set( + (/\bclass\s*=\s*["']([^"']*)/i.exec(attrs)?.[1] ?? '').split(/\s+/).filter(Boolean) + ), + attrs, + }) + if (out.length > 4000) break + } + return out +} + +const isButtonish = (el: Element & { attrs: string }) => + (el.tag === 'a' || el.tag === 'button') && + ([...el.classes].some((c) => /(^|[-_])(btn|button|cta)([-_]|$)/i.test(c)) || + /role\s*=\s*["']button/i.test(el.attrs)) && + ![...el.classes].some((c) => /(close|icon|menu|toggle|skip|cookie|link)/i.test(c)) + +/** A page's type and shapes, as far as its HTML and CSS say. */ +export function measureSite(html: string, css: string[]): BrandMeasurements { + const rules = parseRules(css.join('\n')) + const props = customProperties(rules) + // A shorthand sets its longhands where its own rule does not, so the cascade weighs them by rule. + for (const rule of rules) rule.decls = expandFont(rule.decls, props) + const els = elements(html) + const styleOf = (el: Element | undefined) => (el ? cascade(rules, el) : null) + const first = (pick: (el: Element & { attrs: string }) => boolean) => els.find(pick) + const h1 = styleOf(first((el) => el.tag === 'h1')) + const h2 = styleOf(first((el) => el.tag === 'h2')) + const button = styleOf(first(isButtonish)) + const card = styleOf(first((el) => [...el.classes].some((c) => /(^|[-_])card([-_]|$)/i.test(c)))) + const field = styleOf( + first( + (el) => el.tag === 'input' && !/type\s*=\s*["'](hidden|checkbox|radio|submit)/i.test(el.attrs) + ) + ) + return { + headline: h1 && typeOf(h1, props), + heading: h2 && typeOf(h2, props), + button: button && boxOf(button, props), + card: card && boxOf(card, props), + field: field && boxOf(field, props), + } +} diff --git a/apps/web/src/lib/server/domains/design/ai/site-signals.ts b/apps/web/src/lib/server/domains/design/ai/site-signals.ts index c54209848d..3fbed159ea 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-signals.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-signals.ts @@ -16,6 +16,8 @@ import { isStandardWebsiteUrl, parseWebsiteInput } from '@/lib/shared/website-br import { isBlockPage, pageLinks, readSiteBrand, type SiteBrand } from './site-brand' import { colourVividness } from '@/lib/shared/design/palette' import { findHeaderLogo, findShareImage, resolveSvgColours, type LogoCandidate } from './site-logo' +import { measureSite } from './site-measure' +import type { BrandMeasurements } from '@/lib/shared/design/measured-tune' /** * The colour a logo drawn in the page's current colour most likely takes: a @@ -36,6 +38,8 @@ export interface SiteSignals extends SiteBrand { logo: LogoCandidate | null /** The image it gives link previews. */ shareImage: string | null + /** Its type and shapes as its page's CSS sets them, as far as it can be read. */ + measured: BrandMeasurements | null } export type SiteRead = @@ -157,7 +161,8 @@ export async function readSiteSignals(site: string): Promise { }, 'website read for its brand' ) - return { ok: true, signals: { domain: host, ...brand, logo, shareImage } } + const measured = measureSite(found.html, sheets) + return { ok: true, signals: { domain: host, ...brand, logo, shareImage, measured } } } if (blocked) { // The page refused, but a manifest is a static file sites often serve to anyone. @@ -165,7 +170,10 @@ export async function readSiteSignals(site: string): Promise { const brand = siteManifest ? readSiteBrand({ html: '', css: [], manifest: siteManifest }) : null if (brand && (brand.colour || brand.title)) { log.info({ host }, 'website blocked its page; read its manifest') - return { ok: true, signals: { domain: host, ...brand, logo: null, shareImage: null } } + return { + ok: true, + signals: { domain: host, ...brand, logo: null, shareImage: null, measured: null }, + } } } log.info({ host, blocked }, 'website could not be read for its brand') diff --git a/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts b/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts index 5ddf2745bd..ce0aa0e0d8 100644 --- a/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts +++ b/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts @@ -19,7 +19,7 @@ describe('exact values from a brand’s measurements', () => { }) expect(tune).toEqual({ headline: { size: 48, weight: 300, tracking: -0.02, leading: 1.15 }, - heading: { size: 32, weight: 300, leading: 1.1 }, + heading: { size: 32, weight: 300, tracking: 0, leading: 1.1 }, label: { size: 14, weight: 400 }, button: { radius: 4, height: 40 }, card: { radius: 6, border: 0, shadow: 'none' }, diff --git a/apps/web/src/lib/shared/design/measured-tune.ts b/apps/web/src/lib/shared/design/measured-tune.ts index 2f3cbeffd8..07080926c4 100644 --- a/apps/web/src/lib/shared/design/measured-tune.ts +++ b/apps/web/src/lib/shared/design/measured-tune.ts @@ -42,8 +42,9 @@ function type(m: MeasuredType | null | undefined, [min, max]: [number, number]) const out: NonNullable = {} if (finite(m.size)) out.size = within(Math.round(m.size), min, max) if (finite(m.weight)) out.weight = within(step(m.weight, 100), 100, 900) - if (finite(m.tracking) && Math.abs(m.tracking) >= 0.005) - out.tracking = within(+m.tracking.toFixed(3), -0.1, 0.3) + // Measured as none, tracking is none: the portal's own default would add some. + if (finite(m.tracking)) + out.tracking = Math.abs(m.tracking) < 0.005 ? 0 : within(+m.tracking.toFixed(3), -0.1, 0.3) if (finite(m.leading)) out.leading = within(+m.leading.toFixed(2), 0.9, 1.8) return Object.keys(out).length ? out : undefined } From be75ebf88a730de9e474a48b1c26073a5df6ba74 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 17:17:03 +0000 Subject: [PATCH 03/16] feat(design): exact colours and button type, where they read A tune can colour headlines and section headings and fill primary buttons with their own label, weight and case, in light mode. Each colour is checked where it sits: a headline at 3:1 on the page and the band (and not at all across a blend or an image), a section heading at 3:1 on the page and a card, and a button's label at 4.5:1 on its fill, else the fill's best label. A colour that would not read is left out. The website reader measures the headline's and heading's colours and the main button's fill, label, weight and case, and the faithful look takes them. --- .../design/ai/__tests__/site-measure.test.ts | 10 +++ .../server/domains/design/ai/site-brand.ts | 3 +- .../server/domains/design/ai/site-measure.ts | 13 ++++ .../design/__tests__/measured-tune.test.ts | 13 ++++ .../lib/shared/design/__tests__/tune.test.ts | 52 ++++++++++++++- .../src/lib/shared/design/measured-tune.ts | 24 ++++++- apps/web/src/lib/shared/design/resolve.ts | 36 +++++++++-- apps/web/src/lib/shared/design/tune.ts | 64 ++++++++++++++++++- 8 files changed, 201 insertions(+), 14 deletions(-) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts index 76af74c4cb..16b1b5a8ae 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts @@ -33,6 +33,16 @@ describe('measuring a brand from its page', () => { expect(m.field).toEqual({ radius: 8 }) }) + it('reads the colours and type of the headline and the button', () => { + const m = measureSite(PAGE, [ + `:root { --forest: #163300 } + .text-display { font-size: 64px; color: var(--forest) } + .btn--primary { background: rgb(22, 51, 0); color: #9fe870; font-weight: 600; text-transform: uppercase }`, + ]) + expect(m.headline).toEqual({ size: 64, colour: '#163300' }) + expect(m.button).toEqual({ fill: '#163300', ink: '#9fe870', weight: 600, upper: true }) + }) + it('reads a wide, light screen: small-screen and dark rules are left out', () => { const m = measureSite(PAGE, [ `.text-display { font-size: 64px } diff --git a/apps/web/src/lib/server/domains/design/ai/site-brand.ts b/apps/web/src/lib/server/domains/design/ai/site-brand.ts index 049966b98a..42d89fe2e2 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-brand.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-brand.ts @@ -109,7 +109,8 @@ export function pageLinks(html: string, base: string) { } /** A CSS colour as hex, or null for one that is transparent or not a colour. */ -function hex(value: string): string | null { +/** A CSS colour as six-digit hex, or null when it is not one. */ +export function hex(value: string): string | null { const colour = normaliseColor(value.trim().replace(/\s*!important$/, '')) if (!colour) return null return colour.startsWith('#') ? colour : oklchToHex(colour) diff --git a/apps/web/src/lib/server/domains/design/ai/site-measure.ts b/apps/web/src/lib/server/domains/design/ai/site-measure.ts index fd890ecee4..fff8cae7ee 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-measure.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-measure.ts @@ -12,6 +12,7 @@ import type { MeasuredBox, MeasuredType, } from '@/lib/shared/design/measured-tune' +import { hex } from './site-brand' interface Rule { selectors: string[] @@ -236,9 +237,11 @@ function typeOf(decls: Map, props: Map): Measure : /%$/.test(lh) ? parseFloat(lh) / 100 : (length(lh, props, size) ?? NaN) / size + const colour = decls.get('color') && hex(resolveVars(decls.get('color')!, props)) return { size, ...(weight && { weight }), + ...(colour && { colour }), ...(tracking !== undefined && Number.isFinite(tracking) && { tracking }), ...(leading !== undefined && Number.isFinite(leading) && { leading }), } @@ -250,12 +253,22 @@ function boxOf(decls: Map, props: Map): Measured const height = length(decls.get('min-height') ?? decls.get('height'), props) const border = decls.get('border-width') ?? /^([\d.]+px)\b/.exec(decls.get('border') ?? '')?.[1] const shadow = decls.get('box-shadow') + const background = decls.get('background-color') ?? decls.get('background') + const fill = background && hex(resolveVars(background, props).split(/\s+(?![^(]*\))/)[0] ?? '') + const ink = decls.get('color') && hex(resolveVars(decls.get('color')!, props)) + const rawWeight = decls.get('font-weight') && resolveVars(decls.get('font-weight')!, props).trim() + const weight = rawWeight ? (WEIGHTS[rawWeight] ?? (Number(rawWeight) || undefined)) : undefined + const transform = decls.get('text-transform')?.trim().toLowerCase() const out: MeasuredBox = { ...(radius !== null && radius !== undefined && { radius: radius >= 500 ? 999 : radius }), ...(height && height > 0 && { height }), ...(border !== undefined && length(border, props) !== null && { borderWidth: length(border, props)! }), ...(shadow !== undefined && { shadow: !/^none$/i.test(shadow.trim()) }), + ...(fill && { fill }), + ...(ink && { ink }), + ...(weight && { weight }), + ...(transform && { upper: transform === 'uppercase' }), } return Object.keys(out).length ? out : null } diff --git a/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts b/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts index ce0aa0e0d8..5c780542a6 100644 --- a/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts +++ b/apps/web/src/lib/shared/design/__tests__/measured-tune.test.ts @@ -43,6 +43,19 @@ describe('exact values from a brand’s measurements', () => { expect(tuneSchema.safeParse(tune).success).toBe(true) }) + it('keeps the colours and button type it measured', () => { + const tune = tuneFromMeasurements({ + headline: { size: 64, colour: '#163300' }, + label: { size: 15, colour: '#163300' }, + button: { fill: '#163300', ink: '#9FE870', weight: 600, upper: true }, + }) + expect(tune.headline).toEqual({ size: 64, colour: '#163300' }) + // A label's colour is the menu's own, not one a tune sets. + expect(tune.label).toEqual({ size: 15 }) + expect(tune.button).toEqual({ fill: '#163300', ink: '#9fe870', weight: 600, case: 'upper' }) + expect(tuneSchema.safeParse(tune).success).toBe(true) + }) + it('sets nothing it did not measure', () => { expect(tuneFromMeasurements({ headline: null, button: { radius: null } })).toEqual({}) }) diff --git a/apps/web/src/lib/shared/design/__tests__/tune.test.ts b/apps/web/src/lib/shared/design/__tests__/tune.test.ts index 4990e6d99b..6c8a881553 100644 --- a/apps/web/src/lib/shared/design/__tests__/tune.test.ts +++ b/apps/web/src/lib/shared/design/__tests__/tune.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' import { renderDesignCss, resolveDesign } from '../resolve' import { defaultDesign, portalDesign } from '../schema' -import { tuneCss, tuneTokens } from '../tune' +import { tuneColourCss, tuneCss, tuneTokens } from '../tune' describe('exact values', () => { it('change nothing when a design has none', () => { @@ -74,3 +74,53 @@ describe('exact values', () => { expect(portalDesign.safeParse({ ...doc, tune: { card: { glow: 1 } } }).success).toBe(false) }) }) + +describe('exact colours', () => { + const page = { background: '#ffffff', band: null, mixed: false } + + it('colour headlines and section headings where they read, in light mode', () => { + const css = tuneColourCss( + { headline: { colour: '#163300' }, heading: { colour: '#163300' } }, + { background: '#ffffff', band: '#9fe870', mixed: false } + ) + expect(css).toContain( + '[data-portal-theme]:not(.dark) [data-qb-part=headline] { color: #163300; }' + ) + expect(css).toContain('[data-qb-part=section-heading] { color: #163300; }') + }) + + it('leave out a colour that would not read on the page or the band', () => { + expect(tuneColourCss({ headline: { colour: '#f0f0f0' } }, page)).toBe('') + expect( + tuneColourCss( + { headline: { colour: '#163300' } }, + { background: '#ffffff', band: '#0b1020', mixed: false } + ) + ).toBe('') + // No one colour reads across a blend or an image. + expect( + tuneColourCss( + { headline: { colour: '#163300' } }, + { background: '#ffffff', band: null, mixed: true } + ) + ).toBe('') + }) + + it('fill buttons exactly, with their own label where it reads and the fill’s best label where not', () => { + expect( + tuneColourCss( + { button: { fill: '#163300', ink: '#9fe870', case: 'upper', weight: 600 } }, + page + ) + ).toContain( + 'background-color: #163300; color: #9fe870; font-weight: 600; text-transform: uppercase' + ) + expect(tuneColourCss({ button: { fill: '#163300', ink: '#1f3f10' } }, page)).toContain( + 'background-color: #163300; color: #ffffff' + ) + }) + + it('change nothing without a light page', () => { + expect(tuneColourCss({ headline: { colour: '#163300' } }, null)).toBe('') + }) +}) diff --git a/apps/web/src/lib/shared/design/measured-tune.ts b/apps/web/src/lib/shared/design/measured-tune.ts index 07080926c4..265838e660 100644 --- a/apps/web/src/lib/shared/design/measured-tune.ts +++ b/apps/web/src/lib/shared/design/measured-tune.ts @@ -13,6 +13,8 @@ export interface MeasuredType { tracking?: number /** As a multiple of the size. */ leading?: number + /** Six-digit hex. */ + colour?: string } export interface MeasuredBox { @@ -21,6 +23,11 @@ export interface MeasuredBox { height?: number borderWidth?: number shadow?: boolean + /** A button's fill and label, six-digit hex. */ + fill?: string + ink?: string + weight?: number + upper?: boolean } export interface BrandMeasurements { @@ -46,9 +53,12 @@ function type(m: MeasuredType | null | undefined, [min, max]: [number, number]) if (finite(m.tracking)) out.tracking = Math.abs(m.tracking) < 0.005 ? 0 : within(+m.tracking.toFixed(3), -0.1, 0.3) if (finite(m.leading)) out.leading = within(+m.leading.toFixed(2), 0.9, 1.8) + if (m.colour && HEX.test(m.colour)) out.colour = m.colour.toLowerCase() return Object.keys(out).length ? out : undefined } +const HEX = /^#[0-9a-f]{6}$/i + const corners = (radius: number | null | undefined, max: number) => !finite(radius) ? undefined : radius >= 999 ? 999 : within(Math.round(radius), 0, max) @@ -64,13 +74,21 @@ export function tuneFromMeasurements(m: BrandMeasurements): Tune { const label = type(m.label, [12, 18]) if (label) { delete label.leading - tune.label = label + delete label.colour + if (Object.keys(label).length) tune.label = label } if (m.button) { const radius = corners(m.button.radius, 999) const height = finite(m.button.height) ? within(Math.round(m.button.height), 28, 60) : undefined - if (radius !== undefined || height !== undefined) - tune.button = { ...(radius !== undefined && { radius }), ...(height && { height }) } + const button: NonNullable = { + ...(radius !== undefined && { radius }), + ...(height && { height }), + ...(m.button.fill && HEX.test(m.button.fill) && { fill: m.button.fill.toLowerCase() }), + ...(m.button.ink && HEX.test(m.button.ink) && { ink: m.button.ink.toLowerCase() }), + ...(finite(m.button.weight) && { weight: within(step(m.button.weight, 100), 100, 900) }), + ...(typeof m.button.upper === 'boolean' && { case: m.button.upper ? 'upper' : 'sentence' }), + } + if (Object.keys(button).length) tune.button = button } if (m.card) { tune.card = { diff --git a/apps/web/src/lib/shared/design/resolve.ts b/apps/web/src/lib/shared/design/resolve.ts index a254d65ce7..d8a4f784ea 100644 --- a/apps/web/src/lib/shared/design/resolve.ts +++ b/apps/web/src/lib/shared/design/resolve.ts @@ -7,7 +7,7 @@ * full list of ways a converted portal can look different. */ import { expandTheme } from '@/lib/shared/theme/expand' -import { tuneCss } from './tune' +import { tuneColourCss, tuneCss, type TuneSurface } from './tune' import { variableMap } from '@/lib/shared/theme/generator' import type { ThemeVariables } from '@/lib/shared/theme/types' import { contrastRatio, solveContrast } from './contrast' @@ -138,21 +138,43 @@ export function resolveDesign(doc: PortalDesign): ResolvedDesign { } const light = doc.mode === 'dark' ? null : resolveMode('light') const dark = doc.mode === 'light' ? null : resolveMode('dark') + const tokens = { + base: baseTokens(doc), + light: light ? modeTokens(doc, light, 'light') : {}, + dark: dark ? modeTokens(doc, dark, 'dark') : {}, + } return { mode: doc.mode, light, dark, adjustments, errors, - tokens: { - base: baseTokens(doc), - light: light ? modeTokens(doc, light, 'light') : {}, - dark: dark ? modeTokens(doc, dark, 'dark') : {}, - }, - tuneCss: tuneCss(doc.tune), + tokens, + tuneCss: [tuneCss(doc.tune), tuneColourCss(doc.tune, tuneSurface(doc, light, tokens.light))] + .filter(Boolean) + .join(' '), } } +const SOLID = /^(#[\da-f]{3,8}|oklch\([^)]*\)|rgb\([^)]*\))$/i + +/** What a tune's light-mode colours sit on: the page, and the band behind a headline. */ +function tuneSurface( + doc: PortalDesign, + light: ThemeVariables | null, + tokens: TokenMap +): TuneSurface | null { + if (!light?.background) return null + const band = + doc.layout.band === 'none' + ? null + : doc.layout.bandShape === 'panel' + ? tokens['--qb-band-panel-bg'] + : tokens['--qb-band-bg'] + const solid = !!band && SOLID.test(band.trim()) + return { background: light.background, band: solid ? band : null, mixed: !!band && !solid } +} + /** * The semantic tokens portal components can read, by the theme variable each * one holds. Inside the scope the component library's names point at these. diff --git a/apps/web/src/lib/shared/design/tune.ts b/apps/web/src/lib/shared/design/tune.ts index 857d8ef085..7a7e8b8523 100644 --- a/apps/web/src/lib/shared/design/tune.ts +++ b/apps/web/src/lib/shared/design/tune.ts @@ -5,6 +5,9 @@ * what the tiles choose. Every value is bounded, so no tune can break a page. */ import { z } from 'zod' +import { contrastRatio } from './contrast' + +const hex = z.string().regex(/^#[0-9a-f]{6}$/i) const type = z .object({ @@ -18,12 +21,15 @@ const type = z }) .strict() +/** A headline's or heading's own colour, in light mode, where it reads. */ +const coloured = type.extend({ colour: hex.optional() }) + export const tuneSchema = z .object({ /** Page headlines. */ - headline: type.optional(), + headline: coloured.optional(), /** Section headings, such as a Home block's title. */ - heading: type.optional(), + heading: coloured.optional(), /** The menu and buttons. */ label: type.omit({ leading: true }).optional(), button: z @@ -31,6 +37,11 @@ export const tuneSchema = z /** Corners in px; 999 is a pill. */ radius: z.number().min(0).max(999).optional(), height: z.number().min(24).max(72).optional(), + /** A primary button's own fill and label, in light mode. */ + fill: hex.optional(), + ink: hex.optional(), + weight: z.number().int().min(100).max(900).optional(), + case: z.enum(['sentence', 'upper']).optional(), }) .strict() .optional(), @@ -140,3 +151,52 @@ export function tuneCss(tune: Tune | undefined): string { } return rules.length ? `@layer design { ${rules.join(' ')} }` : '' } + +/** The page a tune's colours sit on, in light mode, to check they read there. */ +export interface TuneSurface { + background: string + /** The band's colour behind a headline, or null with no band. */ + band: string | null + /** The band is a blend or an image: no one colour reads on all of it. */ + mixed: boolean +} + +const reads = (colour: string, on: Array, min: number) => + on.every((surface) => !surface || contrastRatio(colour, surface) >= min) + +/** + * The rules a tune's colours and button type need, in light mode. A colour + * that would not read where it sits is left out: a headline at 3:1 on the + * page and the band, a section heading at 3:1 on the page and on a card, and + * a button's label at 4.5:1 on its fill, else the fill's best label. + */ +export function tuneColourCss(tune: Tune | undefined, surface: TuneSurface | null): string { + if (!tune || !surface) return '' + const light = '[data-portal-theme]:not(.dark)' + const rules: string[] = [] + const headline = tune.headline?.colour + if (headline && !surface.mixed && reads(headline, [surface.background, surface.band], 3)) + rules.push(`${light} [data-qb-part=headline] { color: ${headline}; }`) + const heading = tune.heading?.colour + if (heading && reads(heading, [surface.background, '#ffffff'], 3)) + rules.push(`${light} [data-qb-part=section-heading] { color: ${heading}; }`) + const button = tune.button + if (button?.fill || button?.weight || button?.case) { + const label = + button.fill && + (button.ink && contrastRatio(button.ink, button.fill) >= 4.5 + ? button.ink + : contrastRatio('#ffffff', button.fill) >= contrastRatio('#111111', button.fill) + ? '#ffffff' + : '#111111') + const parts = [ + button.fill && `background-color: ${button.fill}`, + label && `color: ${label}`, + button.weight && `font-weight: ${button.weight}`, + button.case && `text-transform: ${button.case === 'upper' ? 'uppercase' : 'none'}`, + button.case === 'upper' && 'letter-spacing: 0.04em', + ].filter(Boolean) + rules.push(`${light} [data-slot=button][data-variant=default] { ${parts.join('; ')}; }`) + } + return rules.length ? `@layer design { ${rules.join(' ')} }` : '' +} From 77f8c452cc04cd500a88252d40ff58b1583b0c2d Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 17:39:36 +0000 Subject: [PATCH 04/16] fix(portal): keyboard focus and targets across the public portal From a keyboard sweep of every public page on ten branded portals: - Anything focused by keyboard gets a ring in the solved focus colour, in the base layer, so a component without its own focus style still shows one; the vote box's own ring replaces the browser's, which vanished on a dark page. - A card style's shadow gives way to a focus ring, which is drawn as one; linked help cards on filled designs lost their ring. - Small targets reach 24px without changing their look: tags, author names, the composer's sign-in link and Home's roadmap links. - The composer and the help search show a full-strength ring while typed in, and author names a ring as well as an underline. - The logo link is named for whose portal it is when it shows a wordmark or a mark alone. - A section heading's own colour must read at 4.5:1, as it shrinks to body size on a phone. --- .../help-center/help-center-search.tsx | 2 +- .../__tests__/portal-header-logo.test.tsx | 2 ++ .../components/public/author-hover-card.tsx | 3 ++- .../feedback/feedback-header-animated.tsx | 5 +++-- .../components/public/home/home-blocks.tsx | 2 +- .../src/components/public/portal-header.tsx | 4 +++- apps/web/src/components/public/post-card.tsx | 4 +++- .../shared/design/__tests__/resolve.test.ts | 20 +++++++++++++++---- .../lib/shared/design/__tests__/tune.test.ts | 12 ++++++++++- apps/web/src/lib/shared/design/resolve.ts | 4 ++-- apps/web/src/lib/shared/design/tune.ts | 11 +++++++--- 11 files changed, 52 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/help-center/help-center-search.tsx b/apps/web/src/components/help-center/help-center-search.tsx index 3b14cfe45e..a14a4dd8e0 100644 --- a/apps/web/src/components/help-center/help-center-search.tsx +++ b/apps/web/src/components/help-center/help-center-search.tsx @@ -133,7 +133,7 @@ export function HelpCenterHeroSearch({
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..59eaf38ad0 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:ring-1 focus-within:ring-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({
) 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/tune-section.tsx b/apps/web/src/components/admin/settings/appearance/tune-section.tsx new file mode 100644 index 0000000000..40e1532136 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/tune-section.tsx @@ -0,0 +1,258 @@ +import { useState } from 'react' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { PortalDesign } from '@/lib/shared/design/schema' +import type { Tune } from '@/lib/shared/design/tune' +import { ColourField, toHex } from './colour-field' + +type Update = (change: (draft: PortalDesign) => void) => void +type Group = keyof Tune + +/** A number a person types: blank is automatic, and it is held within its bounds. */ +function NumberField({ + id, + label, + value, + min, + max, + step = 1, + unit, + onChange, +}: { + id: string + label: string + value: number | undefined + min: number + max: number + step?: number + unit?: string + onChange: (value: number | undefined) => void +}) { + return ( +
+ + { + 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/lib/shared/design/labels.ts b/apps/web/src/lib/shared/design/labels.ts index a5272b33e4..970a219ebf 100644 --- a/apps/web/src/lib/shared/design/labels.ts +++ b/apps/web/src/lib/shared/design/labels.ts @@ -73,6 +73,13 @@ const LABELS: Record = { brand: 'Brand', 'pages.home.blocks': 'Home blocks', 'layout.pageTitles': 'Page titles', + tune: 'Exact values', + 'tune.headline': 'Headline exact values', + 'tune.heading': 'Section heading exact values', + 'tune.label': 'Menu and button text exact values', + 'tune.button': 'Button exact values', + 'tune.card': 'Card exact values', + 'tune.field': 'Field exact values', 'pages.titles.roadmap': 'Roadmap title', 'pages.titles.changelog': 'Changelog title', 'pages.titles.help': 'Help Center title', From 028e0f7fc28fd7acdef09a88ba21afb3f91a218f Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 18:27:21 +0000 Subject: [PATCH 11/16] fix(design): the reader measures the elements a person would, and the faithful look takes the site's main button From judging the AI's portals against the approved goals: - The headline is the largest h1 in sight, past hidden, screen-reader-only and notice elements; a size set only by a reset (h1 { font-size: 2em }) says nothing about the brand and is left unmeasured. - The main button is the one filled with the site's own button colour, where a secondary button came first in the page. - The faithful look takes a coloured main button as its brand colour, and fills buttons with the text colour where the site's are near-black. --- .../ai/__tests__/design-directions.test.ts | 16 +++++- .../design/ai/__tests__/site-measure.test.ts | 20 ++++++++ .../domains/design/ai/design-directions.ts | 19 ++++++- .../server/domains/design/ai/site-measure.ts | 51 ++++++++++++++++--- .../server/domains/design/ai/site-signals.ts | 2 +- 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts index 22b831503e..3c17996cd7 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts @@ -8,7 +8,7 @@ import { defaultDesign } from '@/lib/shared/design/schema' vi.mock('@/lib/server/config', () => ({ config: {} })) -const { DIRECTIONS_EXAMPLE, faithfulAccent, lookDesign, withMeasuredTune } = +const { DIRECTIONS_EXAMPLE, faithfulAccent, lookDesign, measuredButton, withMeasuredTune } = await import('../design-directions') const every = [ @@ -312,3 +312,17 @@ describe('the faithful look’s measured type and shapes', () => { expect(withMeasuredTune(design, null, allows)).toBe(design) }) }) + +describe('the faithful look’s buttons, as the site’s own', () => { + const site = (fill: string) => ({ measured: { button: { fill } } }) as never + it('take a coloured main button as the brand colour', () => { + expect(measuredButton(site('#0075de'))).toEqual({ accent: '#0075de' }) + expect(measuredButton(site('#163300'))).toEqual({ accent: '#163300' }) + }) + + it('fill with the text colour where the site’s are black, and ignore a pale one', () => { + expect(measuredButton(site('#111111'))).toEqual({ fill: 'ink' }) + expect(measuredButton(site('#f5f5f5'))).toEqual({}) + expect(measuredButton(null)).toEqual({}) + }) +}) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts index 16b1b5a8ae..03358ba9f7 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/site-measure.test.ts @@ -63,4 +63,24 @@ describe('measuring a brand from its page', () => { expect(m.button).toBeNull() expect(m.card).toBeNull() }) + + it('takes the largest headline in sight, past a reset’s default and a hidden one', () => { + const page = `

Skip

Pricing

+

Money without borders

` + const m = measureSite(page, [ + `h1 { font-size: 2em } .sr-only { font-size: 120px } .label { font-size: 14px } .hero { font-size: 104px }`, + ]) + expect(m.headline?.size).toBe(104) + // A page whose only headline size is the reset's says nothing about its headline. + expect(measureSite('

Hi

', ['h1 { font-size: 2em }']).headline).toBeNull() + }) + + it('takes the button filled with the site’s own button colour', () => { + const page = `Log inSend money` + const css = [ + `.btn-secondary { background: #37517e } .btn-primary { background: #163300; border-radius: 999px }`, + ] + expect(measureSite(page, css, '#163300').button).toEqual({ radius: 999, fill: '#163300' }) + expect(measureSite(page, css).button?.fill).toBe('#37517e') + }) }) diff --git a/apps/web/src/lib/server/domains/design/ai/design-directions.ts b/apps/web/src/lib/server/domains/design/ai/design-directions.ts index a0f96d1a3a..96ef1efe04 100644 --- a/apps/web/src/lib/server/domains/design/ai/design-directions.ts +++ b/apps/web/src/lib/server/domains/design/ai/design-directions.ts @@ -45,6 +45,22 @@ const oklchOf = (colour: string) => * to make text read on it, which the portal does for itself. A different hue * is the model's reading of the brand and stays. */ +/** + * The faithful look's buttons as the site's own main button: a coloured one + * is the brand colour, and a black or near-black one fills buttons with the + * text colour. A pale or grey fill says nothing about the brand. + */ +export function measuredButton(site: SiteSignals | null | undefined): { + accent?: string + fill?: 'ink' +} { + const fill = site?.measured?.button?.fill + const colour = fill ? oklchOf(fill) : null + if (!fill || !colour) return {} + if (colour.c >= 0.04) return { accent: fill } + return colour.l < 0.3 ? { fill: 'ink' } : {} +} + export function faithfulAccent( accent: string, input: { site?: SiteSignals | null; palette?: string[] | null } @@ -414,7 +430,8 @@ export async function designDirections(input: { index === 0 ? { ...answered, - accent: faithfulAccent(answered.accent, input), + accent: measuredButton(input.site).accent ?? faithfulAccent(answered.accent, input), + ...(measuredButton(input.site).fill && { fill: 'ink' as const }), support: answered.support && faithfulAccent(answered.support, input), } : answered diff --git a/apps/web/src/lib/server/domains/design/ai/site-measure.ts b/apps/web/src/lib/server/domains/design/ai/site-measure.ts index fff8cae7ee..c41b78ebbb 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-measure.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-measure.ts @@ -119,7 +119,16 @@ function cascade(rules: Rule[], el: Element): Map { a.rule.order - b.rule.order ) const out = new Map() - for (const { rule } of hits) for (const [k, v] of rule.decls) out.set(k, v) + for (const { spec, rule } of hits) + for (const [k, v] of rule.decls) { + // A size set only by a bare tag in em is a reset's default (h1 { font-size: 2em }), + // not the brand's: it says nothing about the headline the page shows. + if (k === 'font-size' && spec[0] === 0 && spec[1] === 0 && /^[\d.]+em$/.test(v.trim())) { + out.delete(k) + continue + } + out.set(k, v) + } return out } @@ -292,6 +301,13 @@ function elements(html: string): Array { return out } +/** An element the page keeps out of sight: hidden, for screen readers only, or a notice over it. */ +const isHidden = (el: Element & { attrs: string }) => + /(^|\s)hidden(\s|=|$)|aria-hidden\s*=\s*["']true/i.test(el.attrs) || + [...el.classes].some((c) => + /(sr-only|visually-hidden|screen-reader|cookie|consent|modal|dialog)/i.test(c) + ) + const isButtonish = (el: Element & { attrs: string }) => (el.tag === 'a' || el.tag === 'button') && ([...el.classes].some((c) => /(^|[-_])(btn|button|cta)([-_]|$)/i.test(c)) || @@ -299,17 +315,40 @@ const isButtonish = (el: Element & { attrs: string }) => ![...el.classes].some((c) => /(close|icon|menu|toggle|skip|cookie|link)/i.test(c)) /** A page's type and shapes, as far as its HTML and CSS say. */ -export function measureSite(html: string, css: string[]): BrandMeasurements { +/** + * A page's type and shapes, as far as its HTML and CSS say. The headline is + * the largest h1 in sight, and the main button the one filled with the site's + * own button colour when one is known. + */ +export function measureSite( + html: string, + css: string[], + brandButton: string | null = null +): BrandMeasurements { const rules = parseRules(css.join('\n')) const props = customProperties(rules) // A shorthand sets its longhands where its own rule does not, so the cascade weighs them by rule. for (const rule of rules) rule.decls = expandFont(rule.decls, props) - const els = elements(html) + const els = elements(html).filter((el) => !isHidden(el)) const styleOf = (el: Element | undefined) => (el ? cascade(rules, el) : null) const first = (pick: (el: Element & { attrs: string }) => boolean) => els.find(pick) - const h1 = styleOf(first((el) => el.tag === 'h1')) - const h2 = styleOf(first((el) => el.tag === 'h2')) - const button = styleOf(first(isButtonish)) + // Of the page's headlines, the largest: a site may mark a small label up as an h1 too. + const largest = (tag: string) => + els + .filter((el) => el.tag === tag) + .slice(0, 12) + .map((el) => cascade(rules, el)) + .map((decls) => ({ decls, size: typeOf(decls, props)?.size ?? 0 })) + .sort((a, b) => b.size - a.size)[0] + const h1 = largest('h1')?.decls ?? null + const h2 = styleOf(first((el) => el.tag === 'h2' && !!typeOf(cascade(rules, el), props))) + const buttons = els.filter(isButtonish).slice(0, 20) + const filled = (el: Element) => boxOf(cascade(rules, el), props)?.fill + const button = styleOf( + (brandButton && buttons.find((el) => filled(el) === brandButton)) || + buttons.find((el) => !!filled(el)) || + buttons[0] + ) const card = styleOf(first((el) => [...el.classes].some((c) => /(^|[-_])card([-_]|$)/i.test(c)))) const field = styleOf( first( diff --git a/apps/web/src/lib/server/domains/design/ai/site-signals.ts b/apps/web/src/lib/server/domains/design/ai/site-signals.ts index 3fbed159ea..bc866d97f0 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-signals.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-signals.ts @@ -161,7 +161,7 @@ export async function readSiteSignals(site: string): Promise { }, 'website read for its brand' ) - const measured = measureSite(found.html, sheets) + const measured = measureSite(found.html, sheets, brand.button) return { ok: true, signals: { domain: host, ...brand, logo, shareImage, measured } } } if (blocked) { From 8225fc95819b99fea1cafb8b20d0f4aed759bb8a Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 18:32:26 +0000 Subject: [PATCH 12/16] feat(design): the faithful look's band follows the site's hero, and every look words every page - When a site fills its hero with a colour, the faithful look's band takes it: the brand colour when the hero is that colour, else the hero's colour as the second. - Looks write the roadmap's, changelog's, help centre's and support's titles and lines in the brand's voice, where every page said its own name; the edit tool writes a page's title from plain text, a line at a time, as it does a headline. - The faithful look's rules, applied after the model's choices from what was read and measured on the site, live in their own module. --- .../ai/__tests__/design-directions.test.ts | 19 +++- .../domains/design/ai/design-directions.ts | 86 +++----------- .../server/domains/design/ai/design-looks.ts | 14 +++ .../server/domains/design/ai/faithful-look.ts | 106 ++++++++++++++++++ .../design/__tests__/apply-changes.test.ts | 17 +++ .../src/lib/shared/design/apply-changes.ts | 15 ++- 6 files changed, 182 insertions(+), 75 deletions(-) create mode 100644 apps/web/src/lib/server/domains/design/ai/faithful-look.ts diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts index 3c17996cd7..05660325e4 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/design-directions.test.ts @@ -8,8 +8,8 @@ import { defaultDesign } from '@/lib/shared/design/schema' vi.mock('@/lib/server/config', () => ({ config: {} })) -const { DIRECTIONS_EXAMPLE, faithfulAccent, lookDesign, measuredButton, withMeasuredTune } = - await import('../design-directions') +const { DIRECTIONS_EXAMPLE, faithfulAccent, lookDesign } = await import('../design-directions') +const { measuredBand, measuredButton, withMeasuredTune } = await import('../faithful-look') const every = [ 'welcome', @@ -326,3 +326,18 @@ describe('the faithful look’s buttons, as the site’s own', () => { expect(measuredButton(null)).toEqual({}) }) }) + +describe('the faithful look’s band, as the site’s hero', () => { + it('fills with the hero’s colour as the second when it differs from the brand colour', () => { + expect(measuredBand({ hero: '#9fe870' } as never, '#163300')).toEqual({ + band: 'support', + support: '#9fe870', + }) + }) + + it('fills with the brand colour when the hero is that colour, and leaves a grey hero alone', () => { + expect(measuredBand({ hero: '#0058a3' } as never, '#0058a3')).toEqual({ band: 'brand' }) + expect(measuredBand({ hero: '#f5f5f5' } as never, '#0058a3')).toEqual({}) + expect(measuredBand(null, '#0058a3')).toEqual({}) + }) +}) diff --git a/apps/web/src/lib/server/domains/design/ai/design-directions.ts b/apps/web/src/lib/server/domains/design/ai/design-directions.ts index 96ef1efe04..8be28ed817 100644 --- a/apps/web/src/lib/server/domains/design/ai/design-directions.ts +++ b/apps/web/src/lib/server/domains/design/ai/design-directions.ts @@ -21,11 +21,12 @@ import { designAiModel, isStructuredOutputError, paletteText } from './design-ai import type { SiteAssets } from './site-assets' import type { SiteSignals } from './site-signals' import { - tuneFromMeasurements, - type BrandMeasurements, - type MeasuredType, -} from '@/lib/shared/design/measured-tune' -import { nearestBundledFont } from '@/lib/shared/design/font-match' + fontBrief, + measuredBand, + measuredBrief, + measuredButton, + withMeasuredTune, +} from './faithful-look' const log = logger.child({ module: 'design-directions' }) @@ -45,21 +46,6 @@ const oklchOf = (colour: string) => * to make text read on it, which the portal does for itself. A different hue * is the model's reading of the brand and stays. */ -/** - * The faithful look's buttons as the site's own main button: a coloured one - * is the brand colour, and a black or near-black one fills buttons with the - * text colour. A pale or grey fill says nothing about the brand. - */ -export function measuredButton(site: SiteSignals | null | undefined): { - accent?: string - fill?: 'ink' -} { - const fill = site?.measured?.button?.fill - const colour = fill ? oklchOf(fill) : null - if (!fill || !colour) return {} - if (colour.c >= 0.04) return { accent: fill } - return colour.l < 0.3 ? { fill: 'ink' } : {} -} export function faithfulAccent( accent: string, @@ -180,6 +166,10 @@ function lookChanges( ...page(fit(look.prompt, 60), 'pages.feedback.copy.prompt'), ...page(fit(look.submit, 24), 'pages.feedback.copy.submit'), ...accentChanges(look, 'pages.feedback.headline', look.headline), + ...(['roadmap', 'changelog', 'help', 'support'] as const).flatMap((p) => [ + ...page(fit(look.pageWords[p].title, 80), `pages.titles.${p}.title`), + ...page(fit(look.pageWords[p].subtitle, 140), `pages.titles.${p}.subtitle`), + ]), ...(look.homeBlocks.length ? [ { path: 'pages.home.blocks', value: homeBlocks(look) }, @@ -218,33 +208,6 @@ function accentChanges(look: Look, headline: string, title: string) { : [] } -/** - * The faithful look with the brand's own type and shapes, exactly as its page - * sets them, where they could be read: its faces as the bundled faces closest - * to them, and its sizes, weights, corners and colours as exact values. The - * other looks are directions of their own and keep their tiles' steps. - */ -export function withMeasuredTune( - design: PortalDesign, - site: SiteSignals | null | undefined, - allows: Omit -): PortalDesign { - const tune = site?.measured ? tuneFromMeasurements(site.measured) : {} - const heading = nearestBundledFont(site?.fonts.heading) - const body = nearestBundledFont(site?.fonts.body) - const changes = [ - ...(heading ? [{ path: 'brand.fonts.heading', value: heading }] : []), - ...(body ? [{ path: 'brand.fonts.body', value: body }] : []), - ...(Object.keys(tune).length ? [{ path: 'tune', value: tune }] : []), - ] - if (!changes.length) return design - return applyDesignChanges(design, changes, 'site', { - ...allows, - links: new Set(), - files: new Set(), - }).design -} - /** A look's design: its preset with every choice it makes that the design accepts. */ export function lookDesign( base: PortalDesign, @@ -300,31 +263,6 @@ function assetsBrief(assets: SiteAssets | null, imageFirst: boolean): string | n .join(' ') } -const typeText = (t: MeasuredType | null | undefined) => - t?.size ? `${Math.round(t.size)}px${t.weight ? ` at weight ${t.weight}` : ''}` : null - -/** The bundled faces closest to the site's own, for the looks to start from. */ -function fontBrief(fonts: SiteSignals['fonts']): string | null { - const heading = nearestBundledFont(fonts.heading) - const body = nearestBundledFont(fonts.body) - if (!heading && !body) return null - return `The closest bundled faces: ${[heading && `headings ${heading}`, body && `text ${body}`].filter(Boolean).join(', ')}.` -} - -/** What was measured on the page, so the looks' steps start near it. */ -function measuredBrief(m: BrandMeasurements | null): string | null { - if (!m) return null - const button = m.button?.radius ?? null - const card = m.card?.radius ?? null - const parts = [ - typeText(m.headline) && `headlines ${typeText(m.headline)}`, - typeText(m.heading) && `section headings ${typeText(m.heading)}`, - button !== null && `buttons with ${button >= 999 ? 'pill' : `${Math.round(button)}px`} corners`, - card !== null && `cards with ${Math.round(card)}px corners`, - ].filter(Boolean) - return parts.length ? `Measured on its page: ${parts.join(', ')}.` : null -} - /** What the website says about its brand, as the model reads it. */ function siteBrief(site: SiteSignals): string { return [ @@ -432,6 +370,10 @@ export async function designDirections(input: { ...answered, accent: measuredButton(input.site).accent ?? faithfulAccent(answered.accent, input), ...(measuredButton(input.site).fill && { fill: 'ink' as const }), + ...measuredBand( + input.site, + measuredButton(input.site).accent ?? faithfulAccent(answered.accent, input) + ), support: answered.support && faithfulAccent(answered.support, input), } : answered diff --git a/apps/web/src/lib/server/domains/design/ai/design-looks.ts b/apps/web/src/lib/server/domains/design/ai/design-looks.ts index 04d0d5e82c..735bf895f1 100644 --- a/apps/web/src/lib/server/domains/design/ai/design-looks.ts +++ b/apps/web/src/lib/server/domains/design/ai/design-looks.ts @@ -75,6 +75,13 @@ export const lookSchema = z.object({ submit: z.string(), homeHeadline: z.string(), homeSubtitle: z.string(), + /** The other pages' titles and lines, in their voice. */ + pageWords: z.object({ + roadmap: z.object({ title: z.string(), subtitle: z.string() }), + changelog: z.object({ title: z.string(), subtitle: z.string() }), + help: z.object({ title: z.string(), subtitle: z.string() }), + support: z.object({ title: z.string(), subtitle: z.string() }), + }), homeBlocks: z.array(z.object({ type: z.enum(BLOCK_TYPES), width: z.enum(WIDTHS) })).max(8), }) export type Look = z.infer @@ -138,6 +145,12 @@ export const DIRECTIONS_EXAMPLE: Look = { submit: 'Suggest it', homeHeadline: 'Climb with us', homeSubtitle: 'Suggest what we set next, see what is coming and find help.', + pageWords: { + roadmap: { title: 'On the wall next', subtitle: 'Routes and features we are setting now.' }, + changelog: { title: 'Fresh chalk', subtitle: 'Everything new at the gym and in the app.' }, + help: { title: 'How can we help?', subtitle: 'Memberships, classes and your account.' }, + support: { title: 'Talk to the desk', subtitle: 'Your conversations with our team.' }, + }, homeBlocks: [ { type: 'composer', width: 'full' }, { type: 'contact', width: 'full' }, @@ -181,6 +194,7 @@ How a brand becomes each choice: - cards: bordered, raised, or list rows for minimal and developer brands. density: compact for technical audiences, comfortable mostly, spacious for calm brands. width: narrow for editorial, wide for dense tools, default otherwise. - words: postWord is what visitors post: feature_request for software and developer tools, suggestion for consumer, retail and hospitality, post for communities, idea otherwise. boardWord: category for consumer brands, board otherwise. - headline, subtitle (the feedback page) and homeHeadline, homeSubtitle (Home), prompt (the composer, 60 characters) and submit (its button, 24): in their voice, short, inviting, never generic, never naming a competitor. +- pageWords: the roadmap's, changelog's, help centre's and support's titles and one-line subtitles, in the same voice: never just the page's name, such as "Roadmap", unless their own site titles it so. - homeBlocks: Home, in order, on a six-column grid; each type with its widths: ${BLOCK_TYPES.map((type) => `${type} (${BLOCK_WIDTHS[type].join(', ')})`).join('; ')}. This workspace can show: ${blockTypes.join(', ')}. Lead with the composer; use what they have. Blocks in a row share its height, so pair short blocks (composer, status, contact, help_search) with each other and lists (ideas, updates, roadmap, help_topics) with each other. A row's last block grows to fill it. No links block. Presets set the starting layout: ${DESIGN_PRESETS.map((preset) => `${preset.id} (${preset.description})`).join('; ')}. diff --git a/apps/web/src/lib/server/domains/design/ai/faithful-look.ts b/apps/web/src/lib/server/domains/design/ai/faithful-look.ts new file mode 100644 index 0000000000..19d19903e5 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/ai/faithful-look.ts @@ -0,0 +1,106 @@ +/** + * The faithful look: the first of Quackback AI's looks keeps the brand as its + * own site shows it. What was read and measured on the site is applied after + * the model's choices, the way a compiler's check follows a draft: its main + * button's colour, its hero's colour, its faces, and its type and shapes as + * exact values. The briefs tell every look what was found. + */ +import { applyDesignChanges, type UntrustedChanges } from '@/lib/shared/design/apply-changes' +import type { PortalDesign } from '@/lib/shared/design/schema' +import { + tuneFromMeasurements, + type BrandMeasurements, + type MeasuredType, +} from '@/lib/shared/design/measured-tune' +import { nearestBundledFont } from '@/lib/shared/design/font-match' +import { parseOklch } from '@/lib/shared/theme/expand' +import { hexToOklch } from '@/lib/shared/theme/colors' +import type { SiteSignals } from './site-signals' + +const oklchOf = (colour: string) => + /^#[0-9a-f]{6}$/i.test(colour) ? parseOklch(hexToOklch(colour)) : null + +/** + * The faithful look with the brand's own type and shapes, exactly as its page + * sets them, where they could be read: its faces as the bundled faces closest + * to them, and its sizes, weights, corners and colours as exact values. The + * other looks are directions of their own and keep their tiles' steps. + */ +export function withMeasuredTune( + design: PortalDesign, + site: SiteSignals | null | undefined, + allows: Omit +): PortalDesign { + const tune = site?.measured ? tuneFromMeasurements(site.measured) : {} + const heading = nearestBundledFont(site?.fonts.heading) + const body = nearestBundledFont(site?.fonts.body) + const changes = [ + ...(heading ? [{ path: 'brand.fonts.heading', value: heading }] : []), + ...(body ? [{ path: 'brand.fonts.body', value: body }] : []), + ...(Object.keys(tune).length ? [{ path: 'tune', value: tune }] : []), + ] + if (!changes.length) return design + return applyDesignChanges(design, changes, 'site', { + ...allows, + links: new Set(), + files: new Set(), + }).design +} + +/** + * The faithful look's buttons as the site's own main button: a coloured one + * is the brand colour, and a black or near-black one fills buttons with the + * text colour. A pale or grey fill says nothing about the brand. + */ +export function measuredButton(site: SiteSignals | null | undefined): { + accent?: string + fill?: 'ink' +} { + const fill = site?.measured?.button?.fill + const colour = fill ? oklchOf(fill) : null + if (!fill || !colour) return {} + if (colour.c >= 0.04) return { accent: fill } + return colour.l < 0.3 ? { fill: 'ink' } : {} +} + +/** + * The faithful look's band as the site's hero: filled with the brand colour + * when the hero is that colour, else with the hero's colour as the second. + */ +export function measuredBand( + site: SiteSignals | null | undefined, + accent: string +): { band?: 'brand' | 'support'; support?: string } { + const hero = site?.hero + const fill = hero ? oklchOf(hero) : null + const own = oklchOf(accent) + if (!hero || !fill || fill.c < 0.04) return {} + const turn = own ? Math.abs(fill.h - own.h) % 360 : 180 + const same = own && Math.min(turn, 360 - turn) <= 20 && Math.abs(fill.l - own.l) < 0.08 + return same ? { band: 'brand' } : { band: 'support', support: hero } +} + +/** The bundled faces closest to the site's own, for the looks to start from. */ +export function fontBrief(fonts: SiteSignals['fonts']): string | null { + const heading = nearestBundledFont(fonts.heading) + const body = nearestBundledFont(fonts.body) + if (!heading && !body) return null + return `The closest bundled faces: ${[heading && `headings ${heading}`, body && `text ${body}`].filter(Boolean).join(', ')}.` +} + +const typeText = (t: MeasuredType | null | undefined) => + t?.size ? `${Math.round(t.size)}px${t.weight ? ` at weight ${t.weight}` : ''}` : null + +/** What was measured on the page, so the looks' steps start near it. */ +export function measuredBrief(m: BrandMeasurements | null): string | null { + if (!m) return null + const button = m.button?.radius ?? null + const card = m.card?.radius ?? null + const parts = [ + typeText(m.headline) && `headlines ${typeText(m.headline)}`, + typeText(m.heading) && `section headings ${typeText(m.heading)}`, + button !== null && `buttons with ${button >= 999 ? 'pill' : `${Math.round(button)}px`} corners`, + card !== null && `cards with ${Math.round(card)}px corners`, + ].filter(Boolean) + return parts.length ? `Measured on its page: ${parts.join(', ')}.` : null +} diff --git a/apps/web/src/lib/shared/design/__tests__/apply-changes.test.ts b/apps/web/src/lib/shared/design/__tests__/apply-changes.test.ts index 7f5c6f72d7..6133798c77 100644 --- a/apps/web/src/lib/shared/design/__tests__/apply-changes.test.ts +++ b/apps/web/src/lib/shared/design/__tests__/apply-changes.test.ts @@ -34,6 +34,23 @@ describe('applyDesignChanges', () => { expect(result.applied[0]).toMatchObject({ before: 'oklch(0.886 0.176 86)', after: '#0f766e' }) }) + it('words a page’s title a line at a time, and gives it back its usual title when emptied', () => { + const set = applyDesignChanges( + defaultDesign(), + [{ path: 'pages.titles.roadmap.title', value: 'On the wall next' }], + 'site' + ) + expect(set.design.pages.titles).toEqual({ + roadmap: { title: { en: 'On the wall next' }, subtitle: {} }, + }) + const cleared = applyDesignChanges( + set.design, + [{ path: 'pages.titles.roadmap.title', value: '' }], + 'site' + ) + expect(cleared.design.pages.titles).toBeUndefined() + }) + it('sets the headlines’ own face by name, and gives them back the heading font with null', () => { const set = applyDesignChanges( defaultDesign(), diff --git a/apps/web/src/lib/shared/design/apply-changes.ts b/apps/web/src/lib/shared/design/apply-changes.ts index d35902fd5f..2017a14839 100644 --- a/apps/web/src/lib/shared/design/apply-changes.ts +++ b/apps/web/src/lib/shared/design/apply-changes.ts @@ -205,7 +205,7 @@ function shaped(doc: PortalDesign, path: string, value: unknown): unknown { } if (typeof value === 'string' && isRichText(current)) return textDoc(value) const translated = - /^pages\.(feedback\.copy\.(prompt|submit|emptyTitle)|(home|feedback)\.headline\.(title|subtitle))$/.test( + /^pages\.(feedback\.copy\.(prompt|submit|emptyTitle)|(home|feedback)\.headline\.(title|subtitle)|titles\.(roadmap|changelog|help|support)\.(title|subtitle))$/.test( path ) if ( @@ -235,7 +235,20 @@ function write(doc: PortalDesign, path: string, value: unknown): PortalDesign | const page = /^pages\.(home|feedback)\.headline\./.exec(path)?.[1] as 'home' | 'feedback' | undefined if (page && !next.pages[page].headline) next.pages[page].headline = { title: {}, subtitle: {} } + // So can a page's own title. + const titled = /^pages\.titles\.(roadmap|changelog|help|support)\./.exec(path)?.[1] as + 'roadmap' | 'changelog' | 'help' | 'support' | undefined + if (titled) { + next.pages.titles ??= {} + next.pages.titles[titled] ??= { title: {}, subtitle: {} } + } if (!writeAt(next, keys, shaped(next, path, value))) return 'there is no such field' + if (titled && next.pages.titles) { + const words = next.pages.titles[titled] + if (words && !Object.keys(words.title).length && !Object.keys(words.subtitle).length) + delete next.pages.titles[titled] + if (!Object.keys(next.pages.titles).length) delete next.pages.titles + } if (page) { const line = next.pages[page].headline if (line && !Object.keys(line.title).length && !Object.keys(line.subtitle).length) From 1638f7cfcfb4f4e4fb4db29c997638c50c0fe93c Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 18:44:28 +0000 Subject: [PATCH 13/16] fix(design): the looks' answer stays within the grammar the provider compiles A look is answered under a strict schema the provider compiles into a grammar, and past a size it refuses the request: every start then fell back to an unbranded portal. The page words are now a flat list of eight lines, and faces are named as plain text and checked after, a face that is not bundled being left out. A test holds the schema at the size that compiles, so a new field is flattened or asked for separately rather than growing the look. --- .../ai/__tests__/design-looks-grammar.test.ts | 26 ++++++++++++ .../domains/design/ai/design-directions.ts | 18 +++++--- .../server/domains/design/ai/design-looks.ts | 42 +++++++++++-------- 3 files changed, 62 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/lib/server/domains/design/ai/__tests__/design-looks-grammar.test.ts diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/design-looks-grammar.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/design-looks-grammar.test.ts new file mode 100644 index 0000000000..9564227e78 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/design-looks-grammar.test.ts @@ -0,0 +1,26 @@ +/** + * A look is answered under a strict schema, which the provider compiles into a + * grammar. Past a size it refuses the request outright ("The compiled grammar is + * too large"), and every start falls back to an unbranded portal. Nested objects + * cost the most: the schema compiled at 60 properties and was refused at 72, when + * the page words were four nested objects. Hold it there, and flatten a new field + * or ask for it in a second call rather than grow the look. + */ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { directionsSchema } from '../design-looks' + +const properties = (node: unknown): number => + !node || typeof node !== 'object' + ? 0 + : Object.entries(node).reduce( + (n, [key, value]) => + n + (key === 'properties' ? Object.keys(value as object).length : 0) + properties(value), + 0 + ) + +describe('the looks’ answer schema', () => { + it('stays within the grammar the provider compiles', () => { + expect(properties(z.toJSONSchema(directionsSchema))).toBeLessThanOrEqual(64) + }) +}) diff --git a/apps/web/src/lib/server/domains/design/ai/design-directions.ts b/apps/web/src/lib/server/domains/design/ai/design-directions.ts index 8be28ed817..04b3ec09ad 100644 --- a/apps/web/src/lib/server/domains/design/ai/design-directions.ts +++ b/apps/web/src/lib/server/domains/design/ai/design-directions.ts @@ -20,6 +20,7 @@ import type { PortalDesign } from '@/lib/shared/design/schema' import { designAiModel, isStructuredOutputError, paletteText } from './design-ai' import type { SiteAssets } from './site-assets' import type { SiteSignals } from './site-signals' +import { BUNDLED_FONT_IDS } from '@/lib/shared/design/options' import { fontBrief, measuredBand, @@ -66,6 +67,8 @@ export function faithfulAccent( } /** Text cut at a word boundary to fit a field; a long answer is trimmed rather than refused. */ +const bundled = (face: string) => (BUNDLED_FONT_IDS as readonly string[]).includes(face) + function fit(text: string, max: number): string { if (text.length <= max) return text const cut = text.slice(0, max) @@ -96,9 +99,12 @@ function lookChanges( ...page(look.darkBackground, 'brand.dark.background'), ...page(look.darkInk, 'brand.dark.ink'), ]), - { path: 'brand.fonts.heading', value: look.headingFont }, - { path: 'brand.fonts.body', value: look.bodyFont }, - ...(look.displayFont && look.displayFont !== look.headingFont + // A face the model names that is not bundled is left out: the preset's stays. + ...(bundled(look.headingFont) + ? [{ path: 'brand.fonts.heading', value: look.headingFont }] + : []), + ...(bundled(look.bodyFont) ? [{ path: 'brand.fonts.body', value: look.bodyFont }] : []), + ...(bundled(look.displayFont) && look.displayFont !== look.headingFont ? [{ path: 'brand.fonts.display', value: look.displayFont }] : []), { path: 'brand.fonts.headingWeight', value: Number(look.headingWeight) }, @@ -166,9 +172,9 @@ function lookChanges( ...page(fit(look.prompt, 60), 'pages.feedback.copy.prompt'), ...page(fit(look.submit, 24), 'pages.feedback.copy.submit'), ...accentChanges(look, 'pages.feedback.headline', look.headline), - ...(['roadmap', 'changelog', 'help', 'support'] as const).flatMap((p) => [ - ...page(fit(look.pageWords[p].title, 80), `pages.titles.${p}.title`), - ...page(fit(look.pageWords[p].subtitle, 140), `pages.titles.${p}.subtitle`), + ...(['roadmap', 'changelog', 'help', 'support'] as const).flatMap((p, i) => [ + ...page(fit(look.pageWords[i * 2] ?? '', 80), `pages.titles.${p}.title`), + ...page(fit(look.pageWords[i * 2 + 1] ?? '', 140), `pages.titles.${p}.subtitle`), ]), ...(look.homeBlocks.length ? [ diff --git a/apps/web/src/lib/server/domains/design/ai/design-looks.ts b/apps/web/src/lib/server/domains/design/ai/design-looks.ts index 735bf895f1..4c067e251e 100644 --- a/apps/web/src/lib/server/domains/design/ai/design-looks.ts +++ b/apps/web/src/lib/server/domains/design/ai/design-looks.ts @@ -4,7 +4,7 @@ * those choices. */ import { z } from 'zod' -import { BLOCK_WIDTHS, BUNDLED_FONT_IDS, DESIGN_OPTIONS } from '@/lib/shared/design/options' +import { BLOCK_WIDTHS, DESIGN_OPTIONS } from '@/lib/shared/design/options' import type { HomeBlockType } from '@/lib/shared/design/home' import { DESIGN_PRESETS } from '@/lib/shared/design/presets' @@ -26,10 +26,12 @@ export const lookSchema = z.object({ /** Dark mode's page and text, or "" to derive them. */ darkBackground: hex, darkInk: hex, - headingFont: z.enum(BUNDLED_FONT_IDS), + // Faces are named as plain text and checked after: three enums of every + // bundled face make the grammar a strict answer is held to too large to compile. + headingFont: z.string(), /** The headlines' own face, or "" for the heading font. */ - displayFont: z.enum(['', ...BUNDLED_FONT_IDS]), - bodyFont: z.enum(BUNDLED_FONT_IDS), + displayFont: z.string(), + bodyFont: z.string(), headingWeight: z.enum(['500', '600', '700', '800']), corners: z.enum(DESIGN_OPTIONS.corners), header: z.enum(['stacked', 'inline', 'centered']), @@ -75,13 +77,13 @@ export const lookSchema = z.object({ submit: z.string(), homeHeadline: z.string(), homeSubtitle: z.string(), - /** The other pages' titles and lines, in their voice. */ - pageWords: z.object({ - roadmap: z.object({ title: z.string(), subtitle: z.string() }), - changelog: z.object({ title: z.string(), subtitle: z.string() }), - help: z.object({ title: z.string(), subtitle: z.string() }), - support: z.object({ title: z.string(), subtitle: z.string() }), - }), + /** + * The other pages' titles and lines, in their voice: the roadmap's title and + * line, then the changelog's, the help centre's and support's. A flat list, + * as nested objects grow the grammar a strict answer is held to past what + * the provider compiles. + */ + pageWords: z.array(z.string()).max(8), homeBlocks: z.array(z.object({ type: z.enum(BLOCK_TYPES), width: z.enum(WIDTHS) })).max(8), }) export type Look = z.infer @@ -145,12 +147,16 @@ export const DIRECTIONS_EXAMPLE: Look = { submit: 'Suggest it', homeHeadline: 'Climb with us', homeSubtitle: 'Suggest what we set next, see what is coming and find help.', - pageWords: { - roadmap: { title: 'On the wall next', subtitle: 'Routes and features we are setting now.' }, - changelog: { title: 'Fresh chalk', subtitle: 'Everything new at the gym and in the app.' }, - help: { title: 'How can we help?', subtitle: 'Memberships, classes and your account.' }, - support: { title: 'Talk to the desk', subtitle: 'Your conversations with our team.' }, - }, + pageWords: [ + 'On the wall next', + 'Routes and features we are setting now.', + 'Fresh chalk', + 'Everything new at the gym and in the app.', + 'How can we help?', + 'Memberships, classes and your account.', + 'Talk to the desk', + 'Your conversations with our team.', + ], homeBlocks: [ { type: 'composer', width: 'full' }, { type: 'contact', width: 'full' }, @@ -194,7 +200,7 @@ How a brand becomes each choice: - cards: bordered, raised, or list rows for minimal and developer brands. density: compact for technical audiences, comfortable mostly, spacious for calm brands. width: narrow for editorial, wide for dense tools, default otherwise. - words: postWord is what visitors post: feature_request for software and developer tools, suggestion for consumer, retail and hospitality, post for communities, idea otherwise. boardWord: category for consumer brands, board otherwise. - headline, subtitle (the feedback page) and homeHeadline, homeSubtitle (Home), prompt (the composer, 60 characters) and submit (its button, 24): in their voice, short, inviting, never generic, never naming a competitor. -- pageWords: the roadmap's, changelog's, help centre's and support's titles and one-line subtitles, in the same voice: never just the page's name, such as "Roadmap", unless their own site titles it so. +- pageWords: eight lines, in the same voice: the roadmap's title and its one-line subtitle, then the changelog's, the help centre's and support's. Never just the page's name, such as "Roadmap", unless their own site titles it so. - homeBlocks: Home, in order, on a six-column grid; each type with its widths: ${BLOCK_TYPES.map((type) => `${type} (${BLOCK_WIDTHS[type].join(', ')})`).join('; ')}. This workspace can show: ${blockTypes.join(', ')}. Lead with the composer; use what they have. Blocks in a row share its height, so pair short blocks (composer, status, contact, help_search) with each other and lists (ideas, updates, roadmap, help_topics) with each other. A row's last block grows to fill it. No links block. Presets set the starting layout: ${DESIGN_PRESETS.map((preset) => `${preset.id} (${preset.description})`).join('; ')}. From d10edeacf887e82b9ff86267ec25172bdefa12ab Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 19:17:02 +0000 Subject: [PATCH 14/16] feat(design): Quackback AI's edit chat reads an attached picture, not only its colours Asked to match a screenshot, the edit chat saw only the picture's colours, so it could not read the layout, type or shapes it was asked to match. An attached image now goes to the model as an image, downscaled as the looks' start does, with its colours alongside. An answer to match a picture can touch many fields, so it has more room; one that does not fit is asked for once more, then without the picture. --- .../settings/appearance/appearance-view.tsx | 4 +- .../settings/appearance/design-composer.tsx | 15 ++++--- .../settings/appearance/use-design-ai.ts | 15 ++++++- .../design/ai/__tests__/design-ai.test.ts | 37 ++++++++++++++++ .../lib/server/domains/design/ai/design-ai.ts | 43 ++++++++++++++++--- .../lib/server/functions/portal-design-ai.ts | 6 +++ 6 files changed, 104 insertions(+), 16 deletions(-) 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/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 }) }) }} />