name: code-review
description: Hakuto-specific code review for Astro + Tailwind v4 + shadcn/ui sites. Audits source code against the project's CLAUDE.md rules — image optimization, className vs class, Tailwind v4 setup, Fonts API, Cloudflare adapter, internal links (anchor ids, trailing slashes), accessibility, LCP / critical-render-path performance, deferred marketing pixels, static-asset caching and security headers in _headers, code hygiene. Can review a single file, recently changed files, or the whole src/ tree. Report-only — no fixes applied. Use when user requests "review code", "code review", "audit code", "check code quality", or "lint the site".
Code Review Skill
Audit Hakuto-built sites against the project's own CLAUDE.md rules.
This skill is the source-side counterpart to seo-audit (which audits the built _dist/ HTML). Together they cover both: this one catches issues in src/, astro.config.mjs, and src/index.css before build; seo-audit catches what the build produces.
Flexible Scope: parses the user request to choose what to review:
- Single file: "Review src/pages/index.astro" or "Audit Header.astro"
- Changed files: "Review my changes" or "Review recent changes"
- Whole project (default): "Run code review", "Audit the site"
Execution Flow
0. Determine Scope
Parse the user prompt:
- Single file — explicit path mentioned → review only that file plus its directly-imported configs.
- Changed files — phrases like "my changes" / "recent changes" / "what I edited" → run
git diff --name-only HEADand also include unstaged/untracked undersrc/(git status --short). Restrict checks to those files. - Whole project (default) — walk
src/**/*.{astro,ts,tsx,css}, plus the project'sastro.config.mjs,src/index.css,package.json, andtailwind.config.*if present.
If the user names a file that doesn't exist, list nearby candidates and stop.
1. Read Project Context
Always read these first — many checks depend on them:
package.json— confirmbunscripts, dependency versions, thattailwind.config.mjsis NOT present.astro.config.mjs— Cloudflare adapter options,experimental.fontsconfiguration, integrations.src/index.css— verify Tailwind v4 ordering and@theme/@layer basestructure.src/layouts/Layout.astro(top ~80 lines) —<Font />preloads, schema/SEO setup.
2. Initialize Trackers
critical = [] # blocking issues
warnings = [] # should-fix but non-blocking
passed = [] # checks that passed
Each entry should be { rule, file, line, message } so the report can show file:line.
3. Run Checks
Run every category below that applies to the in-scope files. Each violation cites the CLAUDE.md rule it ties to so future rule changes flow through automatically.
4. Optional Type-Check
If the scope contains any .astro, .ts, or .tsx file, run bun run check ONCE at the end. Surface only new errors introduced by the in-scope files; do not fix pre-existing ones (per CLAUDE.md "Verification Before Completion"). If bun is not on PATH, log a single warning and continue with static checks.
Check Categories
A. Tailwind v4 Setup (src/index.css, project root)
- Critical:
@import 'tailwindcss'is not the first non-comment line ofsrc/index.css. - Critical:
tailwind.config.mjs/tailwind.config.js/tailwind.config.tsexists at the project root (v4 ignores it — must be deleted). - Warning: custom design tokens (colors, fonts, radius, spacing) defined outside an
@theme { … }block. - Warning: CSS custom properties (
--foo: …) defined outside@layer base { … }. - Pass:
@import 'tailwindcss'first →@plugin 'tailwindcss-animate'→@theme→@layer base(the canonical order).
B. Astro File Types (src/pages/, src/layouts/)
- Critical: any
.tsxor.jsxfile undersrc/pages/orsrc/layouts/(CLAUDE.md: "Use .astro files only for pages and layouts"). - Warning: a
.tsxcomponent imported into a page without a clear interactivity reason (CLAUDE.md prefers native JS over React for simple toggles/animations).
C. shadcn / React Component Props
Scan .astro and .tsx files for shadcn/React component usages (capitalised tag names like <Button>, <Card>, <Badge>, <Tabs>, etc.). React components only recognise className; using class produces a TypeScript error and silently drops the styling at runtime.
- Critical: remove unused components, so that we have leaner CSS.
- Critical:
class=used on a React/shadcn component (must beclassName=). - Warning:
style={{ … }}used on a shadcn component (use Tailwind utility classes; for animation delays usedelay-*or@themetokens). - Pass: HTML elements (lowercase tags) using
class=— that is correct.
D. Image Optimization
For each .astro file:
- Critical: a local image is
import-ed (e.g.import hero from '@/assets/…') and rendered with a bare<img>tag. CLAUDE.md mandates<Picture>fromastro:assetswithformats={['avif','webp']}andwidths={[800, 1200, 1920]}for local images. - Warning:
<Picture>/<Image>missingformatsorwidths. - Warning: an above-the-fold image (in the first section of a page) without
loading="eager", or a below-the-fold image withoutloading="lazy". - Critical: any
<img>/<Picture>/<Image>missing thealtattribute (accessibility + SEO). - Pass (do NOT flag): bare
<img>whosesrcis an external URL such ashttps://images.unsplash.com/…— CLAUDE.md explicitly allows this for placeholder/external imagery.
E. Fonts
Generic system fonts (Inter, Roboto, Arial) are the visual fingerprint of "AI slop" — sites built with them blend into a sea of identical-looking output. CLAUDE.md's <frontend_aesthetics> rules push toward distinctive faces specifically because typography is the highest-leverage signal that a site was made with care.
- Critical:
@font-faceor@import url('https://fonts.googleapis.com/…')present insrc/index.css(CLAUDE.md: "NEVER use @import or @font-face in CSS for custom fonts"). - Critical: custom fonts referenced in
index.css(--font-sans,--font-mono, etc.) butastro.config.mjshas noexperimental.fontsarray configuring them. - Warning: primary font family is a generic AI-default (Inter, Roboto, Arial, system-ui) — CLAUDE.md's
<frontend_aesthetics>block explicitly rejects these. - Pass: fonts declared via
experimental.fonts, exposed as CSS variables in@theme, preloaded via<Font />in Layout.astro.
F. Cloudflare Adapter (astro.config.mjs)
If the project uses @astrojs/cloudflare:
- Critical:
imageService: "passthrough"— disables the image service entirely and breaks<Picture>/<Image>in dev and build. - Critical: missing
prerenderEnvironment: "node"— the default"workerd"fails prerendering with a 404 outside Cloudflare's infra. - Warning:
imageServicenot set at all (relying on adapter default). - Pass:
imageService: "compile"+prerenderEnvironment: "node".
G. Internal Links
G1. Anchor fragments — for every href="#…" in .astro files:
- Critical: no element in the same page (or in components imported by the page) carries the matching
id="…". CLAUDE.md: "When creating anchor links, ALWAYS create the corresponding id in the target element." - Pass: matching
idfound.
G2. Trailing-slash convention — read trailingSlash from astro.config.mjs (absent ⇒ Astro default "ignore"). Extract every internal path link — href="/…" and href={ `/…` } template literals — from .astro / .ts / .tsx files. Before judging, strip any #fragment / ?query, and ignore: external links (http://, https://, protocol-relative //, mailto:, tel:), the bare root /, pure fragments (#…), and file targets whose last path segment contains a dot (/rss.xml, /sitemap-index.xml, /.well-known/security.txt).
A link is mismatched when its slash form contradicts the resolved convention:
trailingSlash: "always"→ internal link with no trailing slash (href="/product").trailingSlash: "never"→ internal link with a trailing slash (href="/product/").trailingSlash: "ignore"(the default) and the project uses@astrojs/cloudflare(or otherwise serves from Cloudflare static assets) → bare links still301-redirect to the slash form at the edge, so treat as the"always"case.Warning — Internal link contradicts the
trailingSlashconvention. Each mismatch sends the browser through a301redirect to the canonical form — an extra round-trip on every navigation, and on crawlers it wastes crawl budget and dilutes link equity. Fix the link to match the convention; don't rely on the redirect. Report eachfile:line; for many repeats in one file you may append "(+N more in this file)".Pass — every internal link already matches the configured convention (or
trailingSlash: "ignore"on a host that doesn't redirect).
Detect with
Grep: for the"always"/ Cloudflare case,href="/[^"#?]*[^/"#?]", then drop matches whose path contains a dot (those are files). For"never", searchhref="/[^"#?]*/". Always confirmtrailingSlashand the adapter inastro.config.mjsfirst — the same bare link is a finding on an"always"site and a non-issue on a true"never"site, so the config read decides the direction.
H. Accessibility & Semantic HTML
Per .astro page:
- Critical: 0 or >1
<h1>elements on the page (after composition — count includes<h1>inside imported components when statically obvious). - Critical: heading hierarchy skips a level (e.g.
<h1>→<h3>). - Critical: any image element lacks
alt. - Warning: icon-only
<button>/<a>(lucide-react<Icon>as the only child) withoutaria-label. - Warning:
<button>styled to look like a link when an<a>is semantically correct (or vice versa). - Pass: heading order correct, all images labelled, interactive elements named.
I. Favicon Source Location
public/ holds files generated by the astro-favicons plugin from a single source in src/assets/. Putting an editable favicon in public/ confuses source-of-truth: regenerations will overwrite it, and the next dev to look for "the favicon" will edit the wrong file.
- Critical: an editable favicon source file (
.svg,.png) is found inpublic/rather thansrc/assets/. CLAUDE.md:public/contains auto-generated files only; sources live insrc/assets/. - Warning:
astro-faviconsplugin block inastro.config.mjsis missing or still references the template default name (Hakuto,Site Name). - Pass: source under
src/assets/, plugin configured with project-specificnameandshort_name.
J. Code Hygiene
Run only if scope includes .astro / .ts / .tsx:
- Critical:
bun run checkreports a NEW error caused by an in-scope file (unused imports, type errors, implicitanyin middleware/Vite plugin callbacks). - Warning:
bun run checkreports pre-existing errors (list separately so the user can decide). - Warning: a file imports something it never references (
import * as fs from "node:fs"with nofs.usage) — detect with a simple grep where possible. - Pass:
bun run checkclean over the in-scope files.
K. Template Cleanup
Generated sites must scrub the scaffold's placeholder strings before shipping:
- Critical:
Layout.astrostill hasSITE_NAME = "Hakuto"/SITE_DESCRIPTION = "…"defaults when the project is clearly customised. - Warning: lorem-ipsum copy (
Lorem ipsum,Placeholder,TODO,[Your headline here]) present in any page. - Warning: page
<title>is the literal stringAstroor empty.
L. Performance — Critical Render Path & LCP
PageSpeed's mobile LCP target is ≤2.5s, and on Hakuto sites the LCP element is usually the hero <h1> text, not an image — the dashboard/hero image typically renders below-the-fold on phone-width viewports. Two source-side patterns cause LCP regressions that this skill can catch statically:
- Warning — Hero H1 LCP blocked by custom heading font (mobile). The first
<h1>insrc/pages/index.astro(or any landing page) inherits a custom-font CSS variable (e.g.var(--font-heading)resolving to a non-system family like Eina01, Crimson Pro, Sora) without a mobile-only system-font override. On 4G-throttled mobile, the H1 waits for the woff2 to download and adds 1–3s of "element render delay" (visible in PSI's LCP breakdown). The recommended fix is amax-md:font-systemclass on the hero H1 plus a--font-system: system-ui, -apple-system, …token in@theme, so phones paint the H1 in a system font and tablets/desktops keep the brand face.- Detect: page contains an
<h1>whose computed font resolves through--font-heading(or another--font-*token that maps to a custom family declared inexperimental.fonts/fontsarray), and the same element has nomax-md:font-*/md:font-heading-style override.
- Detect: page contains an
- Critical — Third-party tracking scripts loaded on the critical render path. Any
<script>whosesrc(static or constructed inside an inline script) resolves to a known tracking domain —googletagmanager.com,googleadservices.com,connect.facebook.net,consent.cookiebot.com,firstpromoter.com/fpr.*,clarity.ms,static.hotjar.com,cdn.amplitude.com,js.hs-scripts.com— must be wrapped in a deferred loader gated on first user interaction ORwindow.load+ small timeout, not loaded synchronously or with bareasync/defer. Loading marketing pixels before LCP burns the main thread during the critical render and inflates LCP/TBT.- Detect: a static
<script src="https://{tracking-domain}/…">inLayout.astroor any always-included component, OR an inline script that callsdocument.createElement('script')with one of those URLs not inside anaddEventListener("load", …)/["click","keydown","touchstart","scroll"].forEach(…, { once:true })/requestIdleCallback(…)wrapper. A baresetTimeout(load, N)on one pixel while others load inline is still flagged (inconsistent deferral). - Recommended pattern: a single
fire()closure that initializes all pixels, triggered by the first of (a) any ofclick/keydown/touchstart/scroll, (b)setTimeout(fire, 500)chained off theloadevent. Seeeasyblognetworks/src/components/MarketingPixels.astrofor a reference implementation.
- Detect: a static
- Warning — Above-the-fold image missing
fetchpriority="high". A<Picture>or<Image>in the first section of a page usingloading="eager"but with nofetchpriority="high"prop. The LCP element should be both eagerly loaded and high-priority so the browser fetches it before lower-priority subresources. - Warning —
google.si/ localized Google TLD ping note. If the project includes a Google Ads conversion tag (id likeAW-…) and the user hasn't opted out of cross-domain remarketing (allow_google_signals: false), Google's gtag fires a country-localizedgoogle.{tld}pixel in addition togoogle.com. Not a source-code defect — just surface this as an informational note when anAW-tag is present, pointing to the Google Ads → Conversion settings opt-out.
M. Static Asset Caching (public/_headers)
Cloudflare Workers' Static Assets binding ([assets] block in wrangler.toml) defaults responses to cache-control: public, max-age=0, must-revalidate for every path — including content-hashed /_astro/* files that are immutable by construction. Browsers re-validate every CSS/JS/image asset on every visit unless _headers overrides this. Cloudflare Pages's older defaults were more permissive; Workers is not, and the skill should treat the bare scaffold _headers as incomplete.
- Critical —
/_astro/*not configured for long-immutable cache. Project uses@astrojs/cloudflareANDwrangler.tomlhas an[assets]block ANDpublic/_headersdoes not contain a/_astro/*rule whoseCache-Controlincludes bothmax-age >= 31536000andimmutable. Hashed Astro outputs change filename whenever content changes, so caching them for a year is always safe. - Warning — Favicon / app-icon / manifest paths uncached or cached too aggressively.
public/_headersmissing a rule for the favicon family —/favicon.ico,/favicon.svg,/favicon-*,/apple-touch-icon*,/android-chrome-*,/mstile-*,/manifest.webmanifest,/browserconfig.xml— OR an existing rule setsmax-age > 86400(>1 day). Favicons get swapped during rebrands; a 1-day cache (max-age=86400) lets new icons propagate to returning visitors within a day. Anything longer "would prevent new changes" from appearing. - Warning — Pagefind index uncached.
astro-pagefindis inastro.config.mjsintegrations AND_headershas no/pagefind/*rule. Pagefind regenerates each build; a 1-day cache (max-age=86400) is the conventional value. - Pass —
_headershas explicitCache-Controlrules for/_astro/*(immutable+1y) and amax-age=86400(1 day) cap on favicons/manifests//pagefind/*. HTML responses can keep Cloudflare'smax-age=0default — that's appropriate for HTML since the CDN edge cache (cf-cache-status: HIT) still serves it without origin round-trips.
Verification tip for the user (not for the skill to run):
curl -I https://{domain}/_astro/{any-file}on a deployed URL should return themax-age=31536000, immutableheader. Pingdom's legacy "Add Expires headers" complaint maps to this check — modernCache-Controldirectives supersede theExpiresheader, and Cloudflare's Brotli (content-encoding: br) supersedes Pingdom's gzip check, so both legacy grades are false-positives against Hakuto's stack once_headersis correctly populated.
N. Security Headers (public/_headers)
Security scanners (securityheaders.com, Mozilla Observatory, Sucuri SiteCheck, the Cloudflare audit) consistently flag missing Content-Security-Policy and Permissions-Policy as findings. Two are blocking, two are usually flagged in the same report — all four belong on a /* rule in public/_headers so they ship on every page, not just the root.
Hakuto sites are fully static-prerendered with many inline scripts (GTM, Cookiebot, Plausible loader, marketing-pixel wrappers). A strict nonce-based CSP requires SSR and isn't compatible with this stack — the realistic policy keeps 'unsafe-inline' + https: for script-src / style-src and instead locks down the high-impact vectors. That's still a real improvement on "no CSP at all" and gets a passing grade on the common scanners.
- Critical —
Content-Security-Policyheader missing entirely.public/_headershas noContent-Security-Policy:line under any rule that matches every path (/*). Even a permissive CSP that blocksframe-ancestors,base-uri,object-src, andform-actionprovides meaningful clickjacking and base-tag-injection protection — having no CSP at all leaves these vectors wide open. - Critical — CSP present but missing high-impact directives. A
Content-Security-Policyline exists but doesn't include all four of:frame-ancestors 'none'(or'self'),base-uri 'self',object-src 'none',form-action(with at minimum'self'). These four are the directives that pay back even whenscript-src/style-srcare permissive — skipping them defeats the point of having a CSP on a Hakuto site. - Critical —
Permissions-Policyheader missing. NoPermissions-Policy:line in_headers. The header should at minimum disable features the site doesn't use —camera=(),microphone=(),geolocation=(),usb=(),magnetometer=(),accelerometer=(),payment=()(or=*if Paddle/Stripe checkout is used),publickey-credentials-get=(). Embedded-video features (autoplay,fullscreen,picture-in-picture,encrypted-media) should be*on sites with Vimeo/YouTube embeds. - Warning —
X-Content-Type-Options: nosniffmissing. Cheap header, eliminates MIME-sniffing-based XSS, flagged by every scanner. - Warning —
Referrer-Policymissing or weaker thanstrict-origin-when-cross-origin.no-referrer-when-downgrade(the browser default) leaks the full URL to cross-origin requests;strict-origin-when-cross-originis the recommended floor. - Warning — Security headers placed on a path-specific rule instead of
/*. If CSP / Permissions-Policy live under a rule like/(root only) or/blog/*instead of/*, they only ship on pages matching that prefix. Cloudflare Pages's_headersmerges across matching rules, so the fix is to put security headers in a/*block. - Warning — CSP omits a known third-party origin used by the site. If the project's
MarketingPixels.astro(or equivalent) loads from origins not covered by the CSP — e.g. CSP hasscript-src 'self' 'unsafe-inline'only, but the site loadshttps://www.googletagmanager.com,https://connect.facebook.net,https://consent.cookiebot.com,https://beacon-v2.helpscout.net,https://cdn.paddle.com,https://challenges.cloudflare.com, etc. — flag the gap. The pragmatic fix isscript-src 'self' 'unsafe-inline' 'unsafe-eval' https: blob:(allow any HTTPS script) rather than enumerating every CDN. - Pass —
_headershas a/*rule containing all four headers: aContent-Security-Policywithframe-ancestors,base-uri,object-src 'none', andform-actiondirectives; aPermissions-Policydisabling unused features;X-Content-Type-Options: nosniff; andReferrer-Policy: strict-origin-when-cross-origin(or stricter).
Verification tip for the user (not for the skill to run):
curl -I https://{domain}/on a deployed URL should show all four headers. Re-running the security scan after deploy should clear the original findings. The scaffold ships sensible defaults — checkscaffold/public/_headersin the hakuto repo for the canonical reference policy.
Output Format
Code Review Results for [scope]
================================
📊 Summary:
✅ Passed: X | ❌ Critical: X | ⚠️ Warnings: X | Files: X
Scope: [Whole project | src/pages/index.astro | Changed files: 3]
---
## Critical Issues (❌)
1. Bare <img> for local image — must use <Picture> with AVIF/WebP
File: src/pages/index.astro:124
Rule: CLAUDE.md → Image Optimization
2. `class=` used on shadcn <Button> (must be `className=`)
File: src/components/Hero.astro:42
Rule: CLAUDE.md → React/shadcn Components
3. Anchor href="#features" has no matching id in target page
File: src/components/Header.astro:18
Rule: CLAUDE.md → Quality Standards (anchor links)
---
## Warnings (⚠️)
1. <Picture> missing `widths` array (responsive sizes)
File: src/components/Hero.astro:88
2. Primary font family is "Inter" — flagged by Hakuto aesthetic guidelines
File: src/index.css:14
3. astro-favicons still configured with template default name "Hakuto"
File: astro.config.mjs:42
4. Internal link `/product` missing trailing slash — trailingSlash: "always" → 301 redirect
File: src/components/Header.astro:24 (+5 more in this file)
Rule: astro.config.mjs → trailingSlash
---
## Passed Checks (✅)
- @import 'tailwindcss' is first line in src/index.css
- All .astro pages have exactly one <h1>
- No `class=` misuse on shadcn components in scope
- experimental.fonts configured for JetBrains Mono + Instrument Sans
- imageService: "compile" + prerenderEnvironment: "node"
- No favicon sources in public/
- All internal links match the trailingSlash convention
- bun run check: clean over in-scope files
---
To fix issues, ask Claude:
- "Fix all critical code review issues"
- "Convert hero <img> to <Picture> in src/pages/index.astro"
- "Add aria-label to icon-only buttons in Header.astro"
Severity Rules
Critical (❌) — blocks shipping or causes a runtime/build failure:
@import 'tailwindcss'not first;tailwind.config.*still present.tsx/.jsxinsrc/pages/orsrc/layouts/class=on a React/shadcn component- Bare
<img>for an imported local image; missingalt @font-face/@importfor fonts in CSS; custom fonts withoutexperimental.fonts- Cloudflare
imageService: "passthrough"or missingprerenderEnvironment: "node" - Anchor
href="#x"with no matchingid - 0 or >1
<h1>on a page; broken heading hierarchy - Editable favicon source under
public/ - New
bun run checkerror introduced by in-scope files - Template placeholders (
SITE_NAME = "Hakuto", etc.) still present - Third-party tracking script (GTM / FB Pixel / Cookiebot / FirstPromoter / Hotjar / Clarity / Amplitude / HubSpot) loaded outside a deferred init wrapped on
load+ interaction public/_headersmissing/_astro/*rule withmax-age >= 31536000, immutableon a Cloudflare Workers ([assets]) projectpublic/_headersmissingContent-Security-Policyentirely, or CSP present but missing one offrame-ancestors/base-uri/object-src/form-actionpublic/_headersmissingPermissions-Policy
Warning (⚠️) — should fix but non-blocking:
- Custom tokens outside
@theme; CSS variables outside@layer base style={{}}on shadcn components;<Picture>missingformats/widthsloadingattribute missing or wrong for above/below-the-fold images- Above-the-fold image with
loading="eager"but nofetchpriority="high" - Hero
<h1>inherits a custom heading font with nomax-md:font-system-style mobile override (mobile LCP risk) - Generic font (Inter / Roboto / Arial) as primary
imageServiceunset;astro-faviconsstill on template defaults- Icon-only buttons without
aria-label - Internal link contradicting the
trailingSlashconvention (or 301-redirecting on Cloudflare static) — adds a redirect hop - Unused imports; pre-existing
bun run checkerrors - Lorem-ipsum / TODO copy left in pages
_headersmissing cache rules for favicons / manifests //pagefind/*_headersmissingX-Content-Type-Options: nosniff_headersReferrer-Policymissing or weaker thanstrict-origin-when-cross-origin- Security headers placed on a path-specific rule (e.g.
/) instead of/* - CSP omits a known third-party origin actually loaded by the site
- Google Ads (
AW-…) tag present withoutallow_google_signals: false— informational
Pass (✅) — meets the requirement.
Cross-cutting reminders (apply to every check above):
- Report-only — never edit files; never run
bun installorbun run build. The user runs follow-up prompts to fix. - Cite
file:linein every issue so the user can jump straight to it. - Don't false-positive on intentional patterns — external
<img>URLs (Unsplash etc.) are fine, only flag bare<img>for imported local images; HTML elements correctly useclass=, only flagclass=on capitalised React/shadcn tags; for trailing-slash, never flag external/mailto:/tel:links, the root/, fragments, or file paths (.xml,.txt,/.well-known/*), and readtrailingSlashbefore judging direction. - Stay in sync with CLAUDE.md — every check ties to a rule there; when rules change, update the corresponding category.
- Complements
seo-audit— that skill audits built HTML in_dist/; this one audits source insrc/. Run both for full coverage.
Error Handling
- Path not found → report error, stop.
- File unreadable → log a critical for that file, continue with the rest.
bun run checkfails to launch (bunnot on PATH) → single warning, continue with static checks.- Git scope requested but project is not a git repo → tell the user, fall back to whole-project scope only if they confirm.
Tool Usage
Read-only:
git diff --name-only HEAD # Changed-files scope
git status --short # Untracked/unstaged additions
bun run check # Type-check (run AT MOST once per review)
File inspection: use Read, Glob, Grep — never Edit/Write.