From hakuto
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".
How this skill is triggered — by the user, by Claude, or both
Slash command
/hakuto:code-reviewThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Audit Hakuto-built sites against the project's own CLAUDE.md rules.
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:
Parse the user prompt:
git diff --name-only HEAD and also include unstaged/untracked under src/ (git status --short). Restrict checks to those files.src/**/*.{astro,ts,tsx,css}, plus the project's astro.config.mjs, src/index.css, package.json, and tailwind.config.* if present.If the user names a file that doesn't exist, list nearby candidates and stop.
Always read these first — many checks depend on them:
package.json — confirm bun scripts, dependency versions, that tailwind.config.mjs is NOT present.astro.config.mjs — Cloudflare adapter options, experimental.fonts configuration, integrations.src/index.css — verify Tailwind v4 ordering and @theme / @layer base structure.src/layouts/Layout.astro (top ~80 lines) — <Font /> preloads, schema/SEO setup.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.
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.
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.
src/index.css, project root)@import 'tailwindcss' is not the first non-comment line of src/index.css.tailwind.config.mjs / tailwind.config.js / tailwind.config.ts exists at the project root (v4 ignores it — must be deleted).@theme { … } block.--foo: …) defined outside @layer base { … }.@import 'tailwindcss' first → @plugin 'tailwindcss-animate' → @theme → @layer base (the canonical order).src/pages/, src/layouts/).tsx or .jsx file under src/pages/ or src/layouts/ (CLAUDE.md: "Use .astro files only for pages and layouts")..tsx component imported into a page without a clear interactivity reason (CLAUDE.md prefers native JS over React for simple toggles/animations).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.
class= used on a React/shadcn component (must be className=).style={{ … }} used on a shadcn component (use Tailwind utility classes; for animation delays use delay-* or @theme tokens).class= — that is correct.For each .astro file:
import-ed (e.g. import hero from '@/assets/…') and rendered with a bare <img> tag. CLAUDE.md mandates <Picture> from astro:assets with formats={['avif','webp']} and widths={[800, 1200, 1920]} for local images.<Picture> / <Image> missing formats or widths.loading="eager", or a below-the-fold image without loading="lazy".<img> / <Picture> / <Image> missing the alt attribute (accessibility + SEO).<img> whose src is an external URL such as https://images.unsplash.com/… — CLAUDE.md explicitly allows this for placeholder/external imagery.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.
@font-face or @import url('https://fonts.googleapis.com/…') present in src/index.css (CLAUDE.md: "NEVER use @import or @font-face in CSS for custom fonts").index.css (--font-sans, --font-mono, etc.) but astro.config.mjs has no experimental.fonts array configuring them.<frontend_aesthetics> block explicitly rejects these.experimental.fonts, exposed as CSS variables in @theme, preloaded via <Font /> in Layout.astro.astro.config.mjs)If the project uses @astrojs/cloudflare:
imageService: "passthrough" — disables the image service entirely and breaks <Picture>/<Image> in dev and build.prerenderEnvironment: "node" — the default "workerd" fails prerendering with a 404 outside Cloudflare's infra.imageService not set at all (relying on adapter default).imageService: "compile" + prerenderEnvironment: "node".G1. Anchor fragments — for every href="#…" in .astro files:
id="…". CLAUDE.md: "When creating anchor links, ALWAYS create the corresponding id in the target element."id found.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 still 301-redirect to the slash form at the edge, so treat as the "always" case.
Warning — Internal link contradicts the trailingSlash convention. Each mismatch sends the browser through a 301 redirect 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 each file: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.
Per .astro page:
<h1> elements on the page (after composition — count includes <h1> inside imported components when statically obvious).<h1> → <h3>).alt.<button> / <a> (lucide-react <Icon> as the only child) without aria-label.<button> styled to look like a link when an <a> is semantically correct (or vice versa).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.
.svg, .png) is found in public/ rather than src/assets/. CLAUDE.md: public/ contains auto-generated files only; sources live in src/assets/.astro-favicons plugin block in astro.config.mjs is missing or still references the template default name (Hakuto, Site Name).src/assets/, plugin configured with project-specific name and short_name.Run only if scope includes .astro / .ts / .tsx:
bun run check reports a NEW error caused by an in-scope file (unused imports, type errors, implicit any in middleware/Vite plugin callbacks).bun run check reports pre-existing errors (list separately so the user can decide).import * as fs from "node:fs" with no fs. usage) — detect with a simple grep where possible.bun run check clean over the in-scope files.Generated sites must scrub the scaffold's placeholder strings before shipping:
Layout.astro still has SITE_NAME = "Hakuto" / SITE_DESCRIPTION = "…" defaults when the project is clearly customised.Lorem ipsum, Placeholder, TODO, [Your headline here]) present in any page.<title> is the literal string Astro or empty.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:
<h1> in src/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 a max-md:font-system class 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.
<h1> whose computed font resolves through --font-heading (or another --font-* token that maps to a custom family declared in experimental.fonts/fonts array), and the same element has no max-md:font-* / md:font-heading-style override.<script> whose src (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 OR window.load + small timeout, not loaded synchronously or with bare async/defer. Loading marketing pixels before LCP burns the main thread during the critical render and inflates LCP/TBT.
<script src="https://{tracking-domain}/…"> in Layout.astro or any always-included component, OR an inline script that calls document.createElement('script') with one of those URLs not inside an addEventListener("load", …) / ["click","keydown","touchstart","scroll"].forEach(…, { once:true }) / requestIdleCallback(…) wrapper. A bare setTimeout(load, N) on one pixel while others load inline is still flagged (inconsistent deferral).fire() closure that initializes all pixels, triggered by the first of (a) any of click/keydown/touchstart/scroll, (b) setTimeout(fire, 500) chained off the load event. See easyblognetworks/src/components/MarketingPixels.astro for a reference implementation.fetchpriority="high". A <Picture> or <Image> in the first section of a page using loading="eager" but with no fetchpriority="high" prop. The LCP element should be both eagerly loaded and high-priority so the browser fetches it before lower-priority subresources.google.si / localized Google TLD ping note. If the project includes a Google Ads conversion tag (id like AW-…) and the user hasn't opted out of cross-domain remarketing (allow_google_signals: false), Google's gtag fires a country-localized google.{tld} pixel in addition to google.com. Not a source-code defect — just surface this as an informational note when an AW- tag is present, pointing to the Google Ads → Conversion settings opt-out.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.
/_astro/* not configured for long-immutable cache. Project uses @astrojs/cloudflare AND wrangler.toml has an [assets] block AND public/_headers does not contain a /_astro/* rule whose Cache-Control includes both max-age >= 31536000 and immutable. Hashed Astro outputs change filename whenever content changes, so caching them for a year is always safe.public/_headers missing 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 sets max-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.astro-pagefind is in astro.config.mjs integrations AND _headers has no /pagefind/* rule. Pagefind regenerates each build; a 1-day cache (max-age=86400) is the conventional value._headers has explicit Cache-Control rules for /_astro/* (immutable+1y) and a max-age=86400 (1 day) cap on favicons/manifests//pagefind/*. HTML responses can keep Cloudflare's max-age=0 default — 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.
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.
Content-Security-Policy header missing entirely. public/_headers has no Content-Security-Policy: line under any rule that matches every path (/*). Even a permissive CSP that blocks frame-ancestors, base-uri, object-src, and form-action provides meaningful clickjacking and base-tag-injection protection — having no CSP at all leaves these vectors wide open.Content-Security-Policy line 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 when script-src/style-src are permissive — skipping them defeats the point of having a CSP on a Hakuto site.Permissions-Policy header missing. No Permissions-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.X-Content-Type-Options: nosniff missing. Cheap header, eliminates MIME-sniffing-based XSS, flagged by every scanner.Referrer-Policy missing or weaker than strict-origin-when-cross-origin. no-referrer-when-downgrade (the browser default) leaks the full URL to cross-origin requests; strict-origin-when-cross-origin is the recommended floor./*. 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 _headers merges across matching rules, so the fix is to put security headers in a /* block.MarketingPixels.astro (or equivalent) loads from origins not covered by the CSP — e.g. CSP has script-src 'self' 'unsafe-inline' only, but the site loads https://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 is script-src 'self' 'unsafe-inline' 'unsafe-eval' https: blob: (allow any HTTPS script) rather than enumerating every CDN._headers has a /* rule containing all four headers: a Content-Security-Policy with frame-ancestors, base-uri, object-src 'none', and form-action directives; a Permissions-Policy disabling unused features; X-Content-Type-Options: nosniff; and Referrer-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.
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"
Critical (❌) — blocks shipping or causes a runtime/build failure:
@import 'tailwindcss' not first; tailwind.config.* still present.tsx/.jsx in src/pages/ or src/layouts/class= on a React/shadcn component<img> for an imported local image; missing alt@font-face / @import for fonts in CSS; custom fonts without experimental.fontsimageService: "passthrough" or missing prerenderEnvironment: "node"href="#x" with no matching id<h1> on a page; broken heading hierarchypublic/bun run check error introduced by in-scope filesSITE_NAME = "Hakuto", etc.) still presentload + interactionpublic/_headers missing /_astro/* rule with max-age >= 31536000, immutable on a Cloudflare Workers ([assets]) projectpublic/_headers missing Content-Security-Policy entirely, or CSP present but missing one of frame-ancestors / base-uri / object-src / form-actionpublic/_headers missing Permissions-PolicyWarning (⚠️) — should fix but non-blocking:
@theme; CSS variables outside @layer basestyle={{}} on shadcn components; <Picture> missing formats/widthsloading attribute missing or wrong for above/below-the-fold imagesloading="eager" but no fetchpriority="high"<h1> inherits a custom heading font with no max-md:font-system-style mobile override (mobile LCP risk)imageService unset; astro-favicons still on template defaultsaria-labeltrailingSlash convention (or 301-redirecting on Cloudflare static) — adds a redirect hopbun run check errors_headers missing cache rules for favicons / manifests / /pagefind/*_headers missing X-Content-Type-Options: nosniff_headers Referrer-Policy missing or weaker than strict-origin-when-cross-origin/) instead of /*AW-…) tag present without allow_google_signals: false — informationalPass (✅) — meets the requirement.
Cross-cutting reminders (apply to every check above):
bun install or bun run build. The user runs follow-up prompts to fix.file:line in every issue so the user can jump straight to it.<img> URLs (Unsplash etc.) are fine, only flag bare <img> for imported local images; HTML elements correctly use class=, only flag class= 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 read trailingSlash before judging direction.seo-audit — that skill audits built HTML in _dist/; this one audits source in src/. Run both for full coverage.bun run check fails to launch (bun not on PATH) → single warning, continue with static checks.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.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub teamniteo/hakuto --plugin hakuto