Zurück zu Skills
juliobsz/skillsVor der Ausführung prüfen

SKILL DETAIL

jury

juliobsz/skills/jury

Score any website the way an Awwwards jury would, from evidence alone. Screenshots every page type on desktop, a throttled phone and reduced motion, measures LCP, CLS, FPS, axe, keyboard focus and hover feedback, audits SEO and metadata, then a fresh critic that never sees the code scores Design, Usability, Creativity, Content and the six developer areas, with a ranked fix list. Use when someone asks to review, critique, score, rate or roast a website, portfolio or landing page, asks whether a site is good enough for Awwwards or Site of the Day, or wants design QA of a live URL or a local build. Not for building or redesigning a site; use /awwwards for that.

Installationen · 334Quelle ansehen

Installation

npx skills add https://github.com/juliobsz/skills --skill jury

Skill-Dateien

SKILL.md

Zuletzt synchronisiert · 20.10.2026

references/calibration.md›
# Calibration: the critic against real juries

The critic scores stills and measurements under a strict rubric. A real jury browses the live
site, feels the motion, hears the sound, and doesn't run axe or throttle a phone. The two scales
differ, and this file holds the measured gap.

**This file is for the agent writing the score card, never for the critic.** A critic that knows
the gap scores toward it. The jury scores below appear nowhere in `quality-bar.md` for the same
reason.

## Runs

Each site was scored with the normal /jury workflow (`shoot.mjs --crawl --nav`, `audit.mjs`,
one fresh critic with the brief from `quality-bar.md`) on Oct 15 2026, a week after its award.
Jury scores are from the site's page on awwwards.com.

| Site of the Day | | Overall | Design | Usability | Creativity | Content |
|---|---|---|---|---|---|---|
| Maria Vasilyeva portfolio, Oct 9 2026 (mariavasilyeva.com) | jury | 7.28 | 7.31 | 7.02 | 7.56 | 7.33 |
| | critic | 6.15 | 6.5 | 5.0 | 7.0 | 6.5 |
| LPAS architecture studio, Oct 10 2026 (lpas.com) | jury | 7.24 | 7.19 | 7.19 | 7.31 | 7.50 |
| | critic | 6.10 | 6.5 | 5.5 | 6.5 | 5.5 |

| Developer areas | | Sem/SEO | Anim | A11y | WPO | Resp | Markup |
|---|---|---|---|---|---|---|---|
| Maria Vasilyeva | jury | 7.20 | 8.00 | 6.40 | 7.60 | 7.40 | 7.00 |
| | critic | 4 | 6.5 | 3 | 6.5 | 5 | 6.5 |
| LPAS | jury | 7.80 | 8.00 | 7.00 | 7.60 | 7.40 | 7.20 |
| | critic | 5 | 5 | 4 | 3.5 | 6 | 4.5 |

Not usable: TWKS (Site of the Day, Aug 2020; jury 7.52). twks.ch has been rebuilt since, so
today's site isn't the one the jury scored.

## What the gap says

- **Overall: the critic ran 1.13 and 1.14 below the jury.** Two sites agree closely, but two is
  few, and both are winners: the gap on weaker sites is not measured.
- **Closest on what both judge from the page:** Design (0.75 below on average) and Creativity
  (0.69 below).
- **Furthest on what only the critic measures:** Accessibility (3.2 below), Semantics/SEO (3.0)
  and WPO (2.6). Juries rarely run axe, tab through a page or throttle a phone; the critic does
  every time. Both winners lost focus on the first Tab, or had no skip link and serious axe
  violations, and the jury still gave Accessibility 6.4 and 7.
- **Animations/Transitions (2.25 below):** the jury watches the motion; the critic sees frames.
- The LPAS critic read `mobile.loaderMs` (29.7 s) as a loader. It was the load event of a 5.3 MB
  page on the throttled phone, since split into `loadMs` and `loaderMs`. That cost it some WPO
  and Usability points, so part of its gap is a reporting bug that is now fixed.

## Using it on a score card

- **Jury estimate = critic overall + 1.1.** Write it as "about 7.3", never with two decimals,
  and say it comes from two sites.
- Put the band on the estimate, against real thresholds: Honorable Mention from 6.5, recent
  Sites of the Day 7.2 to 7.6.
- Leave the critic's own scores and fix list as they are. The fixes are real even where a jury
  would forgive them, and the strict scale is what makes the list useful.
- Don't estimate jury scores for the developer areas. The gap there is too wide and too uneven.

## Adding a run

1. Pick a Site of the Day from the last few weeks, so the live site is the one the jury scored.
   Its awwwards.com page lists the jury scores; write them here, never anywhere the critic reads.
2. Run /jury on it as usual, one fresh critic.
3. Add both rows above, then recompute the offset from every usable run.

A run on a site that won nothing (or only an Honorable Mention) is the most useful next one: it
shows whether the gap holds below the winners.
references/quality-bar.md›
# The quality bar: how award sites are scored, the rubric, and the critic brief

The bar is the Awwwards scoring system. It is public, detailed, and backed by years of real
scores. The goal is a site that would hold up in front of that jury; submitting is optional and
up to the owner. These skills are not affiliated with Awwwards, and every score they give is an
estimate.

## The system (awwwards.com/about-evaluation, verified Oct 2026)

| Criterion | Weight | What reviewers actually judge |
|---|---|---|
| Design | 40% | Craft and control: the typographic system, layout and negative space, color and contrast, consistency, detail. Sites that feel "inevitable" score highest. Piling on effects reads as a lack of control. |
| Usability | 30% | Orientation in under 3 s, clear navigation, load time, responsiveness on real phones, accessibility (keyboard, focus, reduced motion), feedback on interaction. |
| Creativity | 20% | Originality with intent: an idea that could not be moved to another brand. Generic tropes are discounted. |
| Content | 10% | Real, specific copy and imagery integrated into the design. Placeholder content is noticed. |

- Honorable Mention starts at 6.5. Site of the Day is the highest score of the day, and recent
  winners average 7.2 to 7.6.
- The developer review looks for scores above 7 in six areas: **Semantics/SEO,
  Animations/Transitions, Accessibility, WPO (web performance), Responsive Design, and
  Markup/Meta-data.**

## What gets punished

- A polished template with no point of view. Effects bolted on rather than choreographed.
- Hard cuts or plain fades between states ("craft lives between states").
- A WebGL hero at 18 to 20 fps on a mid-range phone, a 9 MB page, a 6-second progress bar.
- Desktop-only thinking: a broken or squished mobile layout caps a site at Honorable Mention.
- A custom cursor that hides the native one, missing focus states, low contrast.
- A dead site: primary hovers that only swap a color, a concept that freezes the moment the
  visitor stops. Reviewers call it
  "static" or "a template" even when the frames are well composed.
- Lorem ipsum, stock imagery, machine-translated copy.
- Borrowed concepts: this season's horizontal gallery, the same distortion everyone ships.

## Critic's self-test (do this on your screenshots)

1. Screenshot the hero. Is the static frame strong on its own?
2. Throttle (4x CPU, slow 4G). Does it still paint quickly and hold its frame rate? `shoot.mjs`
   runs its mobile pass this way: read `mobile.vitals` and `mobile.scrollFps`.
3. Watch the transitions between states, not just between pages.
4. Toggle reduced motion. Is the site complete and calm?
5. Open it at phone width. Is it designed, or squished?
6. Point at things. Do the links, buttons and images answer in one voice? Leave the mouse
   still for five seconds. Does the concept still breathe?

## Score rubric (1 to 10, with evidence from specific screenshots)

Score as a skeptical critic. Every score needs evidence, for example: "d-01-hero: display type 18vw,
tracking -0.05em, optically aligned to the grid; the static frame works as a poster." No
evidence, no points.

**Design (40%)**
- 6: clean and consistent, but could be any template; safe type; uniform spacing.
- 7: a clear typographic system, an intentional palette, good hierarchy; one or two memorable compositions.
- 8: every frame composed; type used as image; rhythm varied on purpose; every detail designed (hover, focus, captions, footer, 404).
- 9: inevitable. Nothing to add or remove, and a visual language you could recognize from a crop.

**Usability (30%)**
- 6: works, but the loader is long, nav is unclear on some page, mobile is squished, or FPS drops.
- 7: fast, clear nav, mobile works, reduced motion is acceptable.
- 8: instant orientation, mobile composed for the hand, transitions never block, accessibility basics complete, 60 fps, every primary hover target answers in the site's own motion, not a color swap alone (`h-*` pairs; `hover.noHoverChange` empty).
- 9: all of 8, plus delightful feedback everywhere and zero friction on every device.

**Creativity (20%)**
- 6: known patterns, competently assembled.
- 7: one idea specific to the subject, partially carried through.
- 8: the concept drives layout, motion and the signature moment; something reviewers haven't seen this month.
- 9: a new interaction idea that becomes a reference for others.

**Content (10%)**
- 6: placeholder-feeling or generic copy.
- 7: specific copy, honest imagery, a clear story.
- 8: copy with voice and real substance; case studies explain the problem, the idea and the outcome.

**Developer (each 1 to 10):** Semantics/SEO, Animations/Transitions, Accessibility, WPO,
Responsive, Markup/Meta-data. Use the `audit.mjs` and `shoot.mjs` reports as evidence. An 8
needs everything in its row; each miss costs a point or more. The audit reads the HTML the
server sends, while `report.json` reads the page after JavaScript ran. When they disagree (the
audit finds no `<h1>`, `desktop.h1` lists one), the element only exists after JavaScript: that
is a Semantics/SEO risk, not a missing element.

