Build a Local AI UI Quality Analyzer

Build a CLI tool that uses a local vision model to audit web UI quality.

Introduction

30 Second Summary

You have probably visited a website where the text was barely readable, buttons blended into the background, or the spacing felt inconsistent from one page to the next. Catching these UI quality issues by hand is slow, subjective, and easy to miss.

In this project, you will build a command-line tool called TARS AI that captures any live webpage screenshot and feeds it to a local AI vision model running on your GPU. It outputs a structured, color-coded quality report with scores and actionable findings.

What You'll Build

Point TARS AI at any URL and within seconds you'll see a color-coded terminal report scoring the page across accessibility, visual hierarchy, and design consistency.

By the end of this project, you'll have:

  • A working CLI tool that takes any URL, captures a full-page screenshot with a headless browser, sends it to a local vision model, and returns a scored quality report in your terminal.
  • Consistent, machine-parseable JSON output on every run thanks to schema enforcement. You can pipe this into other tools or a future CI/CD pipeline.
  • A formatted terminal report with severity-rated findings (critical, warning, info) and concrete recommendations for each issue across 3 dimensions.
  • Secret Mission: Add a multi-page comparison mode that analyzes multiple URLs and highlights consistency issues across pages.

Are there any prerequisites?

You should be comfortable with JavaScript and basic command-line usage. You also need Ollama installed on your machine (the tool that runs AI models locally) and a GPU with at least 6 GB of VRAM.

Before We Start

Before you start building anything, take a moment to commit to what you're about to create and why it matters.

Set Up Your Environment

TARS AI relies on three pieces working together: Node.js to run your CLI, Puppeteer to capture live webpages, and a local vision model to analyze them.

Puppeteer 25.3.0 requires Node.js 22 as its minimum runtime. In this step you will get that runtime in place, pull the Qwen3-VL vision model onto your GPU, and scaffold the project so everything connects.

In this step, get ready to:
  • Install Node.js 22 and initialize a new project.
  • Pull the Qwen3-VL vision model and install npm packages.
  • Create the entry point file and verify the full stack connects.
Upgrade Node.js to v22 and initialize the project

Puppeteer 25 raised its minimum Node.js version to 22. Let's confirm which version you have.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal or Command Prompt, and pressing Enter.
  • Check your Node.js version by running this command:
node --version

✔️ I see v22 or higher

Your Node.js version is ready. Skip ahead to creating the project folder below.

ⓧ I see a version lower than 22

You have an older version installed. The installer from nodejs.org will upgrade it in place.

  • Open https://nodejs.org/en/download/archive/v22 in your browser.
  • Download the installer for your OS (.msi on Windows, .pkg on macOS).
  • Run the installer and accept the defaults.
  • Close and reopen your terminal so the new version takes effect.
  • Run node --version again to confirm it now prints v22.x.

ⓧ Command not found

Node.js is not installed yet. Let's install version 22.

  • Open https://nodejs.org/en/download/archive/v22 in your browser.
  • Download the installer for your OS (.msi on Windows, .pkg on macOS).
  • Run the installer and accept the defaults.
  • Close and reopen your terminal.
  • Run node --version to confirm it prints v22.x.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir tars-ai
cd tars-ai
  • Initialize a new Node.js project by running:
npm init -y

You should see the generated package.json printed in your terminal.

  • Open your code editor by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code (or Cursor), and pressing Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the tars-ai folder on your Desktop, then click Open.
  • Open package.json from the Explorer sidebar and replace its entire contents with:
{
  "name": "tars-ai",
  "version": "1.0.0",
  "description": "AI-powered UI quality analyzer using local vision models",
  "type": "module",
  "main": "tars.mjs",
  "scripts": {
    "analyze": "node tars.mjs analyze",
    "compare": "node tars.mjs compare"
  }
}

Why set type to module?

Setting "type": "module" tells Node.js to treat .mjs and .js files as ES modules. This lets you use modern import syntax and top-level await without extra configuration.

  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
Pull the vision model and install npm packages

Your AI engine is Qwen3-VL, a 4-billion parameter vision model that runs entirely on your local GPU. You already have Ollama installed, so pulling the model is a single command.

  • Pull the vision model by running this command in your terminal:
ollama pull qwen3-vl:4b

How big is this download?

The qwen3-vl:4b model is about 3.3 GB. The download may take a few minutes depending on your connection. You will see a progress bar in the terminal.

Once the download finishes, verify the model responds.

  • Test the model by running:
ollama run qwen3-vl:4b "Describe what you can do"

You should see the model print a text response describing its capabilities, then return you to the terminal prompt.

Model not responding?

  • If you see a connection error, make sure the Ollama app is running in the background.
  • If the pull command hangs, check your internet connection and try again.
  • Help me troubleshoot Ollama
  • Install the two npm packages your project needs by running:
npm install puppeteer@25.3.0 ollama@0.6.3

What happens during this install?

Puppeteer automatically downloads a compatible Chrome browser binary during installation. This adds roughly 200 MB to your node_modules folder. The process takes a minute or two.

