Docs
Dashboard
Examples / Site qualityBeta

UI review

A design check of a page from what the browser actually renders, at desktop width and at phone width, with screenshots and the top fixes in plain words.

When to use it: You want a design check of a page at desktop and phone width, measured and not guessed.

Usestwo sessions (1280 and 375 pixels wide), evaluate (measure.js in the page), screenshot, extract for the fixes
Needsa model on the API (no model key of your own)
Siteyour page; the default is books.toscrape.com; the runner uses a stand-in page with known problems
Outputoutput/review.md, output/desktop.png, output/phone.png, output/result.json
LanguagesNode and Python

What is measured, from the computed styles: the colour palette, the fonts and text sizes in use, every piece of text whose contrast with its background fails WCAG AA (4.5:1, or 3:1 for large text; computed, not guessed), images without alt text, skipped heading levels, and at phone width whether the page scrolls sideways (and which element makes it) and tap targets smaller than 24 × 24 pixels (WCAG 2.5.8). A model then turns those measurements into a short summary and up to 6 concrete fixes; it is given the numbers, so it does not have to guess them from pixels.

The code

The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.

/** * UI review: a design check of a page from what the browser actually renders, at desktop and phone width: its colour * palette, fonts and sizes, text that fails WCAG contrast (computed), images without alt text, skipped heading * levels, sideways scrolling and too-small tap targets on a phone, with screenshots and the top fixes in plain words. * *   npx tsx node/index.ts            (BOXLINE_API_KEY; PAGE_URL) * * measure.js runs in the page; the fixes are written by a model from those measurements. Writes output/result.json, * output/review.md, output/desktop.png and output/phone.png. */import { mkdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { Boxline } from "@boxline/sdk";const here = dirname(fileURLToPath(import.meta.url));const out = process.env.OUTPUT_DIR ?? "output";const url = process.env.PAGE_URL ?? "https://books.toscrape.com/";const measure = readFileSync(join(here, "../measure.js"), "utf8");const bx = new Boxline();type Measures = {  viewport: number;  palette: { value: string; count: number }[];  fonts: { value: string; count: number }[];  sizes: { value: string; count: number }[];  contrastFailures: { text: string; color: string; background: string; ratio: number; needs: number; sizePx: number }[];  imagesWithoutAlt: string[];  headingSkips: string[];  horizontalOverflow: boolean;  overflowing: string[];  smallTargets: { target: string; width: number; height: number }[];};/** Opens the page at one viewport, measures it and keeps a screenshot. */async function look(name: string, viewport: { width: number; height: number }): Promise<Measures> {  const session = await bx.sessions.create({ viewport, timeout: 180, userMetadata: { example: "ui-review" } });  console.log(`Session: ${session.id} (${name}, ${viewport.width}×${viewport.height})`);  try {    await session.goto(url, { waitUntil: "load" });    const shot = await session.screenshot({ fullPage: true });    writeFileSync(join(out, `${name}.png`), Buffer.from(shot.data, "base64"));    return await session.evaluate<Measures>(measure);  } finally {    await session.stop();  }}mkdirSync(out, { recursive: true });const desktop = await look("desktop", { width: 1280, height: 800 });const phone = await look("phone", { width: 375, height: 812 });// The fixes, from the measurements (the model sees the numbers and the page text, not a guess at the pixels).const findings = { contrast: desktop.contrastFailures.slice(0, 10), imagesWithoutAlt: desktop.imagesWithoutAlt.length, headingSkips: desktop.headingSkips, phone: { horizontalOverflow: phone.horizontalOverflow, overflowing: phone.overflowing, smallTargets: phone.smallTargets.slice(0, 10) }, palette: desktop.palette.map((p) => p.value), fonts: desktop.fonts.map((f) => f.value) };const r = await bx.extract<{ summary: string; fixes: { fix: string; why: string; priority: "high" | "medium" | "low" }[] }>({  url,  prompt: `A design review of this page. Measurements from the browser: ${JSON.stringify(findings).slice(0, 3500)}. summary: two sentences on the page's look and its main problems. fixes: up to 6, most important first, each concrete (which element, what to change), why it matters, and a priority. Base them on the measurements.`,  schema: { type: "object", properties: { summary: { type: "string" }, fixes: { type: "array", items: { type: "object", properties: { fix: { type: "string" }, why: { type: "string" }, priority: { type: "string", enum: ["high", "medium", "low"] } }, required: ["fix", "why", "priority"] } } }, required: ["summary", "fixes"] },});console.log(`\nPalette: ${desktop.palette.map((p) => p.value).join(" ")}\nFonts: ${desktop.fonts.map((f) => f.value).join(", ")}; sizes ${desktop.sizes.map((s) => s.value).join(", ")}`);console.log(`Contrast failures: ${desktop.contrastFailures.length}${desktop.contrastFailures.slice(0, 3).map((c) => `\n  "${c.text}" ${c.color} on ${c.background}: ${c.ratio}:1 (needs ${c.needs}:1)`).join("")}`);console.log(`Images without alt: ${desktop.imagesWithoutAlt.length}; heading skips: ${desktop.headingSkips.join(", ") || "none"}`);console.log(`Phone: ${phone.horizontalOverflow ? `scrolls sideways (${phone.overflowing.join(", ")})` : "fits"}; small tap targets: ${phone.smallTargets.length}`);console.log(`\n${r.data.summary}\n${r.data.fixes.map((f) => `  [${f.priority}] ${f.fix}`).join("\n")}`);const md = [  `# UI review: ${url}`,  "",  r.data.summary,  "",  "## Fixes",  "",  ...r.data.fixes.map((f) => `- **${f.priority}**: ${f.fix} (${f.why})`),  "",  "## Measured",  "",  `- Palette: ${desktop.palette.map((p) => `\`${p.value}\``).join(" ")}`,  `- Fonts: ${desktop.fonts.map((f) => f.value).join(", ")}; sizes: ${desktop.sizes.map((s) => s.value).join(", ")}`,  `- Contrast failures (WCAG AA): ${desktop.contrastFailures.length}${desktop.contrastFailures.map((c) => `\n  - "${c.text}": ${c.color} on ${c.background}, ${c.ratio}:1 (needs ${c.needs}:1)`).join("")}`,  `- Images without alt text: ${desktop.imagesWithoutAlt.length}`,  `- Heading levels skipped: ${desktop.headingSkips.join(", ") || "none"}`,  `- Phone (375 px): ${phone.horizontalOverflow ? `scrolls sideways because of ${phone.overflowing.join(", ")}` : "fits the width"}; tap targets under 24 px: ${phone.smallTargets.map((t) => `${t.target} (${t.width}×${t.height})`).join(", ") || "none"}`,  "",  "| Desktop | Phone |",  "|---|---|",  "| ![](desktop.png) | ![](phone.png) |",  "",].join("\n");writeFileSync(join(out, "review.md"), md);writeFileSync(join(out, "result.json"), JSON.stringify({ url, desktop, phone, summary: r.data.summary, fixes: r.data.fixes, usage: { modelUsd: r.usage.costUsd } }, null, 2));
measure.js
// Runs in the page (session.evaluate): measures what a design review looks at, from the browser's computed styles.// One expression; returns plain JSON.(() => {  const parse = (c) => {    const m = /rgba?\(([^)]+)\)/.exec(c);    if (!m) return null;    const [r, g, b, a = 1] = m[1].split(",").map(Number);    return { r, g, b, a };  };  const channel = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);  const lum = ({ r, g, b }) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);  const contrast = (a, b) => {    const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);    return (x + 0.05) / (y + 0.05);  };  const backgroundOf = (el) => {    for (let e = el; e; e = e.parentElement) {      const c = parse(getComputedStyle(e).backgroundColor);      if (c && c.a > 0.5) return c;    }    return { r: 255, g: 255, b: 255, a: 1 };  };  const hex = ({ r, g, b }) => "#" + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");  const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);  const colors = new Map();  const fonts = new Map();  const sizes = new Map();  const failures = [];  // Elements that hold text of their own (not only through children).  const withText = [...document.querySelectorAll("body *")].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 1));  for (const e of withText.slice(0, 2000)) {    const s = getComputedStyle(e);    if (s.visibility === "hidden" || s.display === "none" || !e.getClientRects().length) continue;    const fg = parse(s.color);    if (!fg) continue;    const bg = backgroundOf(e);    bump(colors, hex(fg));    bump(colors, hex(bg));    bump(fonts, s.fontFamily.split(",")[0].replace(/["']/g, "").trim());    const px = parseFloat(s.fontSize);    bump(sizes, `${Math.round(px)}px`);    // WCAG 2.2 AA: 4.5:1 for text, 3:1 for large text (24px, or 18.66px bold).    const large = px >= 24 || (parseInt(s.fontWeight, 10) >= 700 && px >= 18.66);    const ratio = contrast(fg, bg);    if (ratio < (large ? 3 : 4.5)) failures.push({ text: e.textContent.trim().replace(/\s+/g, " ").slice(0, 60), color: hex(fg), background: hex(bg), ratio: Math.round(ratio * 100) / 100, needs: large ? 3 : 4.5, sizePx: px });  }  const levels = [...document.querySelectorAll("h1,h2,h3,h4,h5,h6")].map((h) => Number(h.tagName[1]));  const headingSkips = [];  for (let i = 1; i < levels.length; i++) if (levels[i] > levels[i - 1] + 1) headingSkips.push(`h${levels[i - 1]} → h${levels[i]}`);  const vw = document.documentElement.clientWidth;  const overflow = document.documentElement.scrollWidth > vw + 1;  const name = (e) => e.tagName.toLowerCase() + (e.id ? `#${e.id}` : typeof e.className === "string" && e.className.trim() ? `.${e.className.trim().split(/\s+/)[0]}` : "");  // WCAG 2.5.8 (AA): targets at least 24 × 24 CSS pixels.  const small = [...document.querySelectorAll("a, button, input:not([type=hidden]), select, textarea, [role=button]")]    .map((e) => ({ e, r: e.getBoundingClientRect() }))    .filter(({ r }) => r.width > 0 && r.height > 0 && (r.width < 24 || r.height < 24))    .slice(0, 20)    .map(({ e, r }) => ({ target: (e.innerText || e.value || e.getAttribute("aria-label") || name(e)).trim().slice(0, 40), width: Math.round(r.width), height: Math.round(r.height) }));  const top = (m, n) => [...m].sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count }));  return {    viewport: vw,    palette: top(colors, 8),    fonts: top(fonts, 5),    sizes: top(sizes, 8),    contrastFailures: failures.slice(0, 30),    imagesWithoutAlt: [...document.images].filter((i) => !i.hasAttribute("alt")).map((i) => i.currentSrc || i.src).slice(0, 20),    headingSkips,    horizontalOverflow: overflow,    overflowing: overflow ? [...document.querySelectorAll("body *")].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 5).map(name) : [],    smallTargets: small,  };})()

Inputs

Environment variables the example reads:

  • PAGE_URL.

Run it

Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/ui-review. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.

# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=…   # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts

Then check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.

What its check proves

check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.

Both widths were measured, the palette and fonts are there, both screenshots are real PNGs and there are fixes. On the runner's stand-in page: the grey "Free returns" line fails contrast at 1.9:1, the image without alt, the h1 → h3 skip, the 900-pixel banner that makes a phone scroll sideways and the 20 × 18 button are all found, and a fix addresses the contrast.

Source

github.com/Boxline-dev/examples/ui-review on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.

These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.