Skip to content

Check

critly check opens your running app, measures what it renders at phone and desktop widths in light and dark, and fails when the interface breaks a concept. Each finding says what to change it to and links the concept that explains why. Your agent runs it before it says it's done, and the exit code is the one thing it can't talk its way past.

Set it up with your agent

Paste this to your agent. It reads the setup guide, drafts your app's policy from its design tokens, asks you to approve it, and adds the check to your scripts and AGENTS.md.

Read https://critly.vercel.app/setup.md and set up the Critly check in this project.

Or run it yourself

npx @kausthubh/critly check http://localhost:3000

It uses the Chrome or Edge you already have. Exit 0 means it passed, 1 means there are findings (including ones it couldn't decide), and 2 means it couldn't run. The last line is the summary worth quoting.

critly check
http://localhost:3000/settings/billing

✕ hit-area  error · 390x844 · light, dark
  where   button "Close" in <dialog> "Edit payment method"
  actual  20×20px
  fix     Make it at least 44×44px at ≤ 640px wide: add 12px of padding (p-3), or extend the target with an ::after inset.
  concept https://critly.vercel.app/hit-areas

✕ text-contrast  error · 1440x900 · dark
  where   p.hint "Billed yearly, cancel any time" under "Plan"
  actual  3.1:1, #6b6b6b on #1c1c1c
  fix     Use text-muted-foreground (--muted-foreground, 5.2:1); needs 4.5:1.
  concept https://critly.vercel.app/color-roles

checked 3 pages × 2 viewports × 2 schemes · 4,812 elements · 15 rules
critly check: 2 errors, 0 review · 3 pages × 2 viewports × 2 schemes · exit 1

The policy

.critly/policy.json is your design system as numbers: fonts, the type scale, how dark mode switches, which pages to check, and exceptions. Every exception needs a reason. Fix the interface, not the policy: findings never suggest editing it, and agents are told not to without asking you.

.critly/policy.json
{
  "fonts": ["Inter", "JetBrains Mono"],
  "typeScale": [12, 14, 16, 20, 24, 48],
  "schemes": { "light": {}, "dark": { "class": "dark", "on": "html" } },
  "pages": ["/", "/settings", "/settings/billing"],
  "exceptions": [
    {
      "selector": ".embed-map *",
      "rules": ["text-contrast"],
      "reason": "Third-party map tiles; we don't control their text."
    }
  ]
}

Rules

On by default

Measured on one render with no markup. Rules marked policy need your app's values.

  • text-contrastColor Roles →

    Text is below 4.5:1 against what's behind it (3:1 for large text), in every color scheme. Text over images, gradients or blends is reported for review instead of guessed.

  • A button, standalone link or input is smaller than 24px, or 44px on screens 640px wide or less. Links inside a sentence are exempt.

  • icon-button-nameIcons →

    A button that shows only an icon has no accessible name.

  • input-detailsInput Details →

    An input has no label, or its text is under 16px on a phone, which makes iOS zoom in on focus.

  • Something is wider than the screen at 390px, text is clipped, or a fixed element or open dialog sits off-screen.

  • type-scalepolicyType Scale →

    A font size isn't one of the sizes in the policy's type scale.

  • font-familypolicyDesign Tokens →

    Interface text uses a font that isn't in the policy (code, kbd and pre are excluded).

  • line-lengthLine Length →

    A paragraph runs wider than 75 characters (or the policy's limit).

  • letter-spacingLetter Spacing →

    All-caps text has no positive tracking, or display text (32px and up) has positive tracking.

  • tabular-numbersTabular Numbers →

    Numbers in table cells (or marked data-critly-number) don't use tabular figures, or a numeric column isn't end-aligned.

  • html-backgroundHTML Background →

    The html element has no background, or it differs from the body's, so overscroll shows a different color.

  • transition-allEffect Cost →

    An element uses transition: all.

  • layout-animationSmooth Animation →

    An animation or transition moves width, height, top, left, margin or padding instead of transform and opacity.

  • image-dimensionsImage Loading →

    An image has no width and height or aspect-ratio, so the page jumps when it loads, or a large image above the fold is lazy-loaded.

  • placeholder-contentReal Content →

    The page shows lorem ipsum, "John Doe", TODO, or a phrase the policy bans.

Opt-in

These infer intent from layout, so they stay off until you turn them on in the policy.

  • spacing-scalepolicySpacing Scale →

    A gap or padding isn't on the policy's spacing scale. Opt-in until it's proven quiet on real apps.

  • off-token-colorpolicyDesign Tokens →

    A text, background or border color matches none of the app's color tokens. Opt-in until it's proven quiet on real apps.

  • A rounded surface inside another, at an even inset, doesn't use outer radius minus inset. Opt-in until it's proven quiet on real apps.

Coming

These need interaction: pressing Tab, opening dialogs, watching the page load. focus-ring already runs as an opt-in from the command line.

  • focus-ringFocus Rings →

    Tabbing to a control changes nothing visible, or the focused control is covered.

  • reduced-motionReduced Motion →

    With reduced motion on, transform or position animations still run.

  • layout-shiftLayout Shift →

    The page shifts more than 0.1 (CLS) while it loads.

  • overlay-focusOverlays →

    An open dialog doesn't move focus in, keep it there, or return it on close.

In your tests

Already use Playwright? Check any state your tests reach: an open dialog, a filled form, a signed-in page.

billing.spec.ts
import { test } from "@playwright/test";
import { expectCritly } from "@kausthubh/critly/playwright";

test("billing page passes the Critly check", async ({ page }, testInfo) => {
  await page.goto("/settings/billing");
  await expectCritly(page, testInfo);
});

The taste pass

Some of what makes a screen feel off can't fail a rule: every group in its own box, text starting at five slightly different edges, two buttons competing. critly taste squints at the first screen of each page, counts its boxes and edges, and finds what it leads with. It writes a squint image and an overlay for each screen and lists what to look at. It never fails a page; see Taste Pass for how it measures.

npx @kausthubh/critly taste http://localhost:3000

What it can't tell you

The check rules out the mistakes you can measure. The taste pass points your eyes at the rest, but it can't judge copy or whether the thing a screen leads with is the right thing. That's what the review and audit skills are for, along with looking at the screen. When the check can't decide, like text over an image, it reports the finding for review instead of passing it.

The method comes from lintapple and the messages from @shadcn/lint. See Credits.

Search concepts

Search for a command to run...