When it finishes, npm automatically updates your package.json with a dependencies section listing both packages.

Install failing on Windows?

  • If the Chrome download is blocked by your antivirus, temporarily disable real-time scanning and retry.
  • If you see permission errors, try running your terminal as Administrator.
  • Help me fix the Puppeteer install
Create the entry point and verify the stack

You now have Node.js, the vision model, and both npm packages. Time to confirm they all connect by writing a quick verification script.

  • Create a new file called tars.mjs in the root of your tars-ai folder by right-clicking in the Explorer sidebar and selecting New File.
  • Paste the following code into tars.mjs:
import puppeteer from 'puppeteer';
import ollama from 'ollama';

// Verify both libraries load and Ollama is reachable
console.log('puppeteer loaded:', typeof puppeteer);
console.log('ollama loaded:', typeof ollama);

try {
  await ollama.list();
  console.log('Ollama is reachable at localhost:11434');
} catch (e) {
  console.error('Cannot connect to Ollama:', e.message);
}

What does this code do?

  • The two import lines pull in Puppeteer (headless browser) and the Ollama client library.
  • The typeof checks confirm both packages loaded without crashing.
  • The ollama.list() call hits the local Ollama API at localhost:11434 to confirm the service is running.
  • Save tars.mjs.

Before you run this, what do you expect to see? Three log lines confirming the libraries loaded and Ollama connected, or an error?

  • Run the verification script in your terminal:
node tars.mjs

You should see output like this:

puppeteer loaded: object
ollama loaded: object
Ollama is reachable at localhost:11434

Seeing an error instead?

  • If you see "Cannot use import statement" make sure your package.json has "type": "module" and you saved the file.
  • If you see "Cannot connect to Ollama" make sure the Ollama app is running in the background.
  • If you see "Cannot find package 'puppeteer'" make sure you ran npm install inside the tars-ai folder.
  • Help me debug this error

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is what package.json should look like after npm install added the dependencies:

{
  "name": "tars-ai",
  "version": "1.0.0",
  "description": "AI-powered UI quality analyzer using local vision models",
  "type": "module",
  "main": "tars.mjs",
  "scripts": {
    "analyze": "node tars.mjs analyze",
    "compare": "node tars.mjs compare"
  },
  "dependencies": {
    "ollama": "0.6.3",
    "puppeteer": "25.3.0"
  }
}

And here is the full tars.mjs:

import puppeteer from 'puppeteer';
import ollama from 'ollama';

// Verify both libraries load and Ollama is reachable
console.log('puppeteer loaded:', typeof puppeteer);
console.log('ollama loaded:', typeof ollama);

try {
  await ollama.list();
  console.log('Ollama is reachable at localhost:11434');
} catch (e) {
  console.error('Cannot connect to Ollama:', e.message);
}

Your entire stack is connected. Node.js 22 is running, the vision model is loaded on your GPU, and both npm packages are wired up. Next up, you will use Puppeteer to capture a live webpage screenshot.

Capture a Live Webpage Screenshot

Your environment is wired up and ready to go. Node.js, Puppeteer, Ollama, and the Qwen3-VL model are all confirmed working.

Before the AI can analyze anything, it needs something to look at. In this step, you will build the screenshot pipeline that captures any live webpage as a PNG image. This proves the headless browser works and gives you a tangible artifact to inspect.

In this step, get ready to:
  • Build a reusable function that launches a headless browser and captures a full-page screenshot.
  • Wire up CLI argument parsing so you can run the tool from the terminal with a URL.
  • Save the screenshot to disk and confirm the capture pipeline works end-to-end.
Build the URL capture function

The verification code in tars.mjs confirmed your libraries load. Now you will replace it with a real function that captures a webpage screenshot.

  • Open tars.mjs in your editor.
  • Select all the existing code and replace it with the following:
import puppeteer from 'puppeteer';
import ollama from 'ollama';
import { writeFileSync } from 'fs';

// Capture a full-page screenshot of any URL
async function captureScreenshot(url) {
  // Launch a headless Chrome instance
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  // Standard desktop viewport size
  await page.setViewport({ width: 1280, height: 720 });
  // Wait until network activity settles (page fully loaded)
  await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
  // Capture the entire scrollable page, not just the visible viewport
  const screenshot = await page.screenshot({ fullPage: true });
  await browser.close();
  return screenshot;
}

What does this code do?

This function handles the entire browser lifecycle for capturing a single URL.

  • puppeteer.launch({ headless: true }) starts a Chrome browser with no visible window. Puppeteer downloaded this browser automatically when you ran npm install.
  • setViewport({ width: 1280, height: 720 }) sets a standard desktop resolution so you get consistent screenshots regardless of your actual screen size.
  • waitUntil: 'networkidle2' tells Puppeteer to wait until there are 2 or fewer active network connections for 500ms. This ensures dynamic content has finished loading.
  • page.screenshot({ fullPage: true }) captures the entire scrollable page and returns a Uint8Array of PNG image data.
Wire up CLI argument parsing and run it