| Area | Evidence | An 8 needs |
|---|---|---|
| Semantics/SEO | audit: lang, one `<h1>`, heading order, `<main>`, titles, descriptions, canonical, robots, sitemap, 404 status | 0 audit FAILs in the area, a unique title and description per page, a real 404 |
| Animations/Transitions | `d-90`..`d-92` frames, `h-*` pairs, `hover`, `desktop.idleMotion`, `scrollFps` | a continuous transition between pages, no primary target in `noHoverChange` or a text-color-only `colorOnly`, smooth scroll |
| Accessibility | `a11y` (axe), `keyboard`, `k-*` and `r-*` frames, `reduced.hiddenText`, `reduced.moving` | 0 serious or critical axe violations, a skip link first, every focus stop visible, `hiddenText` 0, nothing still moving under reduced motion |
| WPO | `mobile.vitals`, `mobile.loaderMs` (null: no loader), `desktop.settledMs` and `mobile.settledMs`, `mobile.loadMs`, `mobile.scrollFps`, the audit's heaviest-page line | throttled mobile LCP under 2.5 s, CLS under 0.05, a loader or intro done within 2.6 s, JS under 300 KB gzipped, a page under 3 MB. `loadMs` is when the last asset arrived, not when visitors could read: judge waiting by LCP and the loader |
| Responsive | `m-*` frames, `overflowX` and `offenders`, `smallTargets` | no overflow, tap targets of at least 24 px, a phone layout composed rather than squished |
| Markup/Meta-data | audit: OG and Twitter tags, `og:image` loads, favicon, viewport, alt text, accessible names | 0 audit FAILs in the area, an OG image that loads |

Weighted overall = 0.4 Design + 0.3 Usability + 0.2 Creativity + 0.1 Content.

## Critic brief (for a fresh subagent on every QA pass)

The builder knows what it meant to build, so it scores intent instead of what's on screen. A
critic that sees only the evidence scores like a jury. Pass this brief to a fresh subagent,
filling in the three paths. Give it nothing else: no `DESIGN.md`, no code, no concept card, no
earlier scores, no notes on what you were going for.

- **Google Antigravity (CLI `agy`, IDE, 2.0)**: Launch via `invoke_subagent` with `TypeName: "self"` (or `"research"`), `Role: "Awwwards QA Critic"`, passing the brief below in `Prompt`. The critic runs independently and reports back.
- **Claude Code**: Launch via the `Agent` tool (`Task` in older versions).
- **Codex & Other Multi-Agent Harnesses**: Spawn a fresh delegated worker/sub-session with this brief.
- **Single-turn / Without subagents**: Only if your environment cannot launch subagents or delegate. See "Without subagents" below.

```
You are an Awwwards juror reviewing a site you have never seen. You get only evidence. Use
only the files below: don't open the site, search the web, or read anything else about it.

Rubric:      <absolute path to references/quality-bar.md>, the "Score rubric" section
Screenshots: <absolute path to .shots/>. Each page has its own folder with a report.json;
             summary.json gives an overview. Prefixes: d- desktop 1440, m- phone 390,
             r- reduced motion, k- keyboard focus, d-90..92 page transition and back.
             h-NN-rest / h-NN-hover: one pointer target, clipped at the same
             coordinates, at rest and under the pointer (report.json hover.targets
             names each target, says whether it is primary and lists what changed).
Audit:       <absolute path to the saved audit.mjs output>

1. Open every screenshot. Before scoring, write in one sentence what you think the site's
   idea is. If you can't tell, say so: that is a Creativity finding, not a guess to make.
2. Score Design, Usability, Creativity and Content from 1 to 10, and each developer area
   (Semantics/SEO, Animations/Transitions, Accessibility, WPO, Responsive, Markup/Meta-data).
   Cite a screenshot file or a report field for every score. No evidence, no points. Start
   from 6 and make the evidence earn every point above it.
3. Weighted overall = 0.4 Design + 0.3 Usability + 0.2 Creativity + 0.1 Content.
4. List the fixes that would raise a score, most score gained first. Each one names the
   screenshot or field, what is wrong, and what to change. Be concrete ("m-03: the case-study
   title wraps to five lines at 390 px; drop the mobile display size to 14vw"), never generic
   ("improve the typography").
5. Judge interaction from the h- pairs and report.json `hover`. Primary targets in
   `noHoverChange` cost Design and Animations/Transitions points. `colorOnly`, `pixels` and
   `masked` are leads, not verdicts: open each pair. A fill that floods or inverts the whole button is
   a real treatment; a CTA, nav or project link whose text only changes color is not, and
   costs the same points.
   `desktop.idleMotion: false` is not a fault by itself: judge whether a concept this big
   should keep moving at rest. Things you can't judge from stills (how motion feels,
   sound) you leave out of the score. Don't assume they are good.

Reply with: the one-sentence idea, a score table with evidence, the weighted overall, and the
ranked fix list. Do not suggest a new concept; judge the one on screen.
```

Compare the critic's one-sentence idea with what the site was meant to say (a concept card, a
brief, the owner's own words). If they don't match, the site isn't saying it: fix the site, not
the critic.

**Without subagents**, and only then, score it yourself under the same rules, strictly from the
evidence. Look at the evidence before reading any code, design notes or description of intent,
start every category at 6, and give no points without a cited file. Say in the final message
that the score is a self-score.
scripts/audit.mjs›
#!/usr/bin/env node
// Static audit of a production build: semantics/SEO, metadata, accessibility basics, page weight,
// leftover placeholders and AI-copy tells. Crawls same-origin links from the start page.
//   node audit.mjs <url | build-dir> [--max 40]
//     url:        a running production server, e.g. `next start` → http://127.0.0.1:4400/
//     build-dir:  a static output folder (dist/, out/); served internally
//   exit code 1 if any FAIL
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { serve } from './serve.mjs';

const args = process.argv.slice(2);
const target = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--max') || 'dist';
const maxPages = +(args[args.indexOf('--max') + 1] || 40) || 40;
let server = null;
let base = target;
if (!/^https?:\/\//.test(target)) {
  if (!fs.existsSync(target)) { console.error(`No build at ${path.resolve(target)} and not a URL. Build first, or pass the server URL.`); process.exit(1); }
  server = await serve(target, 0);
  base = `http://127.0.0.1:${server.address().port}/`;
}
const origin = new URL(base).origin;

