From 8f5414f9dbec2ea02bf1159a03399d1f240983ce Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 22:34:07 +0000 Subject: [PATCH 1/6] feat(design): a website reader that renders a page in a headless browser An optional service beside Quackback that reads a website's home page as a browser renders it: the type, colours and shapes of its headline, headings, main button, cards and fields, the colours its first screen is painted in, and a screenshot of that screen. It says what it is. It sends its own agent, keeps the browser's automation flag, respects robots.txt, and never works around a bot check. Consent notices are answered by declining optional cookies, from a maintained set of rules for common consent managers and then by its own pass, and what is left over the page is hidden for the screenshot. Every request a page makes goes through a guard proxy inside the read. It tunnels secure connections on the standard port only, resolves each host itself, refuses private, internal and metadata addresses, and connects to the address it checked, so a page cannot reach the network the reader runs on. Chromium's own UDP paths are off. The service answers POST /read for callers holding its token, one read at a time, each in a process of its own that ends if it overruns. It ships as its own image (docker/browser) and an optional compose profile, with no secret but its token. --- .env.example | 5 + .env.prod.example | 6 + apps/web/package.json | 1 + .../render/__tests__/guard-proxy.test.ts | 102 +++++++ .../render/__tests__/reader-server.test.ts | 40 +++ .../design/render/__tests__/robots.test.ts | 33 +++ .../domains/design/render/guard-proxy.ts | 112 +++++++ .../domains/design/render/page-scripts.ts | 279 ++++++++++++++++++ .../server/domains/design/render/read-page.ts | 174 +++++++++++ .../domains/design/render/reader-server.ts | 119 ++++++++ .../server/domains/design/render/robots.ts | 65 ++++ bun.lock | 3 + docker-compose.prod.yml | 20 ++ docker/browser/Dockerfile | 36 +++ 14 files changed, 995 insertions(+) create mode 100644 apps/web/src/lib/server/domains/design/render/__tests__/guard-proxy.test.ts create mode 100644 apps/web/src/lib/server/domains/design/render/__tests__/reader-server.test.ts create mode 100644 apps/web/src/lib/server/domains/design/render/__tests__/robots.test.ts create mode 100644 apps/web/src/lib/server/domains/design/render/guard-proxy.ts create mode 100644 apps/web/src/lib/server/domains/design/render/page-scripts.ts create mode 100644 apps/web/src/lib/server/domains/design/render/read-page.ts create mode 100644 apps/web/src/lib/server/domains/design/render/reader-server.ts create mode 100644 apps/web/src/lib/server/domains/design/render/robots.ts create mode 100644 docker/browser/Dockerfile diff --git a/.env.example b/.env.example index c1022f911a..a1beb90d55 100644 --- a/.env.example +++ b/.env.example @@ -408,6 +408,11 @@ S3_PUBLIC_URL= # PORTAL_DESIGN_ENGINE=auto +# A website reader that renders a page in a headless browser (docker/browser). +# Optional: without it, websites are read from their HTML and CSS. +# QUACKBACK_BROWSER_URL=http://localhost:3200 +# QUACKBACK_BROWSER_TOKEN= + # Cloud only; leave unset when self-hosting. # QUACKBACK_CONTROL_PLANE_URL=https://app.quackback.io # INTEGRATION_OAUTH_GATEWAY_URL=https://app.quackback.io diff --git a/.env.prod.example b/.env.prod.example index beaa727802..8da2463c7d 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -105,3 +105,9 @@ EMAIL_FROM=Quackback # Enables summaries, duplicate detection, feedback extraction, etc. # OPENAI_API_KEY= # OPENAI_BASE_URL= + +# --- Website reader (optional) ------------------------------------------ +# Renders a website in a headless browser so Quackback AI reads its exact +# type, colours and buttons. Start it with `--profile browser`. +# QUACKBACK_BROWSER_URL=http://browser:3200 +# QUACKBACK_BROWSER_TOKEN= # openssl rand -hex 24 diff --git a/apps/web/package.json b/apps/web/package.json index 4baa9aedd2..afc3df31e4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@duckduckgo/autoconsent": "17.2.0", "@floating-ui/dom": "^1.7.6", "@fontsource-variable/inter": "^5.3.0", "@fontsource/barlow-condensed": "^5.3.0", diff --git a/apps/web/src/lib/server/domains/design/render/__tests__/guard-proxy.test.ts b/apps/web/src/lib/server/domains/design/render/__tests__/guard-proxy.test.ts new file mode 100644 index 0000000000..1b611bd4b4 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/__tests__/guard-proxy.test.ts @@ -0,0 +1,102 @@ +import { connect } from 'node:net' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/server/content/ssrf-guard', () => ({ + checkUrlSafety: vi.fn(async (url: string) => { + const host = new URL(url).hostname.replace(/^\[|\]$/g, '') + if (host === 'brand.example.com') return { safe: true, address: '127.0.0.1', family: 4 } + return { safe: false, reason: 'ssrf-rejected' } + }), +})) + +const { allowedAddress, startGuardProxy, tunnelTarget } = await import('../guard-proxy') + +/** Sends one request through the proxy and returns its first response line. */ +function ask(port: number, request: string): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: '127.0.0.1', port }, () => socket.write(request)) + let got = '' + socket.on('data', (chunk) => { + got += chunk.toString() + if (got.includes('\r\n')) { + socket.destroy() + resolve(got.slice(0, got.indexOf('\r\n'))) + } + }) + socket.on('error', reject) + socket.on('close', () => resolve(got.split('\r\n')[0] ?? '')) + }) +} + +describe('tunnelTarget', () => { + it('reads the host and port of a CONNECT request, and nothing else', () => { + expect(tunnelTarget('CONNECT example.com:443 HTTP/1.1\r\nHost: x\r\n\r\n')).toEqual({ + host: 'example.com', + port: 443, + }) + expect(tunnelTarget('CONNECT [2001:db8::1]:443 HTTP/1.1\r\n\r\n')).toEqual({ + host: '2001:db8::1', + port: 443, + }) + expect(tunnelTarget('GET http://example.com/ HTTP/1.1\r\n\r\n')).toBeNull() + }) +}) + +describe('allowedAddress', () => { + it('allows a public host on the secure port only', async () => { + expect(await allowedAddress('brand.example.com', 443)).toEqual({ + address: '127.0.0.1', + family: 4, + }) + expect(await allowedAddress('brand.example.com', 8443)).toBeNull() + }) + + it('refuses internal names before resolving them', async () => { + expect(await allowedAddress('postgres.railway.internal', 443)).toBeNull() + expect(await allowedAddress('localhost', 443)).toBeNull() + expect(await allowedAddress('printer.local', 443)).toBeNull() + }) + + it('refuses a host the address check rejects', async () => { + expect(await allowedAddress('10.0.0.5', 443)).toBeNull() + }) +}) + +describe('startGuardProxy', () => { + let close: (() => Promise) | undefined + afterEach(async () => { + await close?.() + close = undefined + }) + + it('refuses plain HTTP and tunnels to refused hosts', async () => { + const proxy = await startGuardProxy() + close = proxy.close + expect(await ask(proxy.port, 'GET http://brand.example.com/ HTTP/1.1\r\n\r\n')).toContain('403') + expect(await ask(proxy.port, 'CONNECT 169.254.169.254:443 HTTP/1.1\r\n\r\n')).toContain('403') + expect(proxy.refused.has('169.254.169.254')).toBe(true) + }) + + it('tunnels to an allowed host, at the address it checked', async () => { + const { createServer } = await import('node:net') + // An upstream standing in for the site, on the address the check returned. + const upstream = createServer((socket) => socket.end('hello')) + await new Promise((done) => upstream.listen(0, '127.0.0.1', done)) + const port = (upstream.address() as { port: number }).port + const proxy = await startGuardProxy({ securePort: port }) + close = async () => { + await proxy.close() + upstream.close() + } + const reply = await new Promise((resolve, reject) => { + const socket = connect({ host: '127.0.0.1', port: proxy.port }, () => + socket.write(`CONNECT brand.example.com:${port} HTTP/1.1\r\n\r\n`) + ) + let got = '' + socket.on('data', (chunk) => (got += chunk.toString())) + socket.on('close', () => resolve(got)) + socket.on('error', reject) + }) + expect(reply).toBe('HTTP/1.1 200 Connection Established\r\n\r\nhello') + }) +}) diff --git a/apps/web/src/lib/server/domains/design/render/__tests__/reader-server.test.ts b/apps/web/src/lib/server/domains/design/render/__tests__/reader-server.test.ts new file mode 100644 index 0000000000..91d790bcf6 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/__tests__/reader-server.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import type { PageRead } from '../read-page' +import { authorised, readQueue } from '../reader-server' + +describe('authorised', () => { + const token = 'a-long-enough-token' + it('accepts the service token as a bearer token and nothing else', () => { + expect(authorised(`Bearer ${token}`, token)).toBe(true) + expect(authorised(`Bearer ${token}x`, token)).toBe(false) + expect(authorised(token, token)).toBe(false) + expect(authorised(null, token)).toBe(false) + }) +}) + +describe('readQueue', () => { + it('runs one read at a time and refuses more than four waiting', async () => { + let running = 0 + let most = 0 + const release: Array<() => void> = [] + const enqueue = readQueue(async () => { + running++ + most = Math.max(most, running) + await new Promise((done) => release.push(done)) + running-- + return { ok: false, reason: 'unreachable' } satisfies PageRead + }) + const accepted = [1, 2, 3, 4].map((n) => enqueue(`https://example.com/${n}`)) + expect(accepted.every(Boolean)).toBe(true) + expect(enqueue('https://example.com/5')).toBeNull() + // Each read starts only once the one before it ends. + for (let n = 0; n < 4; n++) { + while (!release.length) await new Promise((done) => setTimeout(done, 1)) + release.shift()!() + } + await Promise.all(accepted) + expect(most).toBe(1) + // Room again once the queue drains. + expect(enqueue('https://example.com/6')).not.toBeNull() + }) +}) diff --git a/apps/web/src/lib/server/domains/design/render/__tests__/robots.test.ts b/apps/web/src/lib/server/domains/design/render/__tests__/robots.test.ts new file mode 100644 index 0000000000..d8d8bcb2ec --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/__tests__/robots.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { robotsAllows } from '../robots' + +describe('robotsAllows', () => { + it('follows the group for every agent when none names the reader', () => { + const text = `User-agent: *\nDisallow: /private\n` + expect(robotsAllows(text, '/')).toBe(true) + expect(robotsAllows(text, '/private/page')).toBe(false) + }) + + it('follows a group that names the reader over the one for every agent', () => { + const text = `User-agent: *\nAllow: /\n\nUser-agent: QuackbackBrandReader\nDisallow: /\n` + expect(robotsAllows(text, '/')).toBe(false) + }) + + it('lets the longest rule win, and Allow win a tie', () => { + const text = `User-agent: *\nDisallow: /\nAllow: /$\nAllow: /en/\n` + expect(robotsAllows(text, '/')).toBe(true) + expect(robotsAllows(text, '/en/pricing')).toBe(true) + expect(robotsAllows(text, '/de/')).toBe(false) + }) + + it('reads wildcards and shared agent lines', () => { + const text = `User-agent: GoodBot\nUser-agent: *\nDisallow: /*.pdf$\n` + expect(robotsAllows(text, '/files/guide.pdf')).toBe(false) + expect(robotsAllows(text, '/files/guide.pdf?x=1')).toBe(true) + }) + + it('allows everything an empty Disallow or no file covers', () => { + expect(robotsAllows(`User-agent: *\nDisallow:\n`, '/')).toBe(true) + expect(robotsAllows('', '/')).toBe(true) + }) +}) diff --git a/apps/web/src/lib/server/domains/design/render/guard-proxy.ts b/apps/web/src/lib/server/domains/design/render/guard-proxy.ts new file mode 100644 index 0000000000..a0818852d7 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/guard-proxy.ts @@ -0,0 +1,112 @@ +/** + * The proxy every request of a rendered read goes through. It tunnels secure + * connections on the standard port only, resolves each host itself, refuses + * private, internal and metadata addresses, and connects to the very address + * it checked, so a page can neither reach the network it runs on nor rebind a + * name between the check and the connection. Plain HTTP is refused: a page + * read over HTTPS upgrades its own requests, and a tunnel carries one host. + */ +import { connect, createServer, type Server, type Socket } from 'node:net' +import { checkUrlSafety } from '@/lib/server/content/ssrf-guard' + +const HEADER_LIMIT = 8 * 1024 +const CONNECT_TIMEOUT_MS = 10_000 + +export interface GuardProxy { + port: number + /** Hosts refused so far, for the read's report. */ + refused: Set + close: () => Promise +} + +/** The host and port a CONNECT request names, or null when it names anything else. */ +export function tunnelTarget(head: string): { host: string; port: number } | null { + const line = head.slice(0, head.indexOf('\r\n')) + const m = /^CONNECT (\[[0-9a-f:.]+\]|[^\s:]+):(\d+) HTTP\/1\.[01]$/i.exec(line) + if (!m) return null + return { host: m[1]!.replace(/^\[|\]$/g, ''), port: Number(m[2]) } +} + +/** The address a host may be reached at: public, on the secure port, or null. */ +export async function allowedAddress( + host: string, + port: number, + securePort = 443 +): Promise<{ address: string; family: 4 | 6 } | null> { + if (port !== securePort) return null + if (/(^|\.)(localhost|local|internal|localdomain|home|lan|intranet)$/i.test(host)) return null + const name = host.includes(':') ? `[${host}]` : host + const safety = await checkUrlSafety(`https://${name}/`) + return safety.safe ? { address: safety.address, family: safety.family } : null +} + +/** `securePort` is the one port tunnels may open, 443 outside tests. */ +export function startGuardProxy({ securePort = 443 } = {}): Promise { + const refused = new Set() + const sockets = new Set() + const server: Server = createServer((client) => { + sockets.add(client) + client.on('close', () => sockets.delete(client)) + client.on('error', () => client.destroy()) + let head = '' + const onData = (chunk: Buffer) => { + head += chunk.toString('latin1') + if (!head.includes('\r\n\r\n')) { + if (head.length > HEADER_LIMIT) client.destroy() + return + } + client.off('data', onData) + client.pause() + void open(client, head) + } + client.on('data', onData) + }) + + async function open(client: Socket, head: string) { + const target = tunnelTarget(head) + const allowed = target && (await allowedAddress(target.host, target.port, securePort)) + if (!target || !allowed) { + if (target) refused.add(target.host) + client.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') + return + } + const upstream = connect({ + host: allowed.address, + family: allowed.family, + port: target.port, + timeout: CONNECT_TIMEOUT_MS, + }) + sockets.add(upstream) + upstream.on('close', () => { + sockets.delete(upstream) + client.destroy() + }) + upstream.on('error', () => upstream.destroy()) + upstream.on('timeout', () => upstream.destroy()) + upstream.once('connect', () => { + upstream.setTimeout(0) + client.write('HTTP/1.1 200 Connection Established\r\n\r\n') + client.pipe(upstream) + upstream.pipe(client) + client.resume() + }) + client.on('close', () => upstream.destroy()) + } + + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') return reject(new Error('no proxy port')) + resolve({ + port: address.port, + refused, + close: () => + new Promise((done) => { + for (const socket of sockets) socket.destroy() + server.close(() => done()) + }), + }) + }) + }) +} diff --git a/apps/web/src/lib/server/domains/design/render/page-scripts.ts b/apps/web/src/lib/server/domains/design/render/page-scripts.ts new file mode 100644 index 0000000000..8778a56591 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/page-scripts.ts @@ -0,0 +1,279 @@ +/** + * Scripts a rendered read runs inside the page. Each is self-contained: it is + * sent as its source text and runs in the page, so it refers to nothing + * outside its own body. + */ + +/** A rendered text role: its type as the page sets it, colours as CSS gives them. */ +export interface PageType { + family: string + size: number + weight: number + /** In em. */ + tracking: number + /** As a multiple of the size. */ + leading: number + transform: string + colour: string +} + +/** A rendered box: a button, card or field. */ +export interface PageBox { + fill: string + ink: string + radius: number + height: number + borderWidth: number + shadow: boolean + weight: number + transform: string + text: string +} + +export interface PageMeasure { + title: string + page: string + headline: PageType | null + heading: PageType | null + body: PageType | null + button: PageBox | null + /** The button was chosen for its words: the page's main call to action. */ + action: boolean + card: PageBox | null + field: PageBox | null + /** Opaque colours the top of the page is painted in, the largest area first. */ + palette: Array<{ colour: string; share: number }> + /** The hero's fill, when a colour other than the page's fills most of it. */ + hero: string | null +} + +/** + * Declines optional cookies where a notice offers it, as a visitor would. + * Returns whether it found a button to press. + */ +export function declineConsent(): boolean { + const words = + /^(decline|reject|refuse|deny)( all| optional)?( cookies)?$|^(use |accept )?(only |strictly )?(necessary|essential|required)( cookies)?( only)?$|^no,? thanks$/i + const seen = (el: Element) => { + const r = el.getBoundingClientRect() + const cs = getComputedStyle(el) + return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' + } + const button = [...document.querySelectorAll('button, [role=button], a, input[type=button]')] + .filter(seen) + .find((el) => + words.test( + ((el as HTMLInputElement).value || el.textContent || '').trim().replace(/\s+/g, ' ') + ) + ) + if (!button) return false + ;(button as HTMLElement).click() + return true +} + +/** + * Hides what is left over the page once a notice is answered: consent + * notices that offer no way to decline, and dimming layers over the page. + * Returns how many it hid. + */ +export function hideOverlays(): number { + const notice = /(cookie|consent|privacy|gdpr|tracking)/i + let hidden = 0 + for (const el of document.querySelectorAll('body *')) { + const cs = getComputedStyle(el) + if (cs.position !== 'fixed' && cs.position !== 'sticky') continue + const r = el.getBoundingClientRect() + const covers = (r.width * r.height) / (innerWidth * innerHeight) + const dims = /rgba\(.+,\s*0?\.\d+\)$/.test(cs.backgroundColor) && covers > 0.5 + const tag = `${el.id} ${typeof el.className === 'string' ? el.className : ''} ${el.getAttribute('aria-label') ?? ''}` + const isNotice = + (notice.test(tag) || (covers < 0.6 && notice.test((el.textContent || '').slice(0, 600)))) && + cs.position === 'fixed' + if (dims || isNotice) { + ;(el as HTMLElement).style.setProperty('display', 'none', 'important') + hidden++ + } + } + document.documentElement.style.setProperty('overflow', 'auto', 'important') + document.body.style.setProperty('overflow', 'auto', 'important') + return hidden +} + +/** The page's type, colours and shapes by role, as it renders. */ +export function measurePage(): PageMeasure { + const FOLD = 1000 + const TOP = 1800 + const visible = (el: Element) => { + const r = el.getBoundingClientRect() + const cs = getComputedStyle(el) + return ( + r.width > 4 && + r.height > 4 && + r.bottom > 0 && + r.top < 2400 && + cs.visibility !== 'hidden' && + cs.display !== 'none' && + Number(cs.opacity) > 0.1 + ) + } + const words = (el: Element) => (el.textContent || '').trim().replace(/\s+/g, ' ') + const opaque = (c: string) => { + if (c === 'transparent') return false + const alpha = /rgba?\([^)]*,\s*([\d.]+)\)$/.exec(c) + return !alpha || Number(alpha[1]) >= 0.9 + } + const typeOf = (el: Element | undefined): PageType | null => { + if (!el) return null + const cs = getComputedStyle(el) + const size = parseFloat(cs.fontSize) + return { + family: cs.fontFamily, + size, + weight: Number(cs.fontWeight) || 400, + tracking: cs.letterSpacing === 'normal' ? 0 : parseFloat(cs.letterSpacing) / size, + leading: cs.lineHeight === 'normal' ? 1.2 : parseFloat(cs.lineHeight) / size, + transform: cs.textTransform, + colour: cs.color, + } + } + const boxOf = (el: Element | undefined): PageBox | null => { + if (!el) return null + const cs = getComputedStyle(el) + const r = el.getBoundingClientRect() + const radius = parseFloat(cs.borderTopLeftRadius) || 0 + return { + fill: cs.backgroundColor, + ink: cs.color, + radius: radius >= r.height / 2 - 1 && radius > 0 ? 999 : radius, + height: r.height, + borderWidth: parseFloat(cs.borderTopWidth) || 0, + shadow: cs.boxShadow !== 'none', + weight: Number(cs.fontWeight) || 400, + transform: cs.textTransform, + text: words(el).slice(0, 40), + } + } + const all = (selector: string) => [...document.querySelectorAll(selector)].filter(visible) + const bodyBg = getComputedStyle(document.body).backgroundColor + const page = opaque(bodyBg) ? bodyBg : getComputedStyle(document.documentElement).backgroundColor + + // The headline: the h1 unless much larger display text above the fold leads instead. + const texts = all('h1, h2, h3, p, span, div, a, strong').filter( + (el) => + words(el).length > 6 && + el.getBoundingClientRect().top < FOLD && + [...el.childNodes].some((n) => n.nodeType === 3 && (n.textContent || '').trim().length > 2) + ) + const size = (el: Element) => parseFloat(getComputedStyle(el).fontSize) + const largest = texts.sort((a, b) => size(b) - size(a))[0] + const h1 = all('h1').find((el) => words(el).length > 2) + const headline = h1 && (!largest || size(h1) >= size(largest) * 0.7) ? h1 : largest + const headSize = headline ? size(headline) : 0 + const heading = all('h2, h3').find((h) => size(h) < headSize * 0.95 && words(h).length > 3) + const body = all('p').find((p) => words(p).length > 60 && size(p) <= 20) + + // The main button: a filled control near the top, its words asking visitors to act first. + const cta = + /^(get( \w+)?( started| free| the app| a demo| \w+ free)?|start\b|sign ?up|try\b|join\b|buy\b|shop\b|book\b|send\b|download\b|request\b|contact sales|create\b|open (an )?account|subscribe\b)/i + const notice = /(accept|reject|decline|cookie|consent|preferences|customi[sz]e|skip to)/i + const signIn = /^(log ?in|sign ?in|my account)$/i + const buttons = all('a, button, [role=button], input[type=submit]').filter((el) => { + const text = words(el) + const cs = getComputedStyle(el) + const r = el.getBoundingClientRect() + return ( + text.length > 1 && + text.length < 32 && + !notice.test(text) && + r.left >= 0 && + r.top < 1400 && + r.height >= 28 && + opaque(cs.backgroundColor) && + cs.backgroundColor !== page + ) + }) + const rank = (el: Element) => + (cta.test(words(el)) ? 3 : 0) + + (el.getBoundingClientRect().top < 900 ? 1 : 0) - + (signIn.test(words(el)) ? 3 : 0) + const button = buttons + .map((el, order) => ({ el, order, score: rank(el) })) + .sort((a, b) => b.score - a.score || a.order - b.order)[0] + + const card = all('div, article, section, li, a').find((el) => { + const cs = getComputedStyle(el) + const r = el.getBoundingClientRect() + return ( + r.width > 180 && + r.height > 100 && + r.width < 900 && + parseFloat(cs.borderTopLeftRadius) > 0 && + (cs.boxShadow !== 'none' || + parseFloat(cs.borderTopWidth) > 0 || + (opaque(cs.backgroundColor) && cs.backgroundColor !== page)) + ) + }) + const field = all('input[type=text], input[type=search], input[type=email], input:not([type])')[0] + + // Painted area by colour over the top of the page: each element's own + // background, less what its painted children cover, clipped to the screen. + const areas = new Map() + const clipped = (r: DOMRect) => + Math.max(0, Math.min(r.right, innerWidth) - Math.max(r.left, 0)) * + Math.max(0, Math.min(r.bottom, TOP) - Math.max(r.top, 0)) + const painted = [...document.querySelectorAll('body, body *')] + .slice(0, 4000) + .filter((el) => visible(el) && opaque(getComputedStyle(el).backgroundColor)) + // Each painted element covers part of the nearest painted element it sits in. + const paintedSet = new Set(painted) + const covered = new Map() + for (const el of painted) { + let above = el.parentElement + while (above && !paintedSet.has(above)) above = above.parentElement + if (above) covered.set(above, (covered.get(above) ?? 0) + clipped(el.getBoundingClientRect())) + } + for (const el of painted) { + const area = Math.max(0, clipped(el.getBoundingClientRect()) - (covered.get(el) ?? 0)) + if (!area) continue + const colour = getComputedStyle(el).backgroundColor + areas.set(colour, (areas.get(colour) ?? 0) + area) + } + // Shares of the painted area: layers can overlap, so the screen's size is not the whole. + const total = [...areas.values()].reduce((sum, area) => sum + area, 0) || 1 + const palette = [...areas] + .map(([colour, area]) => ({ colour, share: area / total })) + .sort((a, b) => b.share - a.share) + .slice(0, 8) + + // The hero's fill: the colour most of the first screen is painted, when it is not the page's. + const hero = + [...document.querySelectorAll('body *')] + .slice(0, 1500) + .filter((el) => { + const r = el.getBoundingClientRect() + const cs = getComputedStyle(el) + return ( + visible(el) && + r.top < 200 && + r.width >= innerWidth * 0.9 && + r.height >= 300 && + opaque(cs.backgroundColor) && + cs.backgroundColor !== page + ) + }) + .map((el) => getComputedStyle(el).backgroundColor)[0] ?? null + + return { + title: document.title, + page, + headline: typeOf(headline), + heading: typeOf(heading), + body: typeOf(body), + button: boxOf(button?.el), + action: !!button && button.score >= 3, + card: boxOf(card), + field: boxOf(field), + palette, + hero, + } +} diff --git a/apps/web/src/lib/server/domains/design/render/read-page.ts b/apps/web/src/lib/server/domains/design/render/read-page.ts new file mode 100644 index 0000000000..3d14e751af --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/read-page.ts @@ -0,0 +1,174 @@ +/** + * One rendered read of a website's home page, in a headless Chromium driven + * over the DevTools protocol. The reader says what it is: it sends its own + * agent, keeps the browser's automation flag, respects robots.txt, declines + * optional cookies, and never works around a bot check. Every request the page + * makes goes through the guard proxy, so it reaches public hosts only. + * + * It runs in a process of its own, one read per process, so a page that hangs + * or leaks is ended with its process. + */ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { safeFetch } from '@/lib/server/content/ssrf-guard' +import { startGuardProxy } from './guard-proxy' +import { declineConsent, hideOverlays, measurePage, type PageMeasure } from './page-scripts' +import { READER_TOKEN, robotsAllows } from './robots' + +export const READER_AGENT = `Mozilla/5.0 (compatible; ${READER_TOKEN}/1.0; +https://quackback.io)` + +const WIDTH = 1440 +const HEIGHT = 900 +/** The screenshot's width: what a model reading images needs, at a modest size. */ +const SHOT_WIDTH = 1024 +const LOAD_MS = 20_000 +const SETTLE_MS = 1_500 +const CONSENT_MS = 800 +/** How long consent rules may take to find and answer a notice. */ +const CONSENT_RULES_MS = 4_000 + +export type PageRead = + | { + ok: true + url: string + measure: PageMeasure + /** The first screen, as a JPEG data URL. */ + screenshot: string | null + refused: string[] + } + | { ok: false; reason: 'robots' | 'unreachable' | 'blocked' } + +/** Whether robots.txt lets the reader visit a page; an unreadable file allows it. */ +async function robotsLet(url: URL): Promise { + let address = new URL('/robots.txt', url).toString() + for (let hop = 0; hop < 4; hop++) { + const response = await safeFetch(address, { + headers: { 'user-agent': READER_AGENT, accept: 'text/plain' }, + timeoutMs: 5_000, + maxResponseBytes: 512 * 1024, + }).catch(() => null) + if (!response) return true + const next = response.headers.get('location') + if (response.status >= 300 && response.status < 400 && next) { + address = new URL(next, address).toString() + continue + } + if (response.status >= 400) return true + return robotsAllows(await response.text(), url.pathname || '/') + } + return true +} + +const run = (script: () => T) => `(${script.toString()})()` + +/** + * The consent rules, run in the page as it loads: they recognise common + * consent notices and choose to decline optional cookies. The image sets the + * script's path; in development it sits beside the installed package. + */ +async function consentRules(): Promise { + const path = + process.env.QUACKBACK_BROWSER_CONSENT_SCRIPT ?? + join( + dirname(fileURLToPath(import.meta.resolve('@duckduckgo/autoconsent'))), + 'autoconsent.standalone.js' + ) + return Bun.file(path) + .text() + .catch(() => null) +} + +/** Whether the consent rules answered a notice, waiting while they work. */ +async function consentAnswered(view: Bun.WebView): Promise { + const started = Date.now() + while (Date.now() - started < CONSENT_RULES_MS) { + const types = (await view + .evaluate('(window.autoconsentStandalone?.messages ?? []).map((m) => m.type)') + .catch(() => [])) as string[] + if (types.includes('autoconsentDone') || types.includes('optOutResult')) return true + // Nothing recognised once detection has run its course: there is no notice it knows. + if (!types.includes('popupFound') && Date.now() - started > 2_000) return false + await Bun.sleep(250) + } + return false +} + +const timeout = (ms: number) => + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), ms)) + +export async function readPage(address: string, browser?: string): Promise { + const url = new URL(address) + if (url.protocol !== 'https:') url.protocol = 'https:' + if (!(await robotsLet(url))) return { ok: false, reason: 'robots' } + + const proxy = await startGuardProxy() + const view = new Bun.WebView({ + width: WIDTH, + height: HEIGHT, + backend: { + type: 'chrome', + url: false, + ...(browser ? { path: browser } : {}), + argv: [ + `--user-agent=${READER_AGENT}`, + `--proxy-server=http://127.0.0.1:${proxy.port}`, + // Even loopback goes through the proxy, which refuses it. + '--proxy-bypass-list=<-loopback>', + '--disable-quic', + '--force-webrtc-ip-handling-policy=disable_non_proxied_udp', + '--disable-dev-shm-usage', + // Memory: a read needs one page, its frames in it, and no video playing. + '--renderer-process-limit=1', + '--disable-site-isolation-trials', + '--disable-features=site-per-process,Translate,MediaRouter,OptimizationHints', + '--autoplay-policy=user-gesture-required', + '--js-flags=--max-old-space-size=384', + '--lang=en-GB', + '--hide-scrollbars', + '--mute-audio', + ...(process.env.QUACKBACK_BROWSER_SANDBOX === 'off' ? ['--no-sandbox'] : []), + ], + }, + }) + try { + const rules = await consentRules() + if (rules) { + // The DevTools session opens with a first page; the rules then run in every page after it. + await view.navigate('about:blank') + await view.cdp('Page.addScriptToEvaluateOnNewDocument', { source: rules }) + } + // A page that never finishes loading is read as it stands once the wait is up. + const loaded = await Promise.race([ + view.navigate(url.toString()).then(() => 'loaded'), + timeout(LOAD_MS), + ]) + const status = await view.evaluate('document.readyState').catch(() => null) + if (loaded !== 'loaded' && status !== 'interactive' && status !== 'complete') + return { ok: false, reason: 'unreachable' } + await Bun.sleep(SETTLE_MS) + const answered = rules ? await consentAnswered(view) : false + if (!answered && (await view.evaluate(run(declineConsent)).catch(() => false))) + await Bun.sleep(CONSENT_MS) + await view.evaluate(run(hideOverlays)).catch(() => 0) + const measure = (await view.evaluate(run(measurePage))) as PageMeasure + if (!measure?.page) return { ok: false, reason: 'unreachable' } + const height = Number(await view.evaluate('innerHeight').catch(() => HEIGHT)) || HEIGHT + const shot = (await view + .cdp('Page.captureScreenshot', { + format: 'jpeg', + quality: 80, + clip: { x: 0, y: 0, width: WIDTH, height, scale: SHOT_WIDTH / WIDTH }, + }) + .catch(() => null)) as { data?: string } | null + return { + ok: true, + url: view.url || url.toString(), + measure, + screenshot: shot?.data ? `data:image/jpeg;base64,${shot.data}` : null, + refused: [...proxy.refused], + } + } finally { + view.close() + await proxy.close() + } +} diff --git a/apps/web/src/lib/server/domains/design/render/reader-server.ts b/apps/web/src/lib/server/domains/design/render/reader-server.ts new file mode 100644 index 0000000000..f8c2ac8949 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/reader-server.ts @@ -0,0 +1,119 @@ +/** + * The website reader's service, run in a container of its own beside + * Quackback. It answers `POST /read` for callers holding its token, one read + * at a time, each read in a fresh process that is ended if it runs over time, + * so a page that hangs or leaks takes nothing with it. + * + * QUACKBACK_BROWSER_TOKEN required; callers send it as a bearer token + * PORT default 3200 + * QUACKBACK_BROWSER_PATH the Chromium to drive, when not on PATH + * + * Run with an argument of `read `, it performs one read and prints it. + */ +import { timingSafeEqual } from 'node:crypto' +import { logger } from '@/lib/server/logger' +import { parseWebsiteInput, isStandardWebsiteUrl } from '@/lib/shared/website-branding' +import { readPage, type PageRead } from './read-page' + +const log = logger.child({ module: 'browser-reader' }) + +const READ_MS = 45_000 +const MAX_WAITING = 4 +const OUTPUT_BYTES = 4 * 1024 * 1024 +/** Marks the read in a child's output, past anything else it prints. */ +const MARK = '\n@@quackback-read@@' + +/** Whether a request carries the service's token. */ +export function authorised(header: string | null, token: string): boolean { + const given = Buffer.from(/^Bearer (.+)$/.exec(header ?? '')?.[1] ?? '') + const expected = Buffer.from(token) + return given.length === expected.length && timingSafeEqual(given, expected) +} + +/** Runs reads one after another, refusing more than a few waiting. */ +export function readQueue(read: (url: string) => Promise) { + let tail: Promise = Promise.resolve() + let waiting = 0 + return (url: string): Promise | null => { + if (waiting >= MAX_WAITING) return null + waiting++ + const next = tail.then(() => read(url)).finally(() => waiting--) + tail = next.catch(() => undefined) + return next + } +} + +/** One read in a process of its own, ended when it overruns. */ +async function readInProcess(url: string): Promise { + const child = Bun.spawn([process.execPath, process.argv[1]!, 'read', url], { + stdout: 'pipe', + stderr: 'ignore', + env: process.env, + }) + const timer = setTimeout(() => child.kill('SIGKILL'), READ_MS) + try { + const text = await new Response(child.stdout).text() + await child.exited + const at = text.lastIndexOf(MARK) + if (at < 0 || text.length > OUTPUT_BYTES) return { ok: false, reason: 'unreachable' } + return JSON.parse(text.slice(at + MARK.length)) as PageRead + } catch { + return { ok: false, reason: 'unreachable' } + } finally { + clearTimeout(timer) + } +} + +async function serve() { + const token = process.env.QUACKBACK_BROWSER_TOKEN + if (!token || token.length < 16) { + log.error('QUACKBACK_BROWSER_TOKEN must be set to at least 16 characters') + process.exit(1) + } + const enqueue = readQueue(readInProcess) + const server = Bun.serve({ + hostname: '::', + port: Number(process.env.PORT) || 3200, + async fetch(request) { + const path = new URL(request.url).pathname + if (request.method === 'GET' && path === '/health') return new Response('ok') + if (request.method !== 'POST' || path !== '/read') + return new Response('Not found', { status: 404 }) + if (!authorised(request.headers.get('authorization'), token)) + return new Response('Unauthorised', { status: 401 }) + const body = (await request.json().catch(() => null)) as { url?: unknown } | null + const url = typeof body?.url === 'string' ? parseWebsiteInput(body.url) : null + if (!url || !isStandardWebsiteUrl(url)) + return Response.json({ ok: false, reason: 'invalid' }, { status: 400 }) + const pending = enqueue(url.toString()) + if (!pending) return Response.json({ ok: false, reason: 'busy' }, { status: 429 }) + const started = Date.now() + const result = await pending + log.info( + { + host: url.hostname, + ok: result.ok, + ms: Date.now() - started, + ...(result.ok ? {} : { reason: result.reason }), + }, + 'read a website' + ) + return Response.json(result) + }, + }) + log.info({ port: server.port }, 'website reader listening') +} + +async function readOnce(url: string) { + const result = await readPage(url, process.env.QUACKBACK_BROWSER_PATH).catch((): PageRead => ({ + ok: false, + reason: 'unreachable', + })) + process.stdout.write(MARK + JSON.stringify(result)) + process.exit(0) +} + +if (import.meta.main) { + if (process.argv[2] === 'read' && process.argv[3]) void readOnce(process.argv[3]) + else void serve() +} diff --git a/apps/web/src/lib/server/domains/design/render/robots.ts b/apps/web/src/lib/server/domains/design/render/robots.ts new file mode 100644 index 0000000000..7be86d6fbb --- /dev/null +++ b/apps/web/src/lib/server/domains/design/render/robots.ts @@ -0,0 +1,65 @@ +/** + * Whether a site's robots.txt lets Quackback's reader visit a page. The group + * naming the reader applies, else the one for every agent; within it the + * longest matching rule wins and Allow wins a tie, as crawlers read it. + */ + +/** The name the reader goes by in robots.txt. */ +export const READER_TOKEN = 'QuackbackBrandReader' + +interface Rule { + allow: boolean + pattern: string +} + +function groups(text: string): Array<{ agents: string[]; rules: Rule[] }> { + const out: Array<{ agents: string[]; rules: Rule[] }> = [] + let current: { agents: string[]; rules: Rule[] } | null = null + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, '').trim() + const colon = line.indexOf(':') + if (colon < 0) continue + const key = line.slice(0, colon).trim().toLowerCase() + const value = line.slice(colon + 1).trim() + if (key === 'user-agent') { + // Consecutive agent lines share the rules that follow them. + if (!current || current.rules.length) out.push((current = { agents: [], rules: [] })) + current.agents.push(value.toLowerCase()) + } else if ((key === 'allow' || key === 'disallow') && current) { + // An empty Disallow allows everything, so it adds no rule. + if (value) current.rules.push({ allow: key === 'allow', pattern: value }) + } + } + return out +} + +function matches(pattern: string, path: string): boolean { + const anchored = pattern.endsWith('$') + const body = anchored ? pattern.slice(0, -1) : pattern + const regex = body + .split('*') + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) + .join('.*') + return new RegExp(`^${regex}${anchored ? '$' : ''}`).test(path) +} + +export function robotsAllows(text: string, path: string, token = READER_TOKEN): boolean { + const all = groups(text) + const name = token.toLowerCase() + const group = + all.find((g) => g.agents.some((agent) => agent !== '*' && name.includes(agent))) ?? + all.find((g) => g.agents.includes('*')) + if (!group) return true + let best: Rule | null = null + for (const rule of group.rules) { + if (!matches(rule.pattern, path)) continue + const length = rule.pattern.length + if ( + !best || + length > best.pattern.length || + (length === best.pattern.length && rule.allow && !best.allow) + ) + best = rule + } + return best ? best.allow : true +} diff --git a/bun.lock b/bun.lock index b9ba0d7032..59db3bd630 100644 --- a/bun.lock +++ b/bun.lock @@ -52,6 +52,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@duckduckgo/autoconsent": "17.2.0", "@floating-ui/dom": "^1.7.6", "@fontsource-variable/inter": "^5.3.0", "@fontsource/barlow-condensed": "^5.3.0", @@ -469,6 +470,8 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@duckduckgo/autoconsent": ["@duckduckgo/autoconsent@17.2.0", "", {}, "sha512-16K0g9pLMIyMkK+vkzSlZROAtD6SgelRd7ZI6wo7y0PaKDM11F3DmOogDvXESHKAm9/z9gTizDXXgTazxZU0pw=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b4f1f92e7d..21651594ec 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -72,6 +72,26 @@ services: retries: 5 start_period: 40s + # Optional: a website reader that renders a page in a headless browser, so + # Quackback AI reads a website's exact type, colours and buttons and sees a + # clean screenshot of it. Without it, websites are read from their HTML and + # CSS. Turn it on with `--profile browser`, and set QUACKBACK_BROWSER_URL= + # http://browser:3200 and QUACKBACK_BROWSER_TOKEN in .env. It holds no secret + # but its token and reaches public hosts only. + browser: + image: ghcr.io/quackbackio/quackback-browser:${QUACKBACK_TAG:-latest} + container_name: quackback-browser + profiles: ['browser'] + restart: unless-stopped + init: true + environment: + QUACKBACK_BROWSER_TOKEN: ${QUACKBACK_BROWSER_TOKEN:?set QUACKBACK_BROWSER_TOKEN in .env (openssl rand -hex 24)} + # A heavy page can need over a gigabyte while it renders; a read that needs + # more ends on its own and the website is read from its HTML instead. + mem_limit: 1536m + tmpfs: + - /tmp + postgres: build: context: ./docker/postgres diff --git a/docker/browser/Dockerfile b/docker/browser/Dockerfile new file mode 100644 index 0000000000..904e4fbce9 --- /dev/null +++ b/docker/browser/Dockerfile @@ -0,0 +1,36 @@ +# The website reader: a headless Chromium behind a small read service that +# Quackback calls to read a website as a browser renders it. It holds no +# secret beyond its own token, and every request a page makes goes through +# its guard, which reaches public hosts only. +# +# Build from the repository root: +# docker build -f docker/browser/Dockerfile -t quackback-browser . + +FROM oven/bun:1.4.0 AS builder +WORKDIR /app +COPY package.json bun.lock tsconfig.json ./ +COPY apps/web ./apps/web +COPY packages ./packages +COPY scripts ./scripts +ENV HUSKY=0 +RUN bun install --frozen-lockfile +WORKDIR /app/apps/web +RUN mkdir -p /out \ + && bun build src/lib/server/domains/design/render/reader-server.ts --target=bun --outfile=/out/reader.js \ + && cp "$(dirname "$(bun -e "console.log(Bun.resolveSync('@duckduckgo/autoconsent', process.cwd()))")")/autoconsent.standalone.js" /out/consent.js + +FROM oven/bun:1.4.0-alpine AS runner +RUN apk add --no-cache chromium \ + && adduser -S -u 1001 reader +WORKDIR /app +COPY --from=builder --chown=reader /out ./ +USER reader +ENV NODE_ENV=production \ + PORT=3200 \ + QUACKBACK_BROWSER_CONSENT_SCRIPT=/app/consent.js \ + # A container cannot give Chromium the namespaces its own sandbox needs. + # The container is the boundary: it holds no secrets and reaches public hosts only. + QUACKBACK_BROWSER_SANDBOX=off +EXPOSE 3200 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:3200/health || exit 1 +CMD ["bun", "/app/reader.js"] From 49655e6e2c1dbc997f95254ca61dcb6d2969702e Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 22:34:33 +0000 Subject: [PATCH 2/6] feat(design): Quackback AI reads a website through the rendering reader when one is set up With QUACKBACK_BROWSER_URL and QUACKBACK_BROWSER_TOKEN set, starting a design from a website asks the reader for the page as a browser renders it, alongside the read of its HTML and CSS. What the render shows leads: the main button and its colours, the hero's fill, the type of each role and the page's colours by area. The rendered first screen stands in for a screenshot when none is given. A reader that is waking is asked once more; without a reader, or no answer within the budget, nothing changes. --- apps/web/src/lib/server/config.ts | 12 ++ .../design/ai/__tests__/site-render.test.ts | 168 +++++++++++++++ .../server/domains/design/ai/site-render.ts | 203 ++++++++++++++++++ .../lib/server/functions/portal-design-ai.ts | 26 ++- 4 files changed, 402 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts create mode 100644 apps/web/src/lib/server/domains/design/ai/site-render.ts diff --git a/apps/web/src/lib/server/config.ts b/apps/web/src/lib/server/config.ts index 5409fbf482..299c28185d 100644 --- a/apps/web/src/lib/server/config.ts +++ b/apps/web/src/lib/server/config.ts @@ -124,6 +124,10 @@ const configSchema = z // Quackback AI may write a portal design's CSS as well as its fields. Off // until it is shown to design better with it. portalDesignAiCss: envBoolean, + // A website reader beside Quackback that renders a page in a headless + // browser. Optional: without it, websites are read from their HTML and CSS. + browserUrl: z.string().url().optional(), + browserToken: z.string().min(16).optional(), // Database // @@ -379,6 +383,8 @@ function buildConfigFromEnv(): unknown { port: env('PORT'), portalDesignEngine: env('PORTAL_DESIGN_ENGINE'), portalDesignAiCss: env('PORTAL_DESIGN_AI_CSS'), + browserUrl: env('QUACKBACK_BROWSER_URL'), + browserToken: env('QUACKBACK_BROWSER_TOKEN'), // Database databaseUrl: env('DATABASE_URL'), @@ -578,6 +584,12 @@ export const config = { get portalDesignAiCss() { return loadConfig().portalDesignAiCss }, + get browserUrl() { + return loadConfig().browserUrl + }, + get browserToken() { + return loadConfig().browserToken + }, get controlDatabaseUrl() { return loadConfig().controlDatabaseUrl }, diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts new file mode 100644 index 0000000000..27deab361b --- /dev/null +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PageMeasure } from '../../render/page-scripts' +import type { SiteSignals } from '../site-signals' + +const settings = vi.hoisted(() => ({ + browserUrl: undefined as string | undefined, + browserToken: undefined as string | undefined, +})) +vi.mock('@/lib/server/config', () => ({ config: settings })) + +const { fromPage, renderSite, renderedSignals, withRendered } = await import('../site-render') + +const page: PageMeasure = { + title: 'Brand', + page: 'rgb(255, 255, 255)', + headline: { + family: '"Display Sans", Inter, sans-serif', + size: 103.5, + weight: 800, + tracking: 0, + leading: 0.85, + transform: 'none', + colour: 'rgb(22, 51, 0)', + }, + heading: null, + body: { + family: 'Inter, sans-serif', + size: 16, + weight: 400, + tracking: 0, + leading: 1.5, + transform: 'none', + colour: 'rgb(14, 15, 12)', + }, + button: { + fill: 'rgb(159, 232, 112)', + ink: 'rgb(22, 51, 0)', + radius: 999, + height: 48, + borderWidth: 0, + shadow: false, + weight: 600, + transform: 'none', + text: 'Send money', + }, + action: true, + card: null, + field: null, + palette: [ + { colour: 'rgb(255, 255, 255)', share: 0.6 }, + { colour: 'rgb(159, 232, 112)', share: 0.3 }, + { colour: 'rgba(0, 0, 0, 0.4)', share: 0.1 }, + ], + hero: 'rgb(159, 232, 112)', +} + +const staticSite: SiteSignals = { + domain: 'brand.example.com', + title: null, + description: 'A brand', + colour: '#37517e', + palette: ['#37517e', '#163300'], + background: '#f7f7f7', + fonts: { heading: 'Inter', body: 'Inter' }, + dark: false, + button: '#37517e', + bold: false, + monochrome: false, + hero: null, + logo: null, + shareImage: null, + measured: { headline: { size: 64 }, card: { radius: 16 } }, +} + +describe('fromPage', () => { + it('reads the main button, hero and type as the page renders them', () => { + const rendered = fromPage(page) + expect(rendered.measured.button).toMatchObject({ + fill: '#9fe870', + ink: '#163300', + radius: 999, + action: true, + }) + expect(rendered.measured.headline).toMatchObject({ + size: 103.5, + weight: 800, + colour: '#163300', + }) + expect(rendered.brand).toMatchObject({ + colour: '#9fe870', + hero: '#9fe870', + bold: true, + background: '#ffffff', + dark: false, + }) + expect(rendered.brand.fonts.heading).toBe('"Display Sans", Inter, sans-serif') + // A dimming layer is not a colour the page is painted in. + expect(rendered.brand.palette).toEqual(['#9fe870', '#ffffff']) + }) +}) + +describe('withRendered', () => { + it('lets the render lead the CSS read, keeping what only the CSS read found', () => { + const merged = withRendered(staticSite, { ...fromPage(page), screenshot: null }) + expect(merged.colour).toBe('#9fe870') + expect(merged.palette[0]).toBe('#9fe870') + expect(merged.palette).toContain('#163300') + expect(merged.description).toBe('A brand') + expect(merged.measured?.headline?.size).toBe(103.5) + // The render found no card, so the CSS read's stands. + expect(merged.measured?.card).toEqual({ radius: 16 }) + }) + + it('stands alone when the page HTML could not be read', () => { + const alone = renderedSignals('brand.example.com', { ...fromPage(page), screenshot: null }) + expect(alone).toMatchObject({ domain: 'brand.example.com', colour: '#9fe870', title: 'Brand' }) + }) +}) + +describe('renderSite', () => { + afterEach(() => { + vi.unstubAllGlobals() + settings.browserUrl = undefined + settings.browserToken = undefined + }) + + it('does nothing without a reader set up', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + expect(await renderSite('brand.example.com')).toBeNull() + expect(fetch).not.toHaveBeenCalled() + }) + + it('asks the reader with its token, once more if it is waking', async () => { + settings.browserUrl = 'http://reader.internal:3200' + settings.browserToken = 'a-long-enough-token' + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response('waking', { status: 502 })) + .mockResolvedValueOnce( + Response.json({ + ok: true, + url: 'https://brand.example.com/', + measure: page, + screenshot: 'data:image/jpeg;base64,AA==', + refused: [], + }) + ) + vi.stubGlobal('fetch', fetch) + const rendered = await renderSite('brand.example.com') + expect(rendered?.brand.colour).toBe('#9fe870') + expect(rendered?.screenshot).toBe('data:image/jpeg;base64,AA==') + expect(fetch).toHaveBeenCalledTimes(2) + const [url, init] = fetch.mock.calls[0]! + expect(String(url)).toBe('http://reader.internal:3200/read') + expect(init.headers.authorization).toBe('Bearer a-long-enough-token') + }) + + it('gives nothing back when the reader cannot read the site', async () => { + settings.browserUrl = 'http://reader.internal:3200' + settings.browserToken = 'a-long-enough-token' + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ ok: false, reason: 'robots' })) + ) + expect(await renderSite('brand.example.com')).toBeNull() + }) +}) diff --git a/apps/web/src/lib/server/domains/design/ai/site-render.ts b/apps/web/src/lib/server/domains/design/ai/site-render.ts new file mode 100644 index 0000000000..a1739dd8a8 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/ai/site-render.ts @@ -0,0 +1,203 @@ +/** + * A website read as a browser renders it, from the website reader beside + * Quackback when one is configured. Its values are what the page shows, so + * they lead the read of the page's HTML and CSS wherever both have one. With + * no reader, or none answering within the budget, nothing changes. + */ +import { config } from '@/lib/server/config' +import { logger } from '@/lib/server/logger' +import { contrastRatio } from '@/lib/shared/design/contrast' +import type { + BrandMeasurements, + MeasuredBox, + MeasuredType, +} from '@/lib/shared/design/measured-tune' +import { isBrandColour } from '@/lib/shared/design/palette' +import type { PageBox, PageMeasure, PageType } from '../render/page-scripts' +import type { PageRead } from '../render/read-page' +import { hex } from './site-brand' +import type { SiteSignals } from './site-signals' + +const log = logger.child({ module: 'site-render' }) + +const BUDGET_MS = 25_000 +/** A reader that sleeps between reads can refuse the first request while it wakes. */ +const WAKE_MS = 1_500 + +export interface RenderedSite { + measured: BrandMeasurements + /** What the render says of the brand, where it can say it. */ + brand: Pick & + Partial> & { + fonts: { heading: string | null; body: string | null } + /** Its colours by painted area with its main button's first, neutrals included. */ + palette: string[] + } + /** The first screen, as a JPEG data URL. */ + screenshot: string | null + title: string | null +} + +const fill = (colour: string) => { + const alpha = /rgba\([^)]*,\s*([\d.]+)\)$/.exec(colour) + return alpha && Number(alpha[1]) < 0.9 ? null : hex(colour) +} + +/** A family stack's first few names, which the font matcher reads in order. */ +const stack = (family: string | undefined) => + family + ? family + .split(',') + .slice(0, 3) + .map((name) => name.trim()) + .join(', ') + : null + +function typeOf(t: PageType | null): MeasuredType | null { + if (!t || !t.size) return null + const colour = hex(t.colour) + return { + size: t.size, + weight: t.weight, + ...(Math.abs(t.tracking) >= 0.005 && { tracking: Math.round(t.tracking * 1000) / 1000 }), + ...(Number.isFinite(t.leading) && { leading: Math.round(t.leading * 100) / 100 }), + ...(colour && { colour }), + } +} + +function boxOf(b: PageBox | null, action = false): MeasuredBox | null { + if (!b) return null + const background = fill(b.fill) + const ink = hex(b.ink) + return { + radius: b.radius >= 999 ? 999 : Math.round(b.radius), + height: Math.round(b.height), + borderWidth: b.borderWidth, + shadow: b.shadow, + ...(background && { fill: background }), + ...(ink && { ink }), + weight: b.weight, + ...(b.transform === 'uppercase' && { upper: true }), + ...(action && { action: true }), + } +} + +/** A rendered page's measurements and brand, in the terms the design steps read. */ +export function fromPage(page: PageMeasure): Omit { + const background = fill(page.page) ?? '#ffffff' + const button = page.button ? fill(page.button.fill) : null + const hero = page.hero ? fill(page.hero) : null + const painted = page.palette.flatMap(({ colour }) => fill(colour) ?? []) + const vivid = [button, hero, ...painted].filter( + (c, i, all): c is string => !!c && isBrandColour(c) && all.indexOf(c) === i + ) + const palette = [button, ...painted].filter( + (c, i, all): c is string => !!c && all.indexOf(c) === i + ) + const brandHero = hero && hero !== background ? hero : null + return { + title: page.title || null, + measured: { + headline: typeOf(page.headline), + heading: typeOf(page.heading), + button: boxOf(page.button, page.action), + card: boxOf(page.card), + field: boxOf(page.field), + }, + brand: { + background, + dark: contrastRatio('#ffffff', background) > contrastRatio('#000000', background), + button, + monochrome: !!button && !isBrandColour(button) && contrastRatio('#ffffff', button) > 7, + ...(vivid[0] && { colour: vivid[0] }), + ...(brandHero && { hero: brandHero, bold: isBrandColour(brandHero) }), + fonts: { heading: stack(page.headline?.family), body: stack(page.body?.family) }, + palette: palette.slice(0, 6), + }, + } +} + +/** The site's signals with what the render shows leading what its CSS suggests. */ +export function withRendered(site: SiteSignals, rendered: RenderedSite): SiteSignals { + const { brand } = rendered + const vivid = brand.palette.filter(isBrandColour) + return { + ...site, + title: site.title ?? rendered.title, + background: brand.background, + dark: brand.dark, + button: brand.button ?? site.button, + monochrome: brand.button ? brand.monochrome : site.monochrome, + colour: brand.colour ?? site.colour, + hero: brand.hero ?? site.hero, + bold: brand.bold ?? site.bold, + fonts: { + heading: brand.fonts.heading ?? site.fonts.heading, + body: brand.fonts.body ?? site.fonts.body, + }, + palette: [...vivid, ...site.palette.filter((c) => !vivid.includes(c))].slice(0, 5), + measured: { ...site.measured, ...withoutEmpty(rendered.measured) }, + } +} + +/** Signals from the render alone, when the page's HTML could not be read. */ +export function renderedSignals(domain: string, rendered: RenderedSite): SiteSignals { + const { brand } = rendered + return withRendered( + { + domain, + title: rendered.title, + description: null, + colour: null, + palette: [], + background: null, + fonts: { heading: null, body: null }, + dark: false, + button: null, + bold: false, + monochrome: false, + hero: null, + logo: null, + shareImage: null, + measured: null, + }, + { ...rendered, brand: { ...brand } } + ) +} + +function withoutEmpty(measured: BrandMeasurements): BrandMeasurements { + return Object.fromEntries( + Object.entries(measured).filter(([, value]) => value) + ) as BrandMeasurements +} + +/** The site as the reader renders it, or null without a reader or an answer in time. */ +export async function renderSite(site: string, budgetMs = BUDGET_MS): Promise { + const base = config.browserUrl + const token = config.browserToken + if (!base || !token) return null + const deadline = Date.now() + budgetMs + for (let attempt = 0; attempt < 2; attempt++) { + const response = await fetch(new URL('/read', base), { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ url: site }), + signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())), + }).catch(() => null) + if (response && (response.status === 502 || response.status === 503) && attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, WAKE_MS)) + continue + } + if (!response?.ok) { + log.warn({ status: response?.status ?? null }, 'website reader did not answer') + return null + } + const read = (await response.json().catch(() => null)) as PageRead | null + if (!read?.ok) { + log.info({ reason: read?.reason ?? 'unreadable' }, 'website reader could not read the site') + return null + } + return { ...fromPage(read.measure), screenshot: read.screenshot } + } + return null +} diff --git a/apps/web/src/lib/server/functions/portal-design-ai.ts b/apps/web/src/lib/server/functions/portal-design-ai.ts index a976170d63..8eabc3ad91 100644 --- a/apps/web/src/lib/server/functions/portal-design-ai.ts +++ b/apps/web/src/lib/server/functions/portal-design-ai.ts @@ -6,6 +6,7 @@ import { createServerFn } from '@tanstack/react-start' import { z } from 'zod' import { PERMISSIONS } from '@/lib/shared/permissions' +import { parseWebsiteInput } from '@/lib/shared/website-branding' import { DESIGN_AI_SCOPES } from '@/lib/shared/design/apply-changes' import { requireAuth } from './auth-helpers' import { logger } from '@/lib/server/logger' @@ -137,21 +138,31 @@ export const portalDesignDirectionsFn = createServerFn({ method: 'POST' }) ) .handler(async ({ data }) => { const auth = await requireAuth({ permission: PERMISSIONS.SETTINGS_BRANDING }) - const [{ designDirections }, { readSiteSignals }, { importSiteAssets }, actions] = + const [{ designDirections }, { readSiteSignals }, render, { importSiteAssets }, actions] = await Promise.all([ import('@/lib/server/domains/design/ai/design-ai'), import('@/lib/server/domains/design/ai/site-signals'), + import('@/lib/server/domains/design/ai/site-render'), import('@/lib/server/domains/design/ai/site-assets'), import('@/lib/server/domains/design/design.actions'), ]) - const [read, logoAdopted] = data.site + // The page's HTML and CSS, and the page as a browser renders it when a reader is set up. + const [read, rendered, logoAdopted] = data.site ? await Promise.all([ readSiteSignals(data.site), + render.renderSite(data.site), adoptSiteLogo(data.site, auth.permissions.includes(PERMISSIONS.SETTINGS_MANAGE)), ]) - : [null, false] - const site = read?.ok ? read.signals : null - if (read && !read.ok && !data.words && !data.palette) { + : [null, null, false] + const domain = data.site ? (parseWebsiteInput(data.site)?.hostname ?? data.site) : '' + const site = read?.ok + ? rendered + ? render.withRendered(read.signals, rendered) + : read.signals + : rendered + ? render.renderedSignals(domain, rendered) + : null + if (read && !site && !read.ok && !data.words && !data.palette) { return { ok: false as const, code: 'SITE_UNREADABLE' as const, @@ -173,8 +184,9 @@ export const portalDesignDirectionsFn = createServerFn({ method: 'POST' }) hasMark: allows.hasMark || logoAdopted, site, words: data.words ?? null, - palette: data.palette ?? null, - image: data.image ?? null, + // The rendered first screen stands in for a screenshot when none is given. + palette: data.palette ?? (data.image ? null : (rendered?.brand.palette ?? null)), + image: data.image ?? rendered?.screenshot ?? null, assets, allows, }) From df7de996ee67c74abd480c8c0b2e3e436be93b88 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 23:42:41 +0000 Subject: [PATCH 3/6] fix(design): a rendered read names a brand colour only where the page does Two ways the rendered read led looks away from a brand: - A colour that merely covered much of the first screen, such as a promotion's panel, became the brand colour on a page with no call to action, displacing the colour the site's CSS and logo name. The rendered read now names a brand colour only from the main call to action's fill or a hero filled edge to edge; other colours join the palette. - The colours given with the rendered screen came from painted backgrounds alone, so imagery, gradients and canvases were missing and a look took its second colour from elsewhere. The reader now reads the screenshot's main colours by pixel, as an uploaded screenshot's are, and the site's brand colour leads them, since a screen's own leading colour can be a photo's. --- .../design/ai/__tests__/site-render.test.ts | 50 ++++++++++++++++++- .../server/domains/design/ai/site-render.ts | 48 ++++++++++++++---- .../server/domains/design/render/read-page.ts | 41 ++++++++++++++- .../lib/server/functions/portal-design-ai.ts | 3 +- 4 files changed, 127 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts index 27deab361b..50ad0001db 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts @@ -8,7 +8,8 @@ const settings = vi.hoisted(() => ({ })) vi.mock('@/lib/server/config', () => ({ config: settings })) -const { fromPage, renderSite, renderedSignals, withRendered } = await import('../site-render') +const { fromPage, lookPalette, renderSite, renderedSignals, withRendered } = + await import('../site-render') const page: PageMeasure = { title: 'Brand', @@ -99,6 +100,53 @@ describe('fromPage', () => { }) }) +describe('fromPage, where the page names no brand colour', () => { + // A page with no call to action and a promotion's panel covering much of it. + const promo: PageMeasure = { + ...page, + button: null, + action: false, + hero: null, + palette: [ + { colour: 'rgb(255, 255, 255)', share: 0.5 }, + { colour: 'rgb(255, 219, 0)', share: 0.4 }, + ], + } + + it('leaves the brand colour to the site, keeping a painted panel in the palette', () => { + const rendered = fromPage(promo) + expect(rendered.brand.colour).toBeUndefined() + expect(rendered.brand.palette).toContain('#ffdb00') + const merged = withRendered( + { ...staticSite, colour: '#0058a3', palette: ['#0058a3', '#ffdb00'] }, + { ...rendered, screenshot: null } + ) + expect(merged.colour).toBe('#0058a3') + expect(merged.palette.slice(0, 2)).toEqual(['#0058a3', '#ffdb00']) + }) + + it('gives the screenshot palette, imagery and all, when the screenshot was read', () => { + const rendered = fromPage(page, ['#ffffff', '#ff7a59', '#b07cff']) + // The main call to action leads; the screenshot's colours follow in its order. + expect(rendered.brand.palette).toEqual(['#9fe870', '#ffffff', '#ff7a59', '#b07cff']) + }) +}) + +describe('lookPalette', () => { + it('leads with the site brand colour before the screen, whose first colour can be a photo', () => { + const rendered = { + ...fromPage({ ...page, button: null, action: false, hero: null }, ['#7c322c', '#ffdb00']), + screenshot: null, + } + expect(lookPalette({ ...staticSite, colour: '#0058a3' }, rendered)).toEqual([ + '#0058a3', + '#7c322c', + '#ffdb00', + ]) + expect(lookPalette(null, rendered)).toEqual(['#7c322c', '#ffdb00']) + }) +}) + describe('withRendered', () => { it('lets the render lead the CSS read, keeping what only the CSS read found', () => { const merged = withRendered(staticSite, { ...fromPage(page), screenshot: null }) diff --git a/apps/web/src/lib/server/domains/design/ai/site-render.ts b/apps/web/src/lib/server/domains/design/ai/site-render.ts index a1739dd8a8..6fbde02047 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-render.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-render.ts @@ -30,7 +30,11 @@ export interface RenderedSite { brand: Pick & Partial> & { fonts: { heading: string | null; body: string | null } - /** Its colours by painted area with its main button's first, neutrals included. */ + /** + * The first screen's main colours, as an uploaded screenshot's: by pixel + * where the screenshot could be read, else by painted area, the main + * button's first. Imagery and gradients are in the first, not the second. + */ palette: string[] } /** The first screen, as a JPEG data URL. */ @@ -83,18 +87,24 @@ function boxOf(b: PageBox | null, action = false): MeasuredBox | null { } /** A rendered page's measurements and brand, in the terms the design steps read. */ -export function fromPage(page: PageMeasure): Omit { +export function fromPage( + page: PageMeasure, + shotPalette: string[] = [] +): Omit { const background = fill(page.page) ?? '#ffffff' const button = page.button ? fill(page.button.fill) : null const hero = page.hero ? fill(page.hero) : null - const painted = page.palette.flatMap(({ colour }) => fill(colour) ?? []) - const vivid = [button, hero, ...painted].filter( - (c, i, all): c is string => !!c && isBrandColour(c) && all.indexOf(c) === i + const brandHero = hero && hero !== background ? hero : null + // The brand colour, only where the page says so: its main call to action's + // fill, else a hero it fills edge to edge. A colour that merely covers an + // area, such as a promotion's panel, is in the palette and no more. + const strong = [page.action ? button : null, brandHero].find( + (c): c is string => !!c && isBrandColour(c) ) - const palette = [button, ...painted].filter( + const painted = page.palette.flatMap(({ colour }) => fill(colour) ?? []) + const palette = (shotPalette.length ? [strong, ...shotPalette] : [button, ...painted]).filter( (c, i, all): c is string => !!c && all.indexOf(c) === i ) - const brandHero = hero && hero !== background ? hero : null return { title: page.title || null, measured: { @@ -109,7 +119,7 @@ export function fromPage(page: PageMeasure): Omit { dark: contrastRatio('#ffffff', background) > contrastRatio('#000000', background), button, monochrome: !!button && !isBrandColour(button) && contrastRatio('#ffffff', button) > 7, - ...(vivid[0] && { colour: vivid[0] }), + ...(strong && { colour: strong }), ...(brandHero && { hero: brandHero, bold: isBrandColour(brandHero) }), fonts: { heading: stack(page.headline?.family), body: stack(page.body?.family) }, palette: palette.slice(0, 6), @@ -120,7 +130,12 @@ export function fromPage(page: PageMeasure): Omit { /** The site's signals with what the render shows leading what its CSS suggests. */ export function withRendered(site: SiteSignals, rendered: RenderedSite): SiteSignals { const { brand } = rendered + // The site's own colours keep their order behind the colour the render is sure of. const vivid = brand.palette.filter(isBrandColour) + const colour = brand.colour ?? site.colour ?? vivid[0] ?? null + const palette = [colour, ...site.palette, ...vivid].filter( + (c, i, all): c is string => !!c && all.indexOf(c) === i + ) return { ...site, title: site.title ?? rendered.title, @@ -128,18 +143,29 @@ export function withRendered(site: SiteSignals, rendered: RenderedSite): SiteSig dark: brand.dark, button: brand.button ?? site.button, monochrome: brand.button ? brand.monochrome : site.monochrome, - colour: brand.colour ?? site.colour, + colour, hero: brand.hero ?? site.hero, bold: brand.bold ?? site.bold, fonts: { heading: brand.fonts.heading ?? site.fonts.heading, body: brand.fonts.body ?? site.fonts.body, }, - palette: [...vivid, ...site.palette.filter((c) => !vivid.includes(c))].slice(0, 5), + palette: palette.slice(0, 5), measured: { ...site.measured, ...withoutEmpty(rendered.measured) }, } } +/** + * The colours the looks start from when the rendered screen stands in for a + * screenshot: the site's brand colour first, then the screen's. A screen's + * own leading colour can be a photo's. + */ +export function lookPalette(site: SiteSignals | null, rendered: RenderedSite): string[] { + return [site?.colour, ...rendered.brand.palette] + .filter((c, i, all): c is string => !!c && all.indexOf(c) === i) + .slice(0, 6) +} + /** Signals from the render alone, when the page's HTML could not be read. */ export function renderedSignals(domain: string, rendered: RenderedSite): SiteSignals { const { brand } = rendered @@ -197,7 +223,7 @@ export async function renderSite(site: string, budgetMs = BUDGET_MS): Promise { const run = (script: () => T) => `(${script.toString()})()` +/** Pixels read along the screenshot's longer side, as an uploaded screenshot's are. */ +const SAMPLE_SIZE = 128 + +/** Runs in a blank page: an image's pixels, downscaled, for its palette. */ +function samplePixels(source: string, size: number): Promise<{ data: number[] } | null> { + return new Promise((resolve) => { + const image = new Image() + image.onload = () => { + const scale = Math.min(1, size / Math.max(image.naturalWidth, image.naturalHeight, 1)) + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)) + canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)) + const context = canvas.getContext('2d') + if (!context) return resolve(null) + context.drawImage(image, 0, 0, canvas.width, canvas.height) + resolve({ data: [...context.getImageData(0, 0, canvas.width, canvas.height).data] }) + } + image.onerror = () => resolve(null) + image.src = source + }) +} + +/** The screenshot's palette, decoded in a blank page so the site's own rules do not apply. */ +async function shotPalette(view: Bun.WebView, screenshot: string): Promise { + await view.navigate('about:blank').catch(() => null) + const pixels = (await view + .evaluate(`(${samplePixels.toString()})(${JSON.stringify(screenshot)}, ${SAMPLE_SIZE})`) + .catch(() => null)) as { data: number[] } | null + return pixels ? paletteFromPixels(Uint8ClampedArray.from(pixels.data), 6) : [] +} + /** * The consent rules, run in the page as it loads: they recognise common * consent notices and choose to decline optional cookies. The image sets the @@ -160,11 +194,14 @@ export async function readPage(address: string, browser?: string): Promise null)) as { data?: string } | null + const screenshot = shot?.data ? `data:image/jpeg;base64,${shot.data}` : null + const finalUrl = view.url || url.toString() return { ok: true, - url: view.url || url.toString(), + url: finalUrl, measure, - screenshot: shot?.data ? `data:image/jpeg;base64,${shot.data}` : null, + screenshot, + shotPalette: screenshot ? await shotPalette(view, screenshot) : [], refused: [...proxy.refused], } } finally { diff --git a/apps/web/src/lib/server/functions/portal-design-ai.ts b/apps/web/src/lib/server/functions/portal-design-ai.ts index 8eabc3ad91..6aa6d17f8f 100644 --- a/apps/web/src/lib/server/functions/portal-design-ai.ts +++ b/apps/web/src/lib/server/functions/portal-design-ai.ts @@ -185,7 +185,8 @@ export const portalDesignDirectionsFn = createServerFn({ method: 'POST' }) site, words: data.words ?? null, // The rendered first screen stands in for a screenshot when none is given. - palette: data.palette ?? (data.image ? null : (rendered?.brand.palette ?? null)), + palette: + data.palette ?? (data.image || !rendered ? null : render.lookPalette(site, rendered)), image: data.image ?? rendered?.screenshot ?? null, assets, allows, From 4618373f789b9b80bb918916be5be0eaa699494e Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 23:55:47 +0000 Subject: [PATCH 4/6] fix(design): a design waits up to 40 seconds for the rendered read of a website A heavy site can take half a minute to render, more when reads queue, and the design started without it. A design starts once, so it waits longer. --- apps/web/src/lib/server/domains/design/ai/site-render.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/domains/design/ai/site-render.ts b/apps/web/src/lib/server/domains/design/ai/site-render.ts index 6fbde02047..cfef452c70 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-render.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-render.ts @@ -20,7 +20,8 @@ import type { SiteSignals } from './site-signals' const log = logger.child({ module: 'site-render' }) -const BUDGET_MS = 25_000 +// A heavy site can take half a minute to render; a design starts once, so it waits. +const BUDGET_MS = 40_000 /** A reader that sleeps between reads can refuse the first request while it wakes. */ const WAKE_MS = 1_500 From 1a7f166ef0b0a2389b97d4c4d1523108315f931e Mon Sep 17 00:00:00 2001 From: James Morton Date: Sun, 1 Nov 2026 00:17:13 +0000 Subject: [PATCH 5/6] fix(design): the looks keep a site's second colour that its first screen hardly shows The colours given with a rendered screen now carry the site's own leading colours after the screen's. A second brand colour used only in small places, such as a coral link colour, could fall out of a palette read from the first screen alone. --- .../design/ai/__tests__/site-render.test.ts | 22 ++++++++++++++----- .../server/domains/design/ai/site-render.ts | 11 ++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts index 50ad0001db..6e8926ec45 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/site-render.test.ts @@ -138,13 +138,25 @@ describe('lookPalette', () => { ...fromPage({ ...page, button: null, action: false, hero: null }, ['#7c322c', '#ffdb00']), screenshot: null, } - expect(lookPalette({ ...staticSite, colour: '#0058a3' }, rendered)).toEqual([ - '#0058a3', - '#7c322c', - '#ffdb00', - ]) + expect( + lookPalette({ ...staticSite, colour: '#0058a3', palette: ['#0058a3', '#ffdb00'] }, rendered) + ).toEqual(['#0058a3', '#7c322c', '#ffdb00']) expect(lookPalette(null, rendered)).toEqual(['#7c322c', '#ffdb00']) }) + + it("keeps the site's second colour when the first screen hardly shows it", () => { + const rendered = { + ...fromPage({ ...page, button: null, action: false, hero: null }, [ + '#ffffff', + '#112231', + '#d8d4cf', + ]), + screenshot: null, + } + const site = { ...staticSite, colour: '#112231', palette: ['#112231', '#ff4f40', '#218fb7'] } + expect(lookPalette(site, rendered)).toContain('#ff4f40') + expect(lookPalette(site, rendered)[0]).toBe('#112231') + }) }) describe('withRendered', () => { diff --git a/apps/web/src/lib/server/domains/design/ai/site-render.ts b/apps/web/src/lib/server/domains/design/ai/site-render.ts index cfef452c70..daaf0c0fb2 100644 --- a/apps/web/src/lib/server/domains/design/ai/site-render.ts +++ b/apps/web/src/lib/server/domains/design/ai/site-render.ts @@ -158,13 +158,16 @@ export function withRendered(site: SiteSignals, rendered: RenderedSite): SiteSig /** * The colours the looks start from when the rendered screen stands in for a - * screenshot: the site's brand colour first, then the screen's. A screen's - * own leading colour can be a photo's. + * screenshot: the site's brand colour first, then the screen's, then the + * site's other leading colours. A screen's own leading colour can be a + * photo's, and a second brand colour used only in small places may not + * show on the first screen at all. */ export function lookPalette(site: SiteSignals | null, rendered: RenderedSite): string[] { - return [site?.colour, ...rendered.brand.palette] + const screen = rendered.brand.palette.slice(0, 5) + return [site?.colour, ...screen, ...(site?.palette.slice(0, 3) ?? [])] .filter((c, i, all): c is string => !!c && all.indexOf(c) === i) - .slice(0, 6) + .slice(0, 8) } /** Signals from the render alone, when the page's HTML could not be read. */ From e7064bf00538ac1e5fb840fe2f831fde0ba99301 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sun, 1 Nov 2026 00:23:05 +0000 Subject: [PATCH 6/6] fix(design): the website reader leaves out every script, however its tags are split Script, style and drawing blocks come out of a page repeatedly until nothing changes, since taking one out can join the pieces of another. A block left open runs to the end of the page, as a browser reads it, so nothing inside it is read as a link. --- .../design/ai/__tests__/page-tree.test.ts | 13 +++++++++++ .../lib/server/domains/design/ai/page-tree.ts | 23 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/domains/design/ai/__tests__/page-tree.test.ts b/apps/web/src/lib/server/domains/design/ai/__tests__/page-tree.test.ts index 0601473893..292c7c7164 100644 --- a/apps/web/src/lib/server/domains/design/ai/__tests__/page-tree.test.ts +++ b/apps/web/src/lib/server/domains/design/ai/__tests__/page-tree.test.ts @@ -37,4 +37,17 @@ describe('pageTree', () => { ['p', true], ]) }) + + it('never leaves a script behind when its tags are split by another', () => { + const nodes = pageTree( + `
ipt>alert(1)Go
` + ) + expect(nodes.map((n) => n.tag)).not.toContain('script') + expect(nodes.find((n) => n.tag === 'a')?.text).toBe('Go') + }) + + it('reads a script left open as running to the end of the page', () => { + const nodes = pageTree(`
Real