The capture function exists but nothing calls it yet. You need a main function that reads the command and URL from the terminal arguments.

  • Add the following code below your captureScreenshot function in tars.mjs:
async function main() {
  // Parse CLI arguments: node tars.mjs <command> <url>
  const args = process.argv.slice(2);
  const command = args[0];
  const positionalArgs = args.filter(a => !a.startsWith('--')).slice(1);

  if (command === 'analyze') {
    const url = positionalArgs[0];
    if (!url) {
      console.error('Error: Please provide a URL.');
      process.exit(1);
    }
    // Capture and save the screenshot to disk
    const screenshot = await captureScreenshot(url);
    writeFileSync('screenshot.png', screenshot);
    console.log(`Screenshot saved to screenshot.png (${screenshot.length} bytes)`);
  }
}

main();

What does this code do?

This is the entry point that turns your function into a usable CLI tool.

  • process.argv.slice(2) grabs everything after node tars.mjs. The first positional argument is the command (analyze), the second is the URL.
  • The filter on -- separates flags from positional arguments. You will add flags like --json in a later step.
  • writeFileSync('screenshot.png', screenshot) writes the raw PNG bytes to disk so you can visually inspect what the AI will see.
  • Save tars.mjs.

Before you run this, what do you think will happen? Will you see a file created, an error, or nothing at all?

  • Run the analyzer on a real webpage by running this command:
node tars.mjs analyze https://news.ycombinator.com

You should see output like this:

Screenshot saved to screenshot.png (245382 bytes)

The exact byte count will vary. The important thing is that no errors appeared and a file size was printed.

  • Confirm the screenshot file exists by running:
ls screenshot.png
  • Open screenshot.png from your tars-ai folder in any image viewer. You should see a full capture of the Hacker News front page.

Seeing an error instead of a file?

  • If you see Error: Please provide a URL. make sure you included both analyze and a URL after node tars.mjs.
  • If you see a timeout error, the page may be slow to load. Try a simpler URL like https://example.com.
  • If you see a Chrome-related error, Puppeteer may not have downloaded its browser correctly. Run npx puppeteer browsers install to re-download it.

Help me debug why my Puppeteer screenshot is failing

✔️ Awesome, I've got everything!

Great. Make sure you have saved tars.mjs and confirmed screenshot.png shows a real webpage capture.

ⓧ I'd like to double check the full code

Your tars.mjs should have the following sections in this order. Scroll up and confirm each one is present.

  • Three imports at the top: puppeteer, ollama, and { writeFileSync } from fs.
  • The captureScreenshot(url) function that launches a browser, sets viewport, navigates, screenshots, and closes.
  • The main() function that parses process.argv, checks for the analyze command, calls captureScreenshot, and writes the file.
  • A main(); call at the very bottom of the file.

You have a working screenshot pipeline. Point it at any URL and it captures a full-page image in seconds. Next up, you will send that image to the Qwen3-VL vision model and see what it thinks about the page's UI quality.

Get Raw AI Feedback on the Screenshot

You have a working screenshot pipeline, but no intelligence analyzing what it captures. The screenshot is just a file sitting on disk with no insight about the UI quality of the page.

In this step, you will connect the screenshot directly to the Qwen3-VL vision model. Sending it a simple prompt proves the full pipeline works end-to-end: URL to screenshot to AI analysis.

In this step, get ready to:
  • Send the screenshot buffer to Qwen3-VL via Ollama's chat API.
  • Observe the model's raw analysis and identify why it is insufficient for a reliable tool.
Send the screenshot to Qwen3-VL via Ollama

The ollama npm package gives you a chat() method that accepts images alongside text prompts. You will create a function that sends the screenshot buffer to the vision model and returns whatever it says.

  • In tars.mjs, find the blank line between your imports and the captureScreenshot function.
  • Add a model constant and the analyzeScreenshot function by pasting this code between the imports and captureScreenshot:
const MODEL = 'qwen3-vl:4b';

async function analyzeScreenshot(screenshotBuffer) {
  // Send the screenshot to the vision model for analysis
  const response = await ollama.chat({
    model: MODEL,
    messages: [
      {
        role: 'user',
        content: 'Analyze this webpage screenshot for UI quality issues including accessibility, visual hierarchy, and design consistency. List specific issues you find.',
        images: [screenshotBuffer]
      }
    ],
    stream: false
  });
  // Return the model's raw text response
  return response.message.content;
}

What does this code do?

MODEL stores the model name as a constant so you only change it in one place if you swap models later.

ollama.chat() sends a chat request to the locally running Ollama server. The images array accepts Uint8Array buffers directly. The library handles encoding them before sending to the model.

stream: false tells Ollama to return the entire response at once instead of streaming tokens one by one.

Now you need to update main() to call this function instead of saving the screenshot to disk.

  • Inside main(), find these two lines inside the if (command === 'analyze') block:
    writeFileSync('screenshot.png', screenshot);
    console.log(`Screenshot saved to screenshot.png (${screenshot.length} bytes)`);
  • Replace those two lines with this code that passes the screenshot to your new function and prints the result:
    const analysis = await analyzeScreenshot(screenshot);
    console.log(analysis);
  • Save tars.mjs.