const results = [];
const add = (level, where, msg) => results.push({ level, where, msg });
const strip = (html) => html
  .replace(/<script[\s\S]*?<\/script>/gi, ' ')
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
  .replace(/<[^>]+>/g, ' ')
  .replace(/&nbsp;|&#160;/g, ' ')
  .replace(/&amp;/g, '&')
  .replace(/&#x27;|&#39;|&rsquo;/g, "'")
  .replace(/\s+/g, ' ');
const attr = (tag, name) => (tag.match(new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i')) || [])[2] ?? null;
const meta = (html, key) => {
  const tag = (html.match(new RegExp(`<meta[^>]+(?:name|property)=["']${key}["'][^>]*>`, 'i')) || [])[0];
  return tag ? attr(tag, 'content') : null;
};
const onSite = (u) => { const x = new URL(u); return new URL(x.pathname + x.search, origin).href; }; // production URLs → local server
const get = async (u) => {
  try {
    const r = await fetch(u, { redirect: 'follow' });
    return { status: r.status, type: r.headers.get('content-type') || '', body: Buffer.from(await r.arrayBuffer()), url: r.url };
  } catch (e) { return { status: 0, type: '', body: Buffer.alloc(0), url: u, error: e.message }; }
};

const PLACEHOLDERS = [/lorem ipsum/i, /\bName Surname\b/, /example\.com/, /Replace with/i, /\bTODO\b/, /\bAcme\b/, /\bJohn Doe\b/i, /\bJane Doe\b/i, /One plain sentence about/i];
const TELLS = [
  [/\b(elevate|seamless(ly)?|unleash|revolutioni[sz]e|cutting-edge|next-gen|synergy|game-chang)/i, 'filler marketing verb'],
  [/\b(crafting digital experiences|pixel[- ]perfect|passionate about)\b/i, 'portfolio cliche'],
  [/scroll to explore/i, 'generic scroll cue copy'],
  // Page text is one line, so both phrases stop at sentence ends to avoid matching across sentences.
  [/\bwhere\s+(?:[^\s.!?]+\s+){1,3}meets?\s+[^\s.!?,;:]+/i, 'portfolio cliche'],
  [/\b(?:not|\w+n['’]t)\s+just\b[^.!?]{1,80}?\bbut\b/i, 'portfolio cliche'],
];

// Crawl
const pages = new Map(); // url → html
const queue = [new URL(base).href];
const seen = new Set(queue);
while (queue.length && pages.size < maxPages) {
  const u = queue.shift();
  const r = await get(u);
  if (r.status >= 400 || r.status === 0) { add('FAIL', new URL(u).pathname, `HTTP ${r.status || r.error} for an internal link`); continue; }
  if (!r.type.includes('html')) continue;
  const html = r.body.toString('utf8');
  pages.set(u, html);
  for (const m of html.matchAll(/<a\b[^>]*\shref=["']([^"'#]+)[^"']*["']/gi)) {
    let next;
    try { next = new URL(m[1].replace(/&amp;/g, '&'), u); } catch { continue; }
    next.hash = ''; next.search = '';
    if (next.origin !== origin || seen.has(next.href) || /\.(?!html?$)[a-z0-9]{2,5}$/i.test(next.pathname)) continue;
    seen.add(next.href);
    queue.push(next.href);
  }
}
if (!pages.size) { console.error(`Could not load ${base}`); server?.close(); process.exit(1); }

for (const [u, html] of pages) {
  const where = new URL(u).pathname;
  const head = html.match(/<head[\s\S]*?<\/head>/i)?.[0] || '';
  const text = strip(html.replace(/<head[\s\S]*?<\/head>/i, ''));
  if (!/<html[^>]*\slang=["'][^"']+["']/i.test(html)) add('FAIL', where, '<html> has no lang');
  const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || [])[1]?.trim();
  if (!title) add('FAIL', where, 'missing <title>');
  else if (title.length > 70) add('WARN', where, `title is ${title.length} chars (keep <= 70)`);
  const desc = meta(html, 'description');
  if (!desc) add('FAIL', where, 'missing meta description');
  else if (desc.length < 50 || desc.length > 170) add('WARN', where, `meta description is ${desc.length} chars (aim 50-160)`);
  if (!/<link[^>]+rel=["']canonical["']/i.test(html)) add('WARN', where, 'no canonical link');
  for (const k of ['og:title', 'og:description', 'og:image', 'twitter:card']) if (!meta(html, k)) add('WARN', where, `missing ${k}`);
  if (!/<meta[^>]+name=["']viewport["']/i.test(html)) add('FAIL', where, 'missing viewport meta');
  if (!/rel=["'](icon|shortcut icon)["']/i.test(html)) add('WARN', where, 'no favicon link');

  const h1s = html.match(/<h1[\s>]/gi) || [];
  if (h1s.length !== 1) add('FAIL', where, `${h1s.length} <h1> elements (want exactly 1)`);
  const levels = [...html.matchAll(/<h([1-6])[\s>]/gi)].map((m) => +m[1]);
  for (let i = 1; i < levels.length; i++) if (levels[i] > levels[i - 1] + 1) { add('WARN', where, `heading level skips h${levels[i - 1]} -> h${levels[i]}`); break; }
  if (!/<main[\s>]/i.test(html)) add('FAIL', where, 'no <main> landmark');

  for (const tag of html.match(/<img\b[^>]*>/gi) || []) {
    const src = attr(tag, 'src') || '';
    if (attr(tag, 'alt') === null) add('FAIL', where, `img without alt: ${src.slice(0, 60)}`);
    if (!attr(tag, 'width') && !/\bfill\b|position:\s*absolute/.test(tag)) add('WARN', where, `img without width/height (layout shift): ${src.slice(0, 60)}`);
  }
  for (const m of html.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi)) {
    if (!strip(m[2]).trim() && !/aria-label=/.test(m[1]) && !/alt=["'][^"']+["']/.test(m[2])) add('FAIL', where, `link with no accessible name: ${attr(`<a ${m[1]}>`, 'href')}`);
    if (/target=["']_blank["']/.test(m[1]) && !/rel=["'][^"']*noopener/.test(m[1])) add('WARN', where, 'target=_blank without rel=noopener');
  }
  for (const m of html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/gi)) {
    if (!strip(m[2]).trim() && !/aria-label=/.test(m[1])) add('FAIL', where, 'button with no accessible name');
  }
  if (/<div[^>]+onclick=/i.test(html)) add('FAIL', where, 'clickable <div> (use <button> or <a>)');

  const dashes = (text.match(/[—–]/g) || []).length;
  if (dashes) add('WARN', where, `${dashes} em/en dash(es) in visible text (read as AI tell; rewrite)`);
  for (const re of PLACEHOLDERS) if (re.test(text) || re.test(head)) add('FAIL', where, `placeholder content: ${re}`);
  for (const [re, why] of TELLS) { const m = text.match(re); if (m) add('WARN', where, `${why}: "${m[0]}"`); }
  // The same sentence twice on one page reads as filler (e.g. hero line repeated in about).
  const body = html.replace(/<head[\s\S]*?<\/head>/i, '').replace(/<script[\s\S]*?<\/script>/gi, '');
  const sentences = [...body.matchAll(/<(p|h[1-6]|li|blockquote|figcaption|dd)\b[^>]*>([\s\S]*?)<\/\1>/gi)]
    .flatMap((m) => strip(m[2]).split(/(?<=[.!?])\s+/))
    .map((s) => s.trim())
    .filter((s) => s.split(' ').length >= 6);
  const counts = new Map();
  for (const s of sentences) counts.set(s, (counts.get(s) || 0) + 1);
  for (const [s, n] of counts) if (n > 1) add('WARN', where, `sentence repeated ${n}x: "${s.slice(0, 70)}"`);
  // Headings must read cleanly: visual duplicates (shadows, outlines) belong outside them.
  for (const m of html.matchAll(/<h([1-3])[^>]*>([\s\S]*?)<\/h\1>/gi)) {
    const t = strip(m[2]).trim().split(' ').filter(Boolean);
    const half = t.length / 2;
    if (t.length >= 2 && t.length % 2 === 0 && t.slice(0, half).join(' ').toLowerCase() === t.slice(half).join(' ').toLowerCase()) add('FAIL', where, `<h${m[1]}> text is duplicated ("${t.join(' ')}"): move decorative copies out of the heading`);
  }
  const words = text.split(' ').filter(Boolean).length;
  if (words < 60) add('WARN', where, `only ${words} words of visible text (thin content)`);
}

// Site level
const missing = await get(new URL(`/__audit-missing-${Date.now()}`, origin).href);
if (missing.status !== 404) add('FAIL', 'site', `a missing URL returned HTTP ${missing.status}, not 404`);
else if (!/<h1[\s>]/i.test(missing.body.toString('utf8'))) add('WARN', 'site', '404 page has no <h1> (design it: a way back, in the site voice)');
const robots = await get(new URL('/robots.txt', origin).href);
if (robots.status !== 200) add('WARN', 'site', 'no robots.txt');
else if (/example\.com/.test(robots.body.toString())) add('WARN', 'site', 'robots.txt still points at example.com');
const maps = [...robots.body.toString().matchAll(/^sitemap:\s*(\S+)/gim)].map((m) => onSite(m[1]));
let sitemapOk = false;
for (const u of [...maps, new URL('/sitemap.xml', origin).href, new URL('/sitemap-index.xml', origin).href]) if ((await get(u)).status === 200) { sitemapOk = true; break; }
if (!sitemapOk) add('WARN', 'site', 'no sitemap');
const ogs = new Set([...pages.values()].map((h) => meta(h, 'og:image')).filter(Boolean));
// As written (a live site, or a CDN), else mapped onto this server (a local build whose tags
// already point at the production domain).
const isImage = (r) => r.status === 200 && r.type.startsWith('image/');
for (const og of ogs) {
  const abs = new URL(og, origin).href;
  if (!isImage(await get(abs)) && !isImage(await get(onSite(abs)))) add('FAIL', 'site', `og:image ${abs} does not load (create a 1200x630 image)`);
}

// Weight: what each page actually downloads (gzip size for text assets).
const sizes = new Map();
const kb = (b) => Math.round(b / 1024);
async function size(u) {
  if (sizes.has(u)) return sizes.get(u);
  const r = await get(u);
  const text = /javascript|css|html|json|svg/.test(r.type);
  const entry = { bytes: r.status === 200 ? (text ? zlib.gzipSync(r.body).length : r.body.length) : 0, type: r.type, body: r.body, url: u };
  sizes.set(u, entry);
  return entry;
}
const pick = (tag) => {
  const set = attr(tag, 'srcset');
  if (set) {
    const c = set.split(',').map((s) => s.trim().split(/\s+/)).map(([u, d]) => ({ u, w: parseFloat(d) || 0 })).filter((c) => c.u).sort((a, b) => a.w - b.w);
    return (c.find((x) => x.w >= 1440) || c[c.length - 1])?.u;
  }
  return attr(tag, 'src');
};
let worst = null;
for (const [u, html] of pages) {
  const where = new URL(u).pathname;
  const res = (sel) => [...html.matchAll(sel)].map((m) => m[1]).filter(Boolean).map((x) => new URL(x.replace(/&amp;/g, '&'), u).href);
  const js = res(/<script\b[^>]*\ssrc=["']([^"']+)["']/gi);
  const css = res(/<link\b[^>]*rel=["']stylesheet["'][^>]*href=["']([^"']+)["']/gi).concat(res(/<link\b[^>]*href=["']([^"']+)["'][^>]*rel=["']stylesheet["']/gi));
  const preFonts = res(/<link\b[^>]*href=["']([^"']+\.(?:woff2?|ttf|otf))["'][^>]*>/gi);
  const imgs = [...html.matchAll(/<img\b[^>]*>/gi)].map((m) => pick(m[0])).filter(Boolean).map((x) => new URL(x.replace(/&amp;/g, '&'), u).href);
  const tally = { html: (await size(u)).bytes, js: 0, css: 0, fonts: 0, img: 0 };
  for (const x of new Set(js)) tally.js += (await size(x)).bytes;
  const fonts = new Set(preFonts);
  for (const x of new Set(css)) {
    const c = await size(x);
    tally.css += c.bytes;
    // Fonts the latin page will fetch: @font-face without unicode-range or covering basic latin.
    for (const face of c.body.toString().match(/@font-face\s*{[^}]+}/g) || []) {
      const range = face.match(/unicode-range:\s*([^;}]+)/)?.[1];
      if (range && !/U\+0000|U\+0020|U\+00?20-/i.test(range)) continue;
      const f = face.match(/url\(["']?([^"')]+\.(?:woff2?|ttf|otf))/)?.[1];
      if (f) fonts.add(new URL(f, x).href);
    }
  }
  for (const f of fonts) { tally.fonts += (await size(f)).bytes; if (/\.(ttf|otf)$/.test(f)) add('WARN', where, `ttf/otf font shipped: ${path.basename(f)} (use woff2)`); }
  for (const x of new Set(imgs)) {
    const s = (await size(x)).bytes;
    tally.img += s;
    if (s > 500 * 1024) add('WARN', where, `image ${kb(s)} KB: ${x.replace(origin, '').slice(0, 70)} (compress, AVIF/WebP, smaller variant)`);
  }
  const total = tally.html + tally.js + tally.css + tally.fonts + tally.img;
  if (kb(tally.js) > 300) add('WARN', where, `JS ${kb(tally.js)} KB gzipped (aim < 300 KB; lazy-load heavy libraries)`);
  if (kb(tally.fonts) > 300) add('WARN', where, `fonts ${kb(tally.fonts)} KB (fewer weights, variable font, subset)`);
  if (kb(total) > 3000) add('WARN', where, `page weight ~${kb(total)} KB (aim < 3 MB)`);
  if (!worst || total > worst.total) worst = { where, total, ...tally };
}

server?.close();
const order = { FAIL: 0, WARN: 1 };
results.sort((a, b) => order[a.level] - order[b.level]);
for (const r of results) console.log(`${r.level.padEnd(4)}  ${r.where.padEnd(28)}  ${r.msg}`);
const fails = results.filter((r) => r.level === 'FAIL').length;
console.log(`\n${pages.size} pages crawled from ${base}`);
if (worst) console.log(`heaviest page ${worst.where}: ~${kb(worst.total)} KB | html ${kb(worst.html)} | JS ${kb(worst.js)} (gz) | CSS ${kb(worst.css)} (gz) | fonts ${kb(worst.fonts)} | images ${kb(worst.img)}`);
console.log(`${fails} FAIL, ${results.length - fails} WARN`);
process.exit(fails ? 1 : 0);
scripts/browser.mjs›
// Resolve a Chromium for Playwright without forcing a download: bundled → any cached build →
// system Chrome/Edge. Requires `playwright` (or `playwright-core`) resolvable from the project.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createRequire } from 'node:module';

const req = createRequire(path.join(process.cwd(), 'noop.js'));
async function loadPlaywright() {
  for (const name of ['playwright', 'playwright-core']) {
    try { return await import(req.resolve(name).replace(/\\/g, '/').replace(/^([A-Za-z]):/, 'file:///$1:')); } catch {}
    try { return await import(name); } catch {}
  }
  console.error('Playwright not found. Run in the project: npm i -D playwright');
  process.exit(1);
}

function cachedChromes() {
  const roots = [
    process.env.PLAYWRIGHT_BROWSERS_PATH,
    process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'ms-playwright'),
    path.join(os.homedir(), '.cache', 'ms-playwright'),
    path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'),
  ].filter(Boolean);
  const found = [];
  for (const root of roots) {
    if (!fs.existsSync(root)) continue;
    for (const dir of fs.readdirSync(root).filter((d) => /^chromium-\d+/.test(d)).sort().reverse()) {
      for (const rel of ['chrome-win64/chrome.exe', 'chrome-win/chrome.exe', 'chrome-linux64/chrome', 'chrome-linux/chrome',
        'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
        'chrome-mac/Chromium.app/Contents/MacOS/Chromium']) {
        const p = path.join(root, dir, rel);
        if (fs.existsSync(p)) found.push(p);
      }
    }
  }
  return found;
}

export async function launch() {
  const pw = await loadPlaywright();
  const chromium = pw.chromium || pw.default?.chromium;
  const args = ['--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'];
  try { return await chromium.launch({ args }); } catch {}
  for (const executablePath of cachedChromes()) {
    try { return await chromium.launch({ executablePath, args }); } catch {}
  }
  for (const channel of ['chrome', 'msedge']) {
    try { return await chromium.launch({ channel, args }); } catch {}
  }
  console.error('No Chromium found. Run: npx playwright install chromium');
  process.exit(1);
}
scripts/serve.mjs›
#!/usr/bin/env node
// Zero-dependency static server for a built site (clean URLs, 404.html, correct MIME types).
// For static output folders (Astro dist/, Next `output: 'export'` out/, Vite dist/).
//   node serve.mjs [dist] [--port 4400]
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const types = {
  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
  '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
  '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2',
  '.woff': 'font/woff', '.ttf': 'font/ttf', '.mp4': 'video/mp4', '.webm': 'video/webm', '.xml': 'application/xml',
  '.txt': 'text/plain', '.glb': 'model/gltf-binary', '.mp3': 'audio/mpeg',
};

/** Serve `dir` on 127.0.0.1:`port` (0 = any free port). Resolves to the http.Server. */
export function serve(dir, port = 4400) {
  const root = path.resolve(dir);
  const resolve = (p) => {
    const clean = path.normalize(decodeURIComponent(p.split('?')[0])).replace(/^([/\\])+/, '');
    const base = path.join(root, clean);
    if (!base.startsWith(root)) return null;
    for (const c of [base, path.join(base, 'index.html'), base + '.html']) {
      try { if (fs.statSync(c).isFile()) return c; } catch {}
    }
    return null;
  };
  const server = http.createServer((req, res) => {
    const file = resolve(req.url || '/');
    const f = file || path.join(root, '404.html');
    if (!fs.existsSync(f)) { res.writeHead(404); return res.end('Not found'); }
    res.writeHead(file ? 200 : 404, { 'content-type': types[path.extname(f)] || 'application/octet-stream', 'cache-control': 'no-cache' });
    fs.createReadStream(f).pipe(res);
  });
  return new Promise((ok) => server.listen(port, '127.0.0.1', () => ok(server)));
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  const args = process.argv.slice(2);
  const dir = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--port') || 'dist';
  const pi = args.indexOf('--port');
  if (!fs.existsSync(dir)) { console.error(`No folder ${path.resolve(dir)}. Build first.`); process.exit(1); }
  const server = await serve(dir, pi > -1 ? +args[pi + 1] : 4400);
  console.log(`serving ${path.resolve(dir)} at http://127.0.0.1:${server.address().port}/`);
}
scripts/shoot.mjs›
#!/usr/bin/env node
// Visual QA: screenshots a running site the way a visitor experiences it (any framework).
//   node shoot.mjs <url> [--out .shots] [--steps 6] [--nav] [--crawl] [--max 8] [--tabs 12] [--hovers 8] [--no-throttle]
// Per page: desktop frames (the first screen once it is still, then scroll steps), mobile frames
// on a throttled mid-range phone (4x CPU, slow 4G), reduced-motion frames, keyboard-focus frames,
// rest/hover pairs per pointer target, page-transition frames (--nav clicks an internal link), plus
// report.json with console errors, failed requests, LCP/CLS, FPS, layout metrics, axe-core
// violations, the tab order, how each hover target reacts, whether anything moves at rest, and
// when the first screen stopped changing (an intro without a #loader is waited out, up to a cap).
// The hover pass adds about 3 s per target; --hovers 0 skips the targets (idle check still runs).
// --crawl shoots one page of every type plus the 404 into <out>/<page>/ and writes summary.json.
// Read every PNG it writes.
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { launch } from './browser.mjs';

const args = process.argv.slice(2);
const valued = ['--out', '--steps', '--max', '--tabs', '--hovers'];
const url = args.find((a, i) => !a.startsWith('--') && !valued.includes(args[i - 1]));
if (!url) { console.error('usage: node shoot.mjs <url> [--out dir] [--steps n] [--nav] [--crawl] [--max n] [--tabs n] [--hovers n] [--no-throttle]'); process.exit(1); }
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i > -1 ? args[i + 1] : d; };
const outRoot = path.resolve(opt('out', '.shots'));
const steps = parseInt(opt('steps', '6'), 10);
const tabs = parseInt(opt('tabs', '12'), 10);
const hovers = parseInt(opt('hovers', '8'), 10);
const nav = args.includes('--nav');
const crawl = args.includes('--crawl');
const throttle = !args.includes('--no-throttle');

// axe-core from the project, else from the CDN; without it the a11y pass is skipped.
async function loadAxe() {
  try { return fs.readFileSync(createRequire(path.join(process.cwd(), 'noop.js')).resolve('axe-core/axe.min.js'), 'utf8'); } catch {}
  try { const r = await fetch('https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js'); if (r.ok) return await r.text(); } catch {}
  return null;
}

// One representative URL per page type: home, each top-level page, one item per collection
// (/work/a and /work/b are both "work-item"), plus a missing URL for the 404.
async function pickPages(start, max) {
  const origin = new URL(start).origin;
  const types = new Map([['home', new URL(start).href]]);
  const queue = [new URL(start).href];
  const seen = new Set(queue);
  for (let fetched = 0; queue.length && fetched < 30 && types.size < max; fetched++) {
    const u = queue.shift();
    let html = '';
    try { const r = await fetch(u); if (!r.ok || !(r.headers.get('content-type') || '').includes('html')) continue; html = await r.text(); } catch { continue; }
    for (const m of html.matchAll(/<a\b[^>]*\shref=["']([^"'#]+)[^"']*["']/gi)) {
      let next;
      try { next = new URL(m[1].replace(/&amp;/g, '&'), u); } catch { continue; }
      next.hash = ''; next.search = '';
      if (next.origin !== origin || seen.has(next.href) || /\.(?!html?$)[a-z0-9]{2,5}$/i.test(next.pathname)) continue;
      seen.add(next.href);
      const segs = next.pathname.split('/').filter(Boolean);
      if (!segs.length) continue;
      const type = segs.length === 1 ? segs[0] : `${segs[0]}-item`;
      if (!types.has(type) && types.size < max) types.set(type, next.href);
      queue.push(next.href);
    }
  }
  types.set('404', new URL(`/__shoot-missing-page-${Date.now()}`, origin).href);
  return [...types].map(([name, href]) => ({ name: name.replace(/[^a-z0-9-]/gi, '_'), url: href }));
}

// Runs before any page script: records LCP (and its element), load-time CLS, and whether the
// page has a #loader at all (one that removes itself early would otherwise go unseen).
const vitalsInit = () => {
  window.__vitals = { lcpMs: null, lcpElement: null, cls: 0, hadLoader: false };
  document.addEventListener('DOMContentLoaded', () => { window.__vitals.hadLoader = !!document.getElementById('loader'); });
  const describe = (e) => !e ? null : e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + (typeof e.className === 'string' && e.className ? '.' + e.className.trim().split(/\s+/)[0] : '');
  try {
    new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__vitals.lcpMs = Math.round(e.startTime); window.__vitals.lcpElement = describe(e.element) || e.url || null; } }).observe({ type: 'largest-contentful-paint', buffered: true });
    new PerformanceObserver((l) => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__vitals.cls += e.value; }).observe({ type: 'layout-shift', buffered: true });
  } catch {}
};
const vitals = (page) => page.evaluate(() => {
  const { hadLoader, ...v } = window.__vitals;
  return { ...v, cls: Math.round(v.cls * 1000) / 1000 };
});
const hadLoader = (page) => page.evaluate(() => !!window.__vitals?.hadLoader || !!document.getElementById('loader'));

// Opens a page the way a visitor meets it: as soon as the HTML is parsed, then up to `cap` ms
// for the load event. A heavy page on the throttled phone can take longer than any timeout to
// fire load, while visitors already see it. Returns ms from `t0` to load, or null if it never came.
async function open(page, url, t0, cap = 30000) {
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
  return page.waitForLoadState('load', { timeout: cap }).then(() => Date.now() - t0, () => null);
}

// A loader is any #loader element; it is done when removed or hidden.
const loaderGone = () => { const l = document.getElementById('loader'); if (!l || l.hidden) return true; const cs = getComputedStyle(l); return cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity === 0; };

// Fraction of pixels that differ between two PNG buffers, decoded on a blank `scratch` page (no
// CSP). `r` limits the comparison to a region of the images (x, y, width, height in image pixels).
const pxDiff = (scratch, a, b, r = null) => scratch.evaluate(async ([a, b, r]) => {
  const read = async (s) => {
    const img = new Image();
    img.src = `data:image/png;base64,${s}`;
    await img.decode();
    const c = new OffscreenCanvas(img.width, img.height);
    const x = c.getContext('2d');
    x.drawImage(img, 0, 0);
    const { x: rx, y: ry, width: rw, height: rh } = r || { x: 0, y: 0, width: img.width, height: img.height };
    return x.getImageData(rx, ry, Math.max(1, rw), Math.max(1, rh)).data;
  };
  const [A, B] = await Promise.all([read(a), read(b)]);
  if (A.length !== B.length) return 1;
  let n = 0;
  for (let i = 0; i < A.length; i += 4) if (Math.abs(A[i] - B[i]) + Math.abs(A[i + 1] - B[i + 1]) + Math.abs(A[i + 2] - B[i + 2]) > 40) n++;
  return n / (A.length / 4);
}, [a.toString('base64'), b.toString('base64'), r]);

// Waits at least `min` ms, then until the screen stops changing, for at most `max` ms more. Sites
// without a #loader (most sites not built on the engine) often run an intro that a fixed wait
// would shoot mid-animation. Small ambient motion (particles, a slow shader) counts as settled.
// Still means three checks in a row (about 1.2 s) within 1% of the frame that began the run:
// intros pause between steps, and a single still pair mistook a pause for the end.
// Returns `settledMs`, the ms from `t0` by which the screen was still (null if it never was), and
// `changing`, whether it was still changing once `min` had passed.
async function settle(page, t0, min, max) {
  await page.waitForTimeout(min);
  const scratch = await page.context().newPage();
  try {
    const end = Date.now() + max;
    let base = await page.screenshot();
    let since = Date.now();
    let still = 0;
    let changing = false;
    while (Date.now() < end) {
      await page.waitForTimeout(400);
      const next = await page.screenshot();
      if (await pxDiff(scratch, base, next) < 0.01) {
        if (++still >= 3) return { settledMs: since - t0, changing };
      } else {
        changing = true;
        still = 0;
        base = next;
        since = Date.now();
      }
    }
    return { settledMs: null, changing };
  } finally {
    await scratch.close();
  }
}
// Wheel-scroll like a person so smooth-scroll libraries see real input.
const wheelTo = async (page, y) => {
  for (let i = 0; i < 80; i++) {
    const cur = await page.evaluate(() => scrollY);
    const d = y - cur;
    if (Math.abs(d) < 8) break;
    await page.mouse.wheel(0, Math.max(-900, Math.min(900, d)));
    await page.waitForTimeout(60);
  }
  await page.waitForTimeout(1300); // let reveals finish
};
const metrics = (page) => page.evaluate(() => {
  const de = document.documentElement;
  const overflowX = de.scrollWidth > innerWidth + 1;
  const offenders = overflowX
    ? [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > innerWidth + 1).slice(0, 5).map((e) => e.tagName.toLowerCase() + (e.className && typeof e.className === 'string' ? '.' + e.className.split(' ').join('.') : ''))
    : [];
  const h1 = [...document.querySelectorAll('h1')].map((h) => h.innerText.trim().slice(0, 60));
  const small = [...document.querySelectorAll('a, button, [role="button"]')].filter((e) => {
    const r = e.getBoundingClientRect();
    return r.width > 0 && (r.width < 24 || r.height < 24);
  }).slice(0, 8).map((e) => `${(e.innerText || e.getAttribute('aria-label') || e.tagName).trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().width)}x${Math.round(e.getBoundingClientRect().height)}`);
  // GL planes render on a canvas UNDER the content: an opaque background on the element or any
  // ancestor inside <main> hides the plane completely (shows up as empty boxes).
  const glHidden = de.classList.contains('gl-on') ? [...document.querySelectorAll('[data-gl]')].map((el) => {
    for (let n = el; n && n.tagName !== 'MAIN' && n !== document.body; n = n.parentElement) {
      const cs = getComputedStyle(n);
      const a = cs.backgroundColor.match(/[\d.]+/g);
      const opaque = (a && (a.length < 4 ? true : +a[3] > 0.05) && cs.backgroundColor !== 'rgba(0, 0, 0, 0)') || cs.backgroundImage !== 'none';
      if (opaque) return `${el.tagName.toLowerCase()}${el.className ? '.' + String(el.className).split(' ')[0] : ''} covered by ${n === el ? 'itself' : n.tagName.toLowerCase() + (n.className ? '.' + String(n.className).split(' ')[0] : '')}`;
    }
    return null;
  }).filter(Boolean).slice(0, 6) : [];
  return { height: de.scrollHeight, overflowX, offenders, h1, smallTargets: small, glHidden, title: document.title, glOn: de.classList.contains('gl-on') };
});
// Frame rate over `ms`. With `scrollPx`, the page also scrolls that far each frame (touch-like).
const fps = (page, ms = 2000, scrollPx = 0) => page.evaluate(([ms, scrollPx]) => new Promise((res) => {
  let n = 0; const t0 = performance.now(); let worst = 0; let last = t0;
  const f = (t) => { n++; worst = Math.max(worst, t - last); last = t; if (scrollPx) scrollBy(0, scrollPx); if (t - t0 < ms) requestAnimationFrame(f); else res({ fps: Math.round((n * 1000) / (t - t0)), worstFrameMs: Math.round(worst) }); };
  requestAnimationFrame(f);
}), [ms, scrollPx]);

// axe-core, WCAG 2.1 A/AA. Contrast failures carry the measured colors and ratio;
// "needsReview" counts text axe could not judge (usually text over images or canvas).
async function a11y(page, axeSource) {
  if (!axeSource) return { skipped: 'axe-core not found: run npm i -D axe-core in the project' };
  await page.evaluate(axeSource);
  return page.evaluate(async () => {
    const r = await window.axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }, resultTypes: ['violations', 'incomplete'] });
    const violations = r.violations.map((v) => ({
      id: v.id, impact: v.impact, help: v.help, count: v.nodes.length,
      nodes: v.nodes.slice(0, 4).map((n) => {
        const d = n.any.find((c) => c.data && c.data.contrastRatio)?.data;
        return d ? `${n.target.join(' ')} (${d.fgColor} on ${d.bgColor}, ${d.contrastRatio}:1, needs ${d.expectedContrastRatio})` : n.target.join(' ');
      }),
    }));
    const needsReview = r.incomplete.find((v) => v.id === 'color-contrast')?.nodes.length || 0;
    return { violations, serious: violations.filter((v) => v.impact === 'serious' || v.impact === 'critical').length, contrastNeedsReview: needsReview };
  });
}

// Tab through the page like a keyboard user. Flags focus stops with no visible change in style
// (heuristic: compares the element, its pseudo-elements and first children focused vs blurred)
// and focus landing on elements that are invisible or off screen.
async function keyboard(page, shot) {
  const stops = [];
  const noVisibleFocus = [];
  const invisibleFocus = [];
  let repeats = 0;
  for (let i = 1; i <= tabs; i++) {
    await page.keyboard.press('Tab');
    await page.waitForTimeout(250);
    const stop = await page.evaluate(() => {
      const el = document.activeElement;
      if (!el || el === document.body) return null;
      const style = (e, pseudo) => { const cs = getComputedStyle(e, pseudo); return [cs.outlineStyle, cs.outlineWidth, cs.outlineColor, cs.boxShadow, cs.backgroundColor, cs.color, cs.textDecorationLine, cs.borderColor, cs.opacity, cs.transform, cs.content].join('|'); };
      window.__snap = () => [style(el), style(el, '::before'), style(el, '::after'), ...[...el.children].slice(0, 3).map((c) => style(c))].join('#');
      window.__focused = el;
      let alpha = 1;
      for (let n = el; n; n = n.parentElement) alpha *= parseFloat(getComputedStyle(n).opacity);
      const r = el.getBoundingClientRect();
      return {
        tag: el.tagName.toLowerCase(),
        label: (el.innerText || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('href') || el.tagName).trim().replace(/\s+/g, ' ').slice(0, 40),
        href: el.getAttribute('href'),
        visible: alpha > 0.1 && r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth,
        focused: window.__snap(),
      };
    });
    if (!stop) { stops.push('(body: focus left the page)'); break; }
    if (i <= 4) await shot(page, `k-${String(i).padStart(2, '0')}-focus`);
    const blurred = await page.evaluate(async () => { window.__focused.blur(); await new Promise((r) => setTimeout(r, 300)); const s = window.__snap(); window.__focused.focus({ preventScroll: true }); return s; });
    const name = `${stop.tag} "${stop.label}"`;
    if (stops.length && stops[stops.length - 1] === name) repeats++;
    stops.push(name);
    if (!stop.visible) invisibleFocus.push(name);
    else if (blurred === stop.focused) noVisibleFocus.push(name);
    if (repeats >= 2) break;
  }
  const first = stops[0] || '';
  return {
    stops,
    skipLinkFirst: /skip|#main|#content/i.test(first),
    noVisibleFocus,
    invisibleFocus,
    stuck: repeats >= 2,
  };
}

// Point at things like a mouse user. Picks varied targets (engine-marked ones first, then
// buttons, then links in content, nav and footer, one per class signature) and moves the pointer
// onto each, off-center (magnetic pull is zero at the exact center), after checking that the
// target is really what sits under the pointer. A target reacts if its computed style changes
// (element, pseudo-elements, descendants, cursor follower) or the pixels around it change by more
// than the page's own idle motion. Both are needed: a finished letter roll looks identical to
// rest but its transforms changed, while a shader hover changes pixels but not the target's style.
// The changed properties tell a color swap from movement: a primary target (a link or button
// that is not legal, cookie, social or small footer text) whose hover only changes color lands
// in `colorOnly`, a list to review by eye (a full fill or inversion is a real treatment, a
// text-color swap is not). When the area already moves at rest (a live shader) and no style
// changed, pixels can't tell a hover apart: the target is `masked`, judged from its pair. Each
// target is saved as a clipped pair, h-NN-rest.png and h-NN-hover.png, at the same coordinates.
// Also records whether anything moves while the pointer is still, at the top and further down.
const COLOR_PROPS = new Set(['color', 'backgroundColor', 'borderColor', 'textDecorationLine', 'textDecorationColor']);
async function hover(page, save) {
  const scratch = await page.context().newPage();
  const diff = (a, b, r) => pxDiff(scratch, a, b, r);

  // Idle: pointer parked mid-left edge (the corners hold UI), cursor follower masked, after
  // intros and the follower have settled. Sampled at the top, then lower down if nothing moved.
  const mask = [page.locator('#cursor')];
  const still = async () => {
    await page.mouse.move(2, 450);
    await page.waitForTimeout(1500);
    const a = await page.screenshot({ mask });
    await page.waitForTimeout(1500);
    return (await diff(a, await page.screenshot({ mask }))) > 0.0005;
  };
  let idleAt = (await still()) ? 'top' : null;
  if (!idleAt) {
    const room = await page.evaluate(() => document.documentElement.scrollHeight - innerHeight);
    for (const f of room > 200 ? [0.35, 0.7] : []) {
      await wheelTo(page, Math.round(room * f));
      if (await still()) { idleAt = `${Math.round(f * 100)}%`; break; }
    }
    if (room > 200) await wheelTo(page, 0);
  }

  const targets = await page.evaluate((max) => {
    const sel = 'a[href], button, [role="button"], [data-magnetic], [data-cursor], [data-gl]';
    const rank = (e) => (e.matches('[data-cursor], [data-magnetic], [data-gl]') ? 0 : e.matches('button, [role="button"]') ? 1 : e.closest('main') ? 2 : 3);
    // Utility links may answer with color alone: legal, cookie, social, small footer text.
    const utility = (e) => {
      const href = e.getAttribute('href') || '';
      if (/privacy|legal|cookie|terms|imprint|impressum|colophon/i.test(href)) return true;
      if (/\/\/(www\.)?(instagram|twitter|x|linkedin|github|dribbble|behance|vimeo|youtube|tiktok|threads|bsky|mastodon|facebook|read)\.(com|net|app|social|cv)\b/i.test(href)) return true;
      return !!e.closest('footer') && rank(e) > 0 && parseFloat(getComputedStyle(e).fontSize) < 16;
    };
    const control = 'a[href], button, [role="button"]';
    // Visible text with spaces between nodes (innerText needs layout; clones have none).
    const text = (e) => {
      const parts = [];
      const walk = document.createTreeWalker(e, NodeFilter.SHOW_TEXT);
      for (let n = walk.nextNode(); n; n = walk.nextNode()) if (!n.parentElement.closest('[aria-hidden="true"]')) parts.push(n.textContent);
      return parts.join(' ').replace(/\s+/g, ' ').replace(/ ([,.;:!?])/g, '$1').trim();
    };
    const media = (e) => e.querySelector('img[alt]')?.getAttribute('alt') || e.querySelector('[role="img"][aria-label]')?.getAttribute('aria-label') || '';
    const seen = new Set();
    const picked = [];
    const all = [...document.querySelectorAll(sel)].filter((e) => {
      if (e.closest('[aria-hidden="true"], [hidden], #cursor, #curtain, #loader')) return false;
      // An image or cursor zone inside a link is tested through that link.
      if (!e.matches(control) && e.parentElement?.closest(control)) return false;
      const r = e.getBoundingClientRect();
      return r.width > 4 && r.height > 4 && getComputedStyle(e).visibility !== 'hidden';
    });
    all.sort((a, b) => rank(a) - rank(b));
    for (const e of all) {
      const sig = `${e.tagName}.${[...e.classList].sort().join('.')}|${e.closest('header, nav, footer, main')?.tagName || ''}`;
      if (seen.has(sig)) continue;
      seen.add(sig);
      e.setAttribute('data-qa-hover', picked.length);
      const interactive = e.matches(control);
      picked.push({
        i: picked.length,
        interactive,
        primary: interactive && !utility(e),
        label: `${e.tagName.toLowerCase()} "${(text(e) || e.getAttribute('aria-label') || media(e) || e.getAttribute('href') || e.tagName).trim().replace(/\s+/g, ' ').slice(0, 40)}"`,
      });
      if (picked.length >= max) break;
    }
    const props = ['color', 'backgroundColor', 'borderColor', 'textDecorationLine', 'textDecorationColor', 'backgroundSize', 'backgroundPosition', 'opacity', 'transform', 'translate', 'scale', 'rotate', 'clipPath', 'filter', 'letterSpacing', 'boxShadow', 'fontVariationSettings'];
    window.__hoverSnap = (e) => {
      const snap = {};
      const add = (key, el, pseudo) => { const cs = getComputedStyle(el, pseudo); for (const p of props) snap[`${key}:${p}`] = cs[p]; };
      add('self', e);
      add('before', e, '::before');
      add('after', e, '::after');
      [...e.querySelectorAll('*')].slice(0, 24).forEach((c, i) => add(`child${i}`, c));
      const cursor = document.getElementById('cursor');
      if (cursor) snap['cursor:follower'] = cursor.className;
      return snap;
    };
    return picked;
  }, hovers);

  const results = [];
  const covered = [];
  for (const t of targets) {
    const loc = page.locator(`[data-qa-hover="${t.i}"]`);
    await loc.evaluate((e) => e.scrollIntoView({ block: 'center' }));
    await page.waitForTimeout(900);
    const box = await loc.boundingBox();
    if (!box || box.y + box.height < 0 || box.y > 900) continue;
    const at = { x: box.x + box.width * 0.35, y: box.y + box.height * 0.4 };
    const hit = await loc.evaluate((e, { x, y }) => { const h = document.elementFromPoint(x, y); return !!h && (h === e || e.contains(h)); }, at);
    if (!hit) { covered.push(t.label); continue; }
    const away = box.x > 720 ? { x: 2, y: 450 } : { x: 1438, y: 450 };
    await page.mouse.move(away.x, away.y);
    await page.waitForTimeout(600);
    const x = Math.max(0, box.x - 24);
    const y = Math.max(0, box.y - 24);
    const clip = { x, y, width: Math.min(box.x + box.width + 24, 1440) - x, height: Math.min(box.y + box.height + 24, 900) - y };
    const restStyle = await loc.evaluate((e) => window.__hoverSnap(e));
    const rest1 = await page.screenshot({ clip });
    await page.waitForTimeout(700);
    const rest2 = await page.screenshot({ clip });
    await page.mouse.move(at.x, at.y, { steps: 8 });
    await page.waitForTimeout(700);
    const hoverStyle = await loc.evaluate((e) => window.__hoverSnap(e));
    const hovered = await page.screenshot({ clip });
    // Pixels are judged inside the target's own box: the saved pairs keep a margin for context,
    // but a moving neighbour in that margin must not hide a dead link.
    const ox = Math.max(0, Math.round(box.x - x));
    const oy = Math.max(0, Math.round(box.y - y));
    const own = {
      x: ox, y: oy,
      width: Math.max(1, Math.floor(Math.min(box.x + box.width, x + clip.width) - x) - ox),
      height: Math.max(1, Math.floor(Math.min(box.y + box.height, y + clip.height) - y) - oy),
    };
    const noise = await diff(rest1, rest2, own);
    const pixels = await diff(rest2, hovered, own) > Math.max(0.01, noise * 2);
    const changed = [...new Set(Object.keys(restStyle).filter((k) => restStyle[k] !== hoverStyle[k]).map((k) => k.split(':')[1]))];
    const react = changed.length ? (changed.every((p) => COLOR_PROPS.has(p)) ? 'color' : 'moves')
      : pixels ? 'pixels' : noise > 0.05 ? 'masked' : 'none';
    const n = `h-${String(results.length + 1).padStart(2, '0')}`;
    save(`${n}-rest`, rest2);
    save(`${n}-hover`, hovered);
    results.push({ shot: n, label: t.label, interactive: t.interactive, primary: t.primary, react, changed });
  }
  await scratch.close();
  const cursorFollower = await page.evaluate(() => !!document.querySelector('#cursor.is-ready'));
  return {
    targets: results,
    noHoverChange: results.filter((r) => r.interactive && r.react === 'none').map((r) => `${r.shot} ${r.label}`),
    colorOnly: results.filter((r) => r.primary && r.react === 'color').map((r) => `${r.shot} ${r.label}`),
    covered,
    cursorFollower,
    idleMotion: !!idleAt,
    idleAt,
  };
}

async function shootPage(browser, axeSource, url, out) {
  fs.mkdirSync(out, { recursive: true });
  for (const f of fs.readdirSync(out)) if (f.endsWith('.png')) fs.rmSync(path.join(out, f));
  const report = { url, errors: [], failed: [], desktop: {}, mobile: {}, files: [], notes: [] };
  const shot = async (page, name) => {
    const file = path.join(out, `${name}.png`);
    await page.screenshot({ path: file });
    report.files.push(file);
  };
  const save = (name, buf) => {
    const file = path.join(out, `${name}.png`);
    fs.writeFileSync(file, buf);
    report.files.push(file);
  };
  // Shooting a missing URL on purpose (to review the 404 page) is not an error.
  const expect404 = /404|not-?found|nothing|missing/i.test(new URL(url).pathname);
  const watch = (page, tag) => {
    page.on('console', (m) => {
      if (m.type() !== 'error') return;
      if (expect404 && /status of 404/.test(m.text())) return;
      report.errors.push(`[${tag}] ${m.text()}`);
    });
    page.on('pageerror', (e) => report.errors.push(`[${tag}] pageerror: ${e.message}`));
    page.on('requestfailed', (r) => report.failed.push(`[${tag}] ${r.url()} ${r.failure()?.errorText}`));
    page.on('response', (r) => {
      if (r.status() < 400) return;
      if (expect404 && r.status() === 404 && r.request().resourceType() === 'document') return;
      report.failed.push(`[${tag}] ${r.status()} ${r.url()}`);
    });
  };

  // Desktop
  let introMs = 0; // from the page being ready (loaded, loader gone) to the hero frame
  {
    const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
    await ctx.addInitScript(vitalsInit);
    const page = await ctx.newPage();
    watch(page, 'desktop');
    const t0 = Date.now();
    report.desktop.loadMs = await open(page, url, t0);
    const loader = await hadLoader(page);
    await page.waitForTimeout(400);
    await shot(page, 'd-00-firstpaint');
    await page.waitForFunction(loaderGone, null, { timeout: 8000 }).catch(() => report.errors.push('[desktop] loader still present after 8s'));
    const ready = Date.now();
    report.desktop.loaderMs = loader ? ready - t0 : null;
    report.desktop.settledMs = (await settle(page, t0, 1600, 8000)).settledMs;
    introMs = Date.now() - ready;
    await shot(page, 'd-01-hero');
    report.desktop.vitals = await vitals(page);
    await page.mouse.move(720, 450);
    const m = await metrics(page);
    report.desktop = { ...report.desktop, ...m };
    const max = m.height - 900;
    for (let i = 1; i <= steps; i++) {
      await wheelTo(page, Math.round((max * i) / steps));
      await shot(page, `d-${String(i + 1).padStart(2, '0')}-scroll`);
    }
    // FPS while scrolling back up through the page
    const fpsP = fps(page, 2500);
    for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, -500); await page.waitForTimeout(120); }
    report.desktop.scrollFps = await fpsP;
    if (nav) {
      await wheelTo(page, 0);
      // Up to four links to other pages on this site, relative or absolute. Some can't be
      // clicked (inside a carousel, an off-canvas menu), so each gets a short try.
      const links = await page.evaluate(() => {
        const out = [];
        for (const a of document.querySelectorAll('a[href]')) {
          const u = new URL(a.href, location.href);
          if (u.origin !== location.origin || u.pathname === location.pathname || a.target === '_blank' || a.hasAttribute('download')) continue;
          if (/\.(?!html?$)[a-z0-9]{2,5}$/i.test(u.pathname) || out.some((l) => l.path === u.pathname)) continue;
          a.setAttribute('data-qa-nav', out.length);
          out.push({ i: out.length, path: u.pathname });
          if (out.length >= 4) break;
        }
        return out;
      });
      let href = null;
      for (const l of links) {
        const link = page.locator(`[data-qa-nav="${l.i}"]`);
        try {
          await link.scrollIntoViewIfNeeded({ timeout: 3000 });
          await page.waitForTimeout(800);
          await link.click({ timeout: 3000 });
          href = l.path;
          break;
        } catch {}
      }
      if (!href) report.notes.push(`--nav: no link to another page could be clicked (tried ${links.map((l) => l.path).join(', ') || 'none found'})`);
      if (href) {
        await page.waitForTimeout(300);
        await shot(page, 'd-90-transition-a');
        await page.waitForTimeout(400);
        await shot(page, 'd-90-transition-b');
        await page.waitForTimeout(500);
        await shot(page, 'd-90-transition-c');
        await page.waitForURL((u) => u.pathname.startsWith(href.replace(/\/$/, '')), { timeout: 8000 }).catch(() => report.errors.push(`[nav] did not reach ${href}`));
        await page.waitForTimeout(2200);
        await shot(page, 'd-91-after-nav');
        report.desktop.nav = { href, scrollYAfter: await page.evaluate(() => scrollY), title: await page.title() };
        await page.goBack({ timeout: 10000 }).catch(() => report.notes.push('--nav: going back did not finish loading within 10 s'));
        await page.waitForTimeout(2500);
        await shot(page, 'd-92-back');
      }
    }
    await ctx.close();
  }

  // Fresh desktop loads below wait as long as the desktop pass did between the page being ready
  // and the hero frame, so their frames don't land mid-intro either. With a #loader, its end
  // already marks the intro's end, and the fixed wait covers the hero's entrance.
  const introWait = (fixed) => (report.desktop.loaderMs != null ? fixed : Math.max(fixed, introMs));

  // Keyboard: a fresh load, then Tab from the top of the page.
  {
    const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
    const page = await ctx.newPage();
    watch(page, 'keyboard');
    await open(page, url, Date.now());
    await page.waitForFunction(loaderGone, null, { timeout: 8000 }).catch(() => {});
    await page.waitForTimeout(introWait(1600));
    report.keyboard = await keyboard(page, shot);
    await ctx.close();
  }

  // Hover: a fresh load, the pointer still for a moment, then onto each target.
  {
    const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
    const page = await ctx.newPage();
    watch(page, 'hover');
    await open(page, url, Date.now());
    await page.waitForFunction(loaderGone, null, { timeout: 8000 }).catch(() => {});
    await page.waitForTimeout(introWait(2200));
    const { idleMotion, idleAt, ...h } = await hover(page, save);
    report.desktop.idleMotion = idleMotion;
    report.desktop.idleAt = idleAt;
    report.hover = h;
    await ctx.close();
  }

  // Mobile, on a mid-range phone: 4x CPU slowdown and slow 4G, like Lighthouse's mobile run.
  {
    const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
    await ctx.addInitScript(vitalsInit);
    const page = await ctx.newPage();
    watch(page, 'mobile');
    if (throttle) {
      const cdp = await ctx.newCDPSession(page);
      await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
      await cdp.send('Network.enable');
      await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 150, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8 });
    }
    const t0 = Date.now();
    const loadMs = await open(page, url, t0, 60000);
    const loader = await hadLoader(page);
    await page.waitForFunction(loaderGone, null, { timeout: 20000 }).catch(() => report.errors.push('[mobile] loader still present after 20s'));
    const loaderMs = loader ? Date.now() - t0 : null;
    const { settledMs } = await settle(page, t0, 1800, 8000);
    await shot(page, 'm-01-hero');
    const v = await vitals(page);
    report.mobile = { throttled: throttle, loadMs, loaderMs, settledMs, vitals: v, ...(await metrics(page)) };
    report.mobile.scrollFps = await fps(page, 2500, 6);
    await page.evaluate(() => scrollTo(0, 0));
    const max = report.mobile.height - 844;
    for (let i = 1; i <= Math.min(steps, 5); i++) {
      await page.evaluate((y) => scrollTo(0, y), Math.round((max * i) / Math.min(steps, 5)));
      await page.waitForTimeout(1500);
      await shot(page, `m-${String(i + 1).padStart(2, '0')}-scroll`);
    }
    await ctx.close();
  }

  // Reduced motion: content must be fully visible with no animation. Also the axe pass, since
  // every text is in its final state here.
  {
    const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
    const page = await ctx.newPage();
    watch(page, 'reduced');
    const t0 = Date.now();
    await open(page, url, Date.now());
    // `moving`: the screen was still changing 1.5 s after load, with reduced motion requested.
    const { changing: moving } = await settle(page, t0, 1500, 4000);
    await shot(page, 'r-01-hero');
    report.reduced = await page.evaluate(() => ({
      hiddenText: [...document.querySelectorAll('h1,h2,h3,p,a')].filter((e) => {
        const cs = getComputedStyle(e);
        return e.getBoundingClientRect().height > 0 && (cs.visibility === 'hidden' || parseFloat(cs.opacity) < 0.05);
      }).length,
    }));
    report.reduced.moving = moving;
    report.a11y = await a11y(page, axeSource).catch((e) => ({ skipped: `axe failed: ${e.message}` }));
    await page.evaluate(() => scrollTo(0, document.documentElement.scrollHeight / 2));
    await page.waitForTimeout(600);
    await shot(page, 'r-02-mid');
    await ctx.close();
  }

  report.notes.push('desktop FPS runs headless on a software GPU: treat it as a floor, not a measurement of real hardware');
  if (throttle) report.notes.push('mobile runs at 4x CPU slowdown on slow 4G: mobile.vitals and mobile.scrollFps are the numbers that count');
  report.notes.push('loadMs: ms from navigation to the load event, null if it had not fired after 30 s (desktop) or 60 s (phone); a heavy page takes long, while visitors see content at vitals.lcpMs. loaderMs: ms until the #loader ended, null when the page has none');
  report.notes.push('settledMs: ms from navigation by which the first screen stopped changing, never before the fixed wait. null: still changing at the cap, either a long intro or continuous motion (a live shader, video); d-01-hero / m-01-hero tell which. reduced.moving: the screen was still changing 1.5 s after load with reduced motion on');
  fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
  return report;
}

// The numbers to act on, one line per page.
const brief = (name, r) => ({
  page: name,
  url: r.url,
  errors: r.errors.length + r.failed.length,
  overflow: !!(r.desktop.overflowX || r.mobile.overflowX),
  smallTargets: r.mobile.smallTargets?.length || 0,
  hiddenTextReduced: r.reduced?.hiddenText ?? null,
  movingReduced: r.reduced?.moving ?? null,
  heroSettledMs: r.desktop.settledMs ?? null,
  mobileLcpMs: r.mobile.vitals?.lcpMs ?? null,
  mobileCls: r.mobile.vitals?.cls ?? null,
  desktopFps: r.desktop.scrollFps?.fps ?? null,
  mobileFps: r.mobile.scrollFps?.fps ?? null,
  axeSerious: r.a11y?.serious ?? null,
  axeViolations: r.a11y?.violations?.map((v) => `${v.id} x${v.count}`) ?? r.a11y?.skipped,
  skipLinkFirst: r.keyboard?.skipLinkFirst ?? null,
  noVisibleFocus: r.keyboard?.noVisibleFocus?.length ?? null,
  invisibleFocus: r.keyboard?.invisibleFocus?.length ?? null,
  noHoverChange: r.hover?.noHoverChange?.length ?? null,
  colorOnly: r.hover?.colorOnly?.length ?? null,
  idleMotion: r.desktop.idleMotion ?? null,
});

const browser = await launch();
const axeSource = await loadAxe();
if (crawl) {
  const targets = await pickPages(url, parseInt(opt('max', '8'), 10));
  const summary = [];
  for (const t of targets) {
    console.error(`shooting ${t.name}: ${t.url}`);
    // One page the browser can't get through must not cost the rest of the crawl.
    try {
      summary.push(brief(t.name, await shootPage(browser, axeSource, t.url, path.join(outRoot, t.name))));
    } catch (e) {
      const why = e.message.split('\n')[0];
      console.error(`  failed: ${why}`);
      summary.push({ page: t.name, url: t.url, shootFailed: why });
      for (const c of browser.contexts()) await c.close().catch(() => {});
    }
  }
  fs.writeFileSync(path.join(outRoot, 'summary.json'), JSON.stringify(summary, null, 2));
  console.log(JSON.stringify(summary, null, 2));
  console.log(`\n${targets.length} pages shot into ${outRoot}/<page>/ (report.json in each). Read every PNG.`);
} else {
  const report = await shootPage(browser, axeSource, url, outRoot);
  console.log(JSON.stringify({ ...report, files: `${report.files.length} screenshots in ${outRoot}` }, null, 2));
}
await browser.close();
SKILL.md›
---
name: jury
description: Score any website the way an Awwwards jury would, from evidence alone. Screenshots every page type on desktop, a throttled phone and reduced motion, measures LCP, CLS, FPS, axe, keyboard focus and hover feedback, audits SEO and metadata, then a fresh critic that never sees the code scores Design, Usability, Creativity, Content and the six developer areas, with a ranked fix list. Use when someone asks to review, critique, score, rate or roast a website, portfolio or landing page, asks whether a site is good enough for Awwwards or Site of the Day, or wants design QA of a live URL or a local build. Not for building or redesigning a site; use /awwwards for that.
---

# /jury

Score a website the way the Awwwards jury would: from what a visitor sees and what the browser
measures, never from the code, the design notes or what the owner meant. Hand back a score card
with evidence for every number and a ranked list of fixes.

**Not affiliated with Awwwards.** The criteria and weights are theirs and public
(awwwards.com/about-evaluation); the scores are an estimate. Say so on every card.

**Skill root:** the directory containing this file (`$SKILL`; substitute the absolute path in
commands). The scripts need Node 20+ and Playwright, plus axe-core for the accessibility pass.
`references/quality-bar.md` has the system, the rubric and the critic brief.
`references/calibration.md` has the measured gap between this critic and real juries. It is for
you, for the score card. Never give it to the critic.

## Why blind

Anyone who knows what a site is meant to do scores the intent instead of the screen. A juror
gets a URL and a few minutes. So the critic gets only screenshots and measurements: no source,
no `DESIGN.md`, no README, no pitch from the owner. If the user explains the idea, hold it until
the score is in, then compare (step 5).

## Workflow

### 1. Target

- **A URL:** score what is deployed.
- **A local project, no URL:** build it and serve the production output. Never score a dev
  server: unminified JS and dev overlays wreck WPO and add console noise.
  - Next.js: `npm run build`, then `npx next start -p 4400` in the background.
  - Static output (Astro, Vite, Next `output: 'export'`):
    `node $SKILL/scripts/serve.mjs <dist|out> --port 4400` in the background.
  - Anything else: its own production build and start commands.
- **A dev URL only** (localhost with hot reload): build production instead if the project is
  here. If it isn't, score it and say the WPO numbers are pessimistic.
- **Several URLs** ("mine vs theirs"): run steps 3 to 5 for each, each with its own fresh
  critic, then put the cards side by side.
- **Behind a login or password:** it can't be scored. Ask for a public URL or a local build.

Evidence goes in `.jury/<host>/` in the current directory (`<host>` such as `example.com` or
`127.0.0.1-4400`). Use absolute paths for it below.

### 2. Tools

The scripts resolve Playwright and axe-core from their working directory.

- If the current project already has Playwright (`node -e "require.resolve('playwright')"`
  succeeds), run the scripts from there.
- Otherwise don't add dependencies to someone's project for a review. Install once into a cache
  folder outside it and run the scripts from that folder:
  ```bash
  mkdir -p ~/.cache/jury && cd ~/.cache/jury && npm init -y > /dev/null && npm i -D playwright axe-core
  ```
- If `shoot.mjs` reports no Chromium, run `npx playwright install chromium` once in that folder.

### 3. Evidence

```bash
node $SKILL/scripts/shoot.mjs <url> --crawl --nav --out <abs>/.jury/<host>/shots
node $SKILL/scripts/audit.mjs <url> > <abs>/.jury/<host>/audit.txt
```

- `shoot.mjs --crawl` takes one page of every type (home, each top-level page, one item per
  collection) plus the 404. For each: desktop at 1440, a phone at 390 throttled to a mid-range
  device (4x CPU, slow 4G), reduced motion, keyboard focus, rest/hover pairs for pointer
  targets, idle motion, and page-transition frames. Allow a minute or two per page.
- `audit.mjs` exits with code 1 when it finds a FAIL. That is a finding, not a broken run.
- For a quick look at one page, drop `--crawl` and pass `--max 10` to the audit. Say on the
  card that it covers one page.
- **Check the evidence is real before scoring.** Open `shots/home/d-01-hero.png` and
  `shots/summary.json`. A bot challenge, a login wall, an error page, or a blank frame with
  errors in `report.json` means there is nothing to score: stop and tell the user. A cookie
  banner is not a reason to stop. Visitors see it too, so it counts.
- Crawling is light: at most 40 HTML pages for the audit and 8 page types for screenshots.
  Don't raise those limits on a site the user doesn't own.

### 4. Score

- **With subagents:** give a fresh one the critic brief from `references/quality-bar.md`
  ("Critic brief"), with its three paths filled in (the rubric, `.jury/<host>/shots/`,
  `.jury/<host>/audit.txt`). Give it nothing else, and never `calibration.md`. It reads every
  screenshot, so your own context stays small.
- **Without subagents:** score it yourself under the same brief, from the evidence alone, and
  mark the card as a self-score.
- **If you built or edited this site in this conversation**, a self-score isn't blind. Use a
  subagent, or say on the card that the score is not independent.

### 5. Score card

Write `.jury/<host>/scorecard.md` and give the same card in chat:

```markdown
# <site>: jury score
Estimate, not affiliated with Awwwards. <Independent critic | Self-score>, from <n> pages on <date>.

**The idea, as the critic read it:** one sentence
**Critic overall: 6.2** (0.4 Design + 0.3 Usability + 0.2 Creativity + 0.1 Content, on the critic's strict scale)
**Jury estimate: about 7.3** (critic + 1.1, measured on two recent Sites of the Day)
**Band, from the estimate:** below Honorable Mention (under 6.5) | Honorable Mention range (6.5 to 7.1) | Site of the Day range (7.2 and up)

| Criterion | Score | Evidence |
|---|---|---|
| Design (40%) | | file or report field, and what it shows |
| Usability (30%) | | |
| Creativity (20%) | | |
| Content (10%) | | |

| Developer | Score | Evidence |
|---|---|---|
| Semantics/SEO | | |
| Animations/Transitions | | |
| Accessibility | | |
| WPO | | |
| Responsive | | |
| Markup/Meta-data | | |

## Fixes, most score first
1. [Usability] m-03: what is wrong, and what to change
2. ...

## Measured
Mobile LCP and CLS, loader time, FPS, serious axe violations, overflow, `noHoverChange`,
`colorOnly`, idle motion, audit FAIL and WARN counts (from `summary.json` and the audit).

Not scored: anything stills can't show (how motion feels, sound).
```

Take the offset for the jury estimate from `references/calibration.md` (the 1.1 above is the
offset when this was written) and follow its rules: the critic's own numbers stay as scored, and
the developer areas get no jury estimate.

Then, in chat only:
- If the user told you what the site is meant to say, one line: does the critic's reading
  match? If not, the site isn't saying it, and that is the first Creativity fix.
- The two fixes that would move the overall most.
- An offer: fix the list in this project if it's here, or run `/awwwards` in it for a redesign.

### Re-scoring after fixes

Run steps 3 to 5 again with a **new** critic, never the earlier one, and never show it the
earlier card: it would anchor on the old numbers. Before writing the new card, rename the old
one to `scorecard-<n>.md`, then show the change per category.

## Rules

- No evidence, no points. Every score cites a screenshot file or a report field. Start each
  category at 6 and make the evidence earn every point above it.
- Don't argue a score up. Dispute one only by pointing at a screenshot the critic misread.
- Judge the site on screen. Don't propose a new concept; fix what's there.
- Specific, not cruel: "m-03: the title wraps to five lines at 390 px; drop it to 14vw", never
  "the typography needs work". Someone made this.