Seeing a red underline on analyzeScreenshot?

Make sure your new analyzeScreenshot function is defined ABOVE the main() function. JavaScript hoists function declarations but not const assignments, so the MODEL constant must appear before any code that uses it.

help me fix a reference error in tars.mjs

Print the raw analysis and observe the problem

Your code is ready. Before you run it, think about what the model will return. Will it be structured data you could parse programmatically, or freeform prose?

  • Run the analyzer on a real webpage by running this command:
node tars.mjs analyze https://news.ycombinator.com

After 20-40 seconds (the model needs time to process the image), you will see a block of text analysis printed to your terminal. The model describes UI quality issues it found on the page in natural language.

Why does it take so long?

Vision models process images by converting them into hundreds of visual tokens. Your local GPU is running the full inference pass on both the image tokens and text tokens. This is slower than text-only models, but everything stays private on your machine.

  • Now run the exact same command again:
node tars.mjs analyze https://news.ycombinator.com

Compare the two outputs. Notice that the format is different. One run might use bullet points. Another might use paragraphs. Sometimes it includes numerical scores, sometimes it does not.

Why is this a problem?

The model's output is useful for a human reading it. But you cannot reliably parse it, extract scores from it, or pipe it to another tool. Every run produces a different structure.

For TARS AI to work as a proper CLI tool, you need deterministic output with a fixed schema: the same fields, the same types, every single time. That inconsistency is exactly what the next step solves.

Not seeing any output?

If you see a connection refused error, make sure Ollama is running. Open a separate terminal and run ollama serve.

If you see a model not found error, the qwen3-vl:4b model may not have finished downloading. Run ollama pull qwen3-vl:4b and wait for it to complete.

If the command hangs for more than 90 seconds with no output, your GPU may not have enough free VRAM. Close other GPU-intensive apps and try again.

help me debug why my TARS AI analyzer is not returning output

✔️ Awesome, I've got everything!

Great. Double check you have saved tars.mjs.

ⓧ I'd like to double check the full code

Your tars.mjs should have these sections in order. Scroll up and confirm each one is present:

  • Three imports at the top: puppeteer, ollama, and writeFileSync from fs.
  • const MODEL = 'qwen3-vl:4b' constant.
  • captureScreenshot(url) function (unchanged from Step 2).
  • analyzeScreenshot(screenshotBuffer) function that calls ollama.chat() with the image.
  • main() function that calls analyzeScreenshot(screenshot) and prints the result with console.log(analysis).
  • main() call at the bottom of the file.

Your end-to-end pipeline is proven. You can go from any URL to AI-generated UI feedback in a single command. The problem is that the feedback changes shape every time you run it. In the next step, you will lock the output into a fixed JSON schema so every run produces consistent, parseable data.

Engineer Structured JSON Output with Schema Enforcement

In the previous step, you saw the full pipeline work end-to-end. The vision model analyzed a webpage and returned feedback. But running it multiple times revealed the core problem: the output format changes every time.

You cannot parse unpredictable prose. You cannot score it, compare it across runs, or pipe it to a CI/CD tool. In this step, you will fix that by constraining the model to output valid JSON matching an exact schema you define.

In this step, get ready to:
  • Define a JSON schema that enforces the exact report structure you want.
  • Rewrite the analysis prompt with specific scoring criteria for each dimension.
  • Verify that the output is consistent and parseable on every run.
Define the UI quality report schema

Ollama's chat API accepts a format parameter that takes a JSON schema object. When provided, the model is constrained to output only valid JSON matching that schema. This is the key to getting deterministic, parseable responses.

  • In tars.mjs, find the line const MODEL = 'qwen3-vl:4b'; and add the following code directly below it:
const REPORT_SCHEMA = {
  type: 'object',
  properties: {
    overall_score: { type: 'number', minimum: 0, maximum: 100 },
    dimensions: {
      type: 'object',
      properties: {
        accessibility: {
          type: 'object',
          properties: {
            score: { type: 'number', minimum: 0, maximum: 100 },
            findings: {
              type: 'array',
              items: {
                type: 'object',
                properties: {
                  issue: { type: 'string' },
                  severity: { type: 'string', enum: ['critical', 'warning', 'info'] },
                  element: { type: 'string' },
                  recommendation: { type: 'string' }
                },
                required: ['issue', 'severity', 'element', 'recommendation']
              }
            }
          },
          required: ['score', 'findings']
        },

What does this code do?

This is the start of a JSON schema that defines the report structure. The accessibility dimension has a score (number 0-100) and a findings array. Each finding contains the issue, its severity level, the affected element, and a recommendation.

  • Directly below the accessibility block you just added, paste the visual_hierarchy dimension (same structure as accessibility):
        visual_hierarchy: {
          type: 'object',
          properties: {
            score: { type: 'number', minimum: 0, maximum: 100 },
            findings: {
              type: 'array',
              items: {
                type: 'object',
                properties: {
                  issue: { type: 'string' },
                  severity: { type: 'string', enum: ['critical', 'warning', 'info'] },
                  element: { type: 'string' },
                  recommendation: { type: 'string' }
                },
                required: ['issue', 'severity', 'element', 'recommendation']
              }
            }
          },
          required: ['score', 'findings']
        },
  • Finally, paste the design_consistency dimension and the closing braces for the entire schema directly below:
        design_consistency: {
          type: 'object',
          properties: {
            score: { type: 'number', minimum: 0, maximum: 100 },
            findings: {
              type: 'array',
              items: {
                type: 'object',
                properties: {
                  issue: { type: 'string' },
                  severity: { type: 'string', enum: ['critical', 'warning', 'info'] },
                  element: { type: 'string' },
                  recommendation: { type: 'string' }
                },
                required: ['issue', 'severity', 'element', 'recommendation']
              }
            }
          },
          required: ['score', 'findings']
        }
      },
      required: ['accessibility', 'visual_hierarchy', 'design_consistency']
    },
    summary: { type: 'string' }
  },
  required: ['overall_score', 'dimensions', 'summary']
};

Why three identical dimension structures?

Each dimension (accessibility, visual_hierarchy, design_consistency) uses the same shape: a numeric score plus an array of findings. This lets you compare dimensions consistently and iterate on the findings format once rather than maintaining three different structures.

The required arrays at each level force the model to always include those fields. Without them, the model might omit fields it considers empty.

  • Save tars.mjs.

Seeing a syntax error when you save?

The schema is one large nested object. Check that every opening brace { has a matching close }, and that commas separate sibling properties. Your editor's bracket matching feature can help spot mismatches.

Help me fix a bracket mismatch in my REPORT_SCHEMA object in tars.mjs

Update the analysis call with schema enforcement and improved prompt

The schema defines the shape of the output. Now you need a prompt that tells the model exactly what criteria to score against, and you need to wire the schema into the API call.

  • In tars.mjs, add the following ANALYSIS_PROMPT constant directly below the closing }; of REPORT_SCHEMA:
const ANALYSIS_PROMPT = `You are a senior UI quality engineer. Analyze this webpage screenshot and evaluate it across three dimensions:

1. ACCESSIBILITY (score 0-100):
   - Color contrast ratios (text against backgrounds)
   - Text sizing and readability
   - Touch/click target sizes (minimum 44px)
   - Presence of visual labels and indicators
   - Heading hierarchy and structure

2. VISUAL HIERARCHY (score 0-100):
   - Clear visual weight distribution (most important elements stand out)
   - Heading sizes create clear content levels
   - Whitespace usage guides the eye
   - Call-to-action elements are visually prominent
   - Information density is manageable

3. DESIGN CONSISTENCY (score 0-100):
   - Consistent spacing between similar elements
   - Uniform color palette usage
   - Typography consistency (font families, weights)
   - Border and shadow styles are coherent
   - Alignment follows a visible grid

For each dimension, provide a score and list specific findings with severity (critical/warning/info), the element affected, and a concrete recommendation. Calculate an overall_score as the weighted average. Provide a brief summary.`;

What makes this prompt better than the original?

The original prompt said "list specific issues" with no guidance. This version gives the model a role (senior UI quality engineer), explicit scoring dimensions with bullet-point criteria, and output instructions matching the schema fields.

Specific criteria like "minimum 44px touch targets" and "color contrast ratios" give the model concrete things to evaluate. Vague prompts produce vague results.

  • Now replace the existing analyzeScreenshot function with this updated version that uses the schema and prompt:
async function analyzeScreenshot(screenshotBuffer) {
  const response = await ollama.chat({
    model: MODEL,
    messages: [
      {
        role: 'user',
        content: ANALYSIS_PROMPT,
        images: [screenshotBuffer]
      }
    ],
    format: REPORT_SCHEMA,
    stream: false,
    options: { temperature: 0 }
  });
  return JSON.parse(response.message.content);
}

What changed in this function?

  • format: REPORT_SCHEMA constrains the model to output only JSON matching your schema. This is Ollama's structured output feature.
  • options: { temperature: 0 } makes responses more deterministic. The model picks the highest-probability token at each step instead of sampling randomly.
  • JSON.parse(response.message.content) converts the string response into a JavaScript object you can work with programmatically.

One more small change. The function now returns a parsed object instead of a raw string, so the main() function needs to handle it differently.

  • In the main() function, find these two lines:
    const analysis = await analyzeScreenshot(screenshot);
    console.log(analysis);
  • Replace them with:
    const report = await analyzeScreenshot(screenshot);
    console.log(JSON.stringify(report, null, 2));
  • Save tars.mjs.

Before you run this, what do you think the output will look like compared to the prose you saw in the previous step?

  • Run the analyzer by running this command:
node tars.mjs analyze https://news.ycombinator.com

Instead of unpredictable prose, you should see a clean JSON object with overall_score, dimensions (each with a numeric score and findings array), and a summary string. The analysis takes 30-60 seconds depending on your GPU.

Getting a JSON parse error?

If you see a SyntaxError: Unexpected token error, the model may have returned malformed JSON on this run. Run the command again. With temperature: 0 and a schema constraint, this should be rare.

If the error persists, check that your REPORT_SCHEMA is valid JavaScript (all braces matched, no trailing commas after the last property in an object).

Help me debug why my ollama structured output is returning invalid JSON in tars.mjs

Verify consistent structured output

The whole point of schema enforcement is consistency. In the last step, running the same URL twice gave different formats. Now it should give the same structure every time.

  • Run the same command a second time:
node tars.mjs analyze https://news.ycombinator.com
  • Compare the two outputs. The scores may vary slightly between runs, but confirm that both have the exact same structure: overall_score is a number, each dimension has score and findings, and every finding has issue, severity, element, and recommendation.

Why might scores differ between runs?

Even with temperature: 0, vision models can produce slightly different scores because image processing involves floating-point operations that can vary. The key win is structural consistency: the fields, types, and format are always the same.

✔️ Awesome, I've got everything!

Great. Double check you have saved tars.mjs and that your last run produced valid JSON.

ⓧ I'd like to double check the full code

Your tars.mjs file is now too long to show in one block. Scroll through your file and confirm these sections appear in order:

  • Import statements (puppeteer, ollama, writeFileSync).
  • const MODEL = 'qwen3-vl:4b';
  • const REPORT_SCHEMA = { ... }; with all three dimensions (accessibility, visual_hierarchy, design_consistency).
  • const ANALYSIS_PROMPT template literal with scoring criteria for all three dimensions.
  • captureScreenshot(url) function (unchanged from previous step).
  • analyzeScreenshot(screenshotBuffer) function with format: REPORT_SCHEMA, options: { temperature: 0 }, and JSON.parse() return.
  • main() function using const report and JSON.stringify(report, null, 2).

Your analyzer now produces machine-parseable, consistent output on every run. The JSON format means you could pipe this into a dashboard, a test runner, or a CI/CD gate. Next up, you will make this output satisfying for humans too with a color-coded terminal report.

Build a Color-Coded Terminal Report

Your analyzer now returns structured JSON with scores and findings. That is perfect for machines, but reading raw JSON in a terminal is painful for humans.

In this step, you will transform that JSON into a polished terminal report with color-coded severity badges and visual score bars. You will also finalize the CLI with flags for JSON output, screenshot saving, timing, and graceful error handling.

In this step, get ready to:
  • Build color helper functions and a formatted report display.
  • Add CLI flags for JSON output and screenshot saving.
  • Implement timing and graceful error handling.
Build the report formatter

Terminals support ANSI escape codes for colors and text formatting. You will use these to make severity levels instantly recognizable at a glance.

  • In tars.mjs, find the closing brace of the analyzeScreenshot function.
  • Add the following three color helper functions directly below it:
function severityColor(severity) {
  // Map severity levels to terminal colors
  const colors = {
    critical: '\x1b[31m',
    warning: '\x1b[33m',
    info: '\x1b[34m'
  };
  return colors[severity] || '\x1b[0m';
}

function resetColor() {
  return '\x1b[0m';
}

function scoreBar(score, width = 20) {
  // Build a visual progress bar with color based on score
  const filled = Math.round((score / 100) * width);
  const empty = width - filled;
  let color;
  if (score >= 80) color = '\x1b[32m';
  else if (score >= 60) color = '\x1b[33m';
  else color = '\x1b[31m';
  return `${color}${'='.repeat(filled)}${'\x1b[90m'}${'-'.repeat(empty)}${resetColor()} ${score}/100`;
}

What do these ANSI codes do?

ANSI escape codes are special character sequences that terminals interpret as formatting instructions.

  • \x1b[31m turns text red (critical issues).
  • \x1b[33m turns text yellow (warnings).
  • \x1b[34m turns text blue (informational).
  • \x1b[32m turns text green (high scores).
  • \x1b[0m resets back to default. Without it, color bleeds into everything that follows.
  • Now start the formatReport function directly below scoreBar. Add this first section that renders the report header:
function formatReport(report, url) {
  let output = '';
  // Report header with bold title
  output += `\n\x1b[1m--- TARS AI Quality Report ---${resetColor()}\n`;
  output += `URL: ${url}\n`;
  output += `Overall Score: ${scoreBar(report.overall_score)}\n\n`;

  // Map JSON keys to display labels
  const dimensionNames = {
    accessibility: 'Accessibility',
    visual_hierarchy: 'Visual Hierarchy',
    design_consistency: 'Design Consistency'
  };
  • Continue the formatReport function by adding the dimension loop and closing brace directly below:
  for (const [key, label] of Object.entries(dimensionNames)) {
    const dim = report.dimensions[key];
    output += `\x1b[1m${label}:${resetColor()} ${scoreBar(dim.score)}\n`;

    if (dim.findings && dim.findings.length > 0) {
      for (const finding of dim.findings) {
        const badge = `${severityColor(finding.severity)}[${finding.severity.toUpperCase()}]${resetColor()}`;
        output += `  ${badge} ${finding.issue}\n`;
        output += `    Element: ${finding.element}\n`;
        output += `    Fix: ${finding.recommendation}\n`;
      }
    } else {
      output += `  No issues found.\n`;
    }
    output += '\n';
  }

  output += `\x1b[1mSummary:${resetColor()} ${report.summary}\n`;
  output += `\x1b[90m--- End of Report ---${resetColor()}\n`;
  return output;
}

What does formatReport do?

  • \x1b[1m makes text bold for headers and labels.
  • The dimensionNames object maps JSON keys to human-readable labels for display.
  • Each finding gets a colored severity badge like [CRITICAL] in red or [WARNING] in yellow.
  • The function builds the entire report as a string and returns it, so the caller decides when to print.

The formatter is ready. Now wire it into your existing main() function so you can see it in action.

  • In the main() function, find this line:
    console.log(JSON.stringify(report, null, 2));
  • Replace it with:
    console.log(formatReport(report, url));
  • Save tars.mjs.

Before you run this, what do you expect to see instead of raw JSON?

  • Run the analyzer to see your formatted report:
node tars.mjs analyze https://news.ycombinator.com

You should see a color-coded report with score bars, dimension headings, and severity-colored findings. The output is now scannable at a glance instead of a wall of JSON.

Still seeing raw JSON or an error?

  • Make sure you replaced the entire console.log(JSON.stringify(report, null, 2)) line, not just part of it.
  • Confirm the formatReport function is placed after scoreBar and before main().
  • Check for a message mentioning resetColor is not defined. This means the helper functions are below formatReport instead of above it.

Help me debug my formatReport output

Add the JSON output flag and finalize the CLI

The colored report is great for interactive use. But for piping results into other tools or a future CI/CD pipeline, you need the raw JSON back as an option.

  • In tars.mjs, delete the entire main() function and the main(); call at the bottom of the file.
  • Replace it with the new main() function, starting with argument parsing and help text:
async function main() {
  const args = process.argv.slice(2);
  const command = args[0];
  // Separate flags (--json, --save) from positional args (URL)
  const flags = args.filter(a => a.startsWith('--'));
  const positionalArgs = args.filter(a => !a.startsWith('--')).slice(1);
  const jsonOutput = flags.includes('--json');
  const saveScreenshot = flags.includes('--save');

  if (!command || command === '--help') {
    console.log(`
TARS AI - UI Quality Analyzer

Usage:
  node tars.mjs analyze <url> [--json] [--save]
  node tars.mjs --help

Commands:
  analyze <url>   Analyze a webpage for UI quality issues

Flags:
  --json          Output raw JSON instead of formatted report
  --save          Save the screenshot to disk
  --help          Show this help message
`);
    process.exit(0);
  }

What does this code do?

  • flags collects any arguments starting with -- so you can check for --json and --save separately from the URL.
  • positionalArgs filters out flags and skips the command name, leaving just the URL.
  • If no command is given or the user passes --help, the tool prints usage instructions and exits.
  • Save tars.mjs and test the help flag by running:
node tars.mjs --help

You should see the usage instructions printed with the available commands and flags listed.

Seeing a syntax error?

  • Make sure you deleted the entire old main() function before pasting the new one. Having two main() definitions causes a duplicate declaration error.
  • Check that the help block's closing } is present. The code above is intentionally incomplete. The next chunk adds the rest.

Help me fix my main() syntax error

  • Directly below the help block (still inside main()), add the analyze command handler with timing and the --save flag:
  if (command === 'analyze') {
    const url = positionalArgs[0];
    if (!url) {
      console.error('Error: Please provide a URL. Example: node tars.mjs analyze https://example.com');
      process.exit(1);
    }

    try {
      const startTime = Date.now();

      console.log(`Capturing screenshot of ${url}...`);
      const screenshot = await captureScreenshot(url);
      const captureTime = Date.now() - startTime;

      if (saveScreenshot) {
        writeFileSync('screenshot.png', screenshot);
        console.log('Screenshot saved to screenshot.png');
      }

      console.log(`Screenshot captured in ${captureTime}ms. Analyzing with ${MODEL}...`);
      const report = await analyzeScreenshot(screenshot);
      const totalTime = Date.now() - startTime;

      if (jsonOutput) {
        console.log(JSON.stringify(report, null, 2));
      } else {
        console.log(formatReport(report, url));
        console.log(`\x1b[90mCompleted in ${(totalTime / 1000).toFixed(1)}s (capture: ${(captureTime / 1000).toFixed(1)}s, analysis: ${((totalTime - captureTime) / 1000).toFixed(1)}s)${resetColor()}`);
      }

What does this code do?

  • startTime and captureTime track how long each phase takes, so you can see the capture vs. analysis split.
  • The --save flag writes the screenshot to screenshot.png so you can inspect exactly what the model saw.
  • The --json flag switches between raw JSON (for piping to other tools) and the formatted report (for human reading).
  • Finally, add the error handling and closing braces directly below, then the main() call at the very end of the file:
    } catch (error) {
      if (error.message.includes('net::ERR_')) {
        console.error(`Error: Could not reach ${url}. Check the URL and your internet connection.`);
      } else if (error.message.includes('ECONNREFUSED')) {
        console.error('Error: Cannot connect to Ollama. Make sure it is running (ollama serve).');
      } else if (error.message.includes('model')) {
        console.error(`Error: Model ${MODEL} not found. Run: ollama pull ${MODEL}`);
      } else {
        console.error(`Error: ${error.message}`);
      }
      process.exit(1);
    }
  } else {
    console.error(`Unknown command: ${command}. Run "node tars.mjs --help" for usage.`);
    process.exit(1);
  }
}

main();

What does the error handling do?

Instead of dumping a raw stack trace, the catch block matches known error patterns and prints a helpful message.

  • net::ERR_ means Puppeteer could not reach the URL (bad address or no internet).
  • ECONNREFUSED means Ollama is not running on the expected port.
  • A message containing model means the model has not been pulled yet.
  • Save tars.mjs.

Now run the full analysis. What do you think the output will include that it did not before?

  • Run the analyzer with the --save flag:
node tars.mjs analyze https://news.ycombinator.com --save

You should see a color-coded report with score bars, severity badges, and a timing summary at the bottom. A screenshot.png file now exists in your project folder.

  • Confirm the screenshot was saved by running:
ls screenshot.png

You should see screenshot.png listed. Open it to see exactly what the vision model analyzed.

  • Test the JSON flag to confirm piped output still works:
node tars.mjs analyze https://news.ycombinator.com --json

This time you should see raw JSON output with no colors or formatting. This is the machine-readable mode for CI/CD integration.

Seeing an error about a missing function or unexpected token?

  • Make sure you deleted the entire old main() function before pasting the new one. Having two main() definitions causes a syntax error.
  • Check that all three code chunks are pasted in order and that the closing braces match. The function ends with } then main(); on the last line.
  • If you see Cannot connect to Ollama, that means Ollama is not running. Start it with ollama serve in a separate terminal.

Help me fix my updated main() function

✔️ Awesome, I've got everything!

Great. Double check you have saved tars.mjs and both the formatted report and --json output are working.

ⓧ I'd like to double check the full code

Your tars.mjs is quite long now. Scroll through and confirm these sections are present in order:

  • Imports (puppeteer, ollama, writeFileSync) and the MODEL constant.
  • REPORT_SCHEMA object.
  • ANALYSIS_PROMPT template string.
  • captureScreenshot(url) function.
  • analyzeScreenshot(screenshotBuffer) function.
  • severityColor(severity), resetColor(), and scoreBar(score, width) helper functions.
  • formatReport(report, url) function.
  • main() function with flag parsing, help text, analyze handler, try/catch error handling, and the else block for unknown commands.
  • main(); call on the very last line.

Your CLI tool is complete. You can point TARS AI at any URL and get an instant, scored UI quality audit right in your terminal. Next up, the Secret Mission challenges you to compare multiple pages and spot inconsistencies across them.

Secret mission

Multi-Page Consistency Analysis

Your analyzer works great on single pages. But real applications have dozens of pages, and UI drift between them is one of the most common quality problems. Can you extend TARS AI to compare multiple pages and catch cross-page inconsistencies that single-page analysis misses?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • tars-ai project folder on your Desktop (source code, node_modules, screenshots).
  • qwen3-vl:4b Ollama model (3.3 GB on disk).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with TARS AI or want to analyze more pages.

  • Your tars-ai folder stays on your Desktop, ready to use anytime.
  • The qwen3-vl:4b model stays available in Ollama for other vision tasks.
  • Node.js 22 remains installed for future JavaScript projects.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and model stay on disk so you can pick up where you left off.

  • If Ollama is running in the background, quit it from your system tray (Windows) or menu bar (macOS) to free up RAM.
  • No other action needed. Everything stays on disk and costs nothing while idle.

Delete - I don't want to use this again

Remove all project resources and free up disk space.

  • Delete the project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/tars-ai

Windows

rmdir /s /q %USERPROFILE%\Desktop\tars-ai
  • Remove the qwen3-vl:4b model from Ollama to reclaim 3.3 GB of disk space:
ollama rm qwen3-vl:4b
  • Confirm the model was removed by running:
ollama list

You should no longer see qwen3-vl:4b in the output.

Nice Work!

Nice Work!

You just built TARS AI, a fully local CLI tool that captures live webpages and analyzes them for UI quality issues using a vision model running on your own GPU. No API costs, no data leaving your machine, and a structured output format ready for automation.

You learned how to:

  • Integrate Puppeteer for headless browser automation with Ollama for local AI inference, creating a full capture-to-analysis pipeline.
  • Engineer prompts with JSON schema enforcement to get consistent, structured output from a vision model on every run.
  • Build a polished CLI tool with color-coded terminal reports, a --json flag for machine-parseable output, and graceful error handling.
  • (Secret Mission) Add a multi-page comparison mode that detects cross-page UI drift by sending multiple screenshots to the vision model in a single call.

Ready to quiz yourself?