vercel/next-forge
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·July 9, 2026
More findings
Verdict
Visual polish is real, staggered marquees, clean grids, disciplined CTA hierarchy, but accessibility is an afterthought everywhere it counts. The biggest risk is that entire sections and controls are invisible or unusable for keyboard and screen reader users.
Files Rams reviewed
apps/web/app/[locale]/components/header/index.tsx
docs/app/[lang]/(home)/components/apps/index.tsx
docs/app/[lang]/(home)/components/features/index.tsx
apps/app/app/(authenticated)/components/avatar-stack.tsx
apps/app/app/(authenticated)/components/collaboration-provider.tsx
apps/app/app/(authenticated)/components/cursors.tsx
apps/app/app/(authenticated)/components/header.tsx
apps/app/app/(authenticated)/components/sidebar.tsx
apps/web/app/[locale]/(home)/components/cases.tsx
apps/web/app/[locale]/(home)/components/cta.tsx
apps/web/app/[locale]/(home)/components/faq.tsx
apps/web/app/[locale]/(home)/components/features.tsx
apps/web/app/[locale]/(home)/components/hero.tsx
apps/web/app/[locale]/(home)/components/stats.tsx
apps/web/app/[locale]/(home)/components/testimonials.tsx
apps/web/app/[locale]/components/footer.tsx
apps/web/app/[locale]/components/header/language-switcher.tsx
apps/web/app/[locale]/contact/components/contact-form.tsx
docs/app/[lang]/(home)/components/hero.tsx
docs/app/[lang]/(home)/components/installer.tsx
apps/app/app/(authenticated)/layout.tsx
apps/app/app/(authenticated)/page.tsx
apps/app/app/(authenticated)/search/page.tsx
apps/app/app/(unauthenticated)/layout.tsx
apps/app/app/layout.tsx
apps/web/app/[locale]/(home)/page.tsx
apps/web/app/[locale]/blog/[slug]/page.tsx
apps/web/app/[locale]/blog/page.tsx
apps/web/app/[locale]/layout.tsx
apps/web/app/[locale]/legal/[slug]/page.tsx
Accessibility
Mobile menu toggle has no accessible name or expanded state
The mobile nav trigger `<Button onClick={() => setOpen(!isOpen)} variant="ghost">` renders only a `Menu`/`X` icon with no `aria-label`, no `aria-expanded`, and no `aria-controls` pointing at the panel it opens.
Why it matters
Screen reader users hear an unlabeled button with no indication it opens a menu or whether it's currently open, so the entire mobile navigation is effectively locked out for non-visual users.
Fix
Give icon-only toggle buttons an aria-label and expose expanded state with aria-expanded/aria-controls.
<Button onClick={() => setOpen(!isOpen)} variant="ghost">
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button><Button
onClick={() => setOpen(!isOpen)}
variant="ghost"
aria-label={isOpen ? "Close menu" : "Open menu"}
aria-expanded={isOpen}
aria-controls="mobile-nav-panel"
>
{isOpen ? <X className="h-5 w-5" aria-hidden="true" /> : <Menu className="h-5 w-5" aria-hidden="true" />}
</Button>aria-hidden on the entire section hides all 24 tool logos and the headline
The outer content wrapper carries `aria-hidden="true"` on the div that contains everything: the paragraph reading "Built with the best tools for modern developers" and all 24 `<span>` labels (BetterStack, Clerk, Stripe, React, etc.) inside the marquee rows.
Why it matters
Screen reader users get nothing from the `#features` section: no headline, no list of integrations. This is real content, not decoration, so hiding it removes a meaningful part of the page for assistive tech users while sighted users still see it.
Fix
Reserve aria-hidden for purely decorative wrappers; expose real content by removing it from the content container and only hiding truly non-informative background elements.
<div
aria-hidden="true"
className="relative h-full overflow-hidden bg-background py-24 ring-inset sm:py-32"
><div
className="relative h-full overflow-hidden bg-background py-24 ring-inset sm:py-32"
>Product screenshots ship empty alt text, hiding them from screen readers
Each `App` card renders `<Image alt="" src={app.image} />` for actual product screenshots (app.png, api.png, docs.png, etc.), not decorative graphics.
Why it matters
Screen reader users lose the visual proof for every app card ("app", "api", "email", "web", "docs", "studio", "storybook") and hear nothing where sighted users see the product in action.
Fix
Give every content image descriptive alt text naming what it shows.
<Image
alt=""
className="h-auto w-full overflow-hidden rounded-md border object-cover object-left shadow-sm"
src={app.image}
/><Image
alt={`Screenshot of the ${app.name} app`}
className="h-auto w-full overflow-hidden rounded-md border object-cover object-left shadow-sm"
src={app.image}
/>Mobile nav panel opens with no dialog semantics or escape route
The panel `<div className="container absolute top-20 right-0 ... ">` rendered when `isOpen` is true has no `role`, no focus moved into it on open, and no Escape-key handler to close it. It's driven purely by conditional render, not a dialog primitive.
Why it matters
Keyboard and screen reader users get no signal that a new navigation surface appeared, can't reliably navigate it, and have no keyboard path to dismiss it other than re-finding the toggle button.
Fix
Use an accessible disclosure/dialog pattern that names the region, moves focus in on open, and closes on Escape.
{isOpen && (
<div className="container absolute top-20 right-0 flex w-full flex-col gap-8 border-t bg-background py-4 shadow-lg">{isOpen && (
<div
id="mobile-nav-panel"
role="dialog"
aria-label="Mobile navigation"
className="container absolute top-20 right-0 flex w-full flex-col gap-8 border-t bg-background py-4 shadow-lg"
>UX
Self avatar is visually and semantically identical to collaborators
The current user's avatar renders through the same `PresenceAvatar` with the same tooltip pattern as everyone else: `<p>{info?.name ?? "Unknown"}</p>`. Nothing in the markup marks it as "you" versus another collaborator, and it sits at the end of the stack with identical size, ring, and fallback styling.
Why it matters
In a presence stack, telling your own avatar apart from teammates matters for orientation, especially once the list grows past 2-3 people. Right now a user has to hover every avatar and mentally match names to know which one is theirs.
Fix
Append a self-identifying suffix to the current user's tooltip label so self-identification doesn't require memorizing your own display name.
{self && <PresenceAvatar info={self.info} />}{self && (
<PresenceAvatar
info={
self.info ? { ...self.info, name: `${self.info.name} (You)` } : self.info
}
/>
)}Mention and user-resolution failures throw generic errors with no recovery path
`resolveUsers` and `resolveMentionSuggestions` both throw `new Error("Problem resolving users")` / `"Problem resolving mention suggestions"` on failure, with no fallback value or user-facing message.
Why it matters
When the users or search API call fails, the thrown error has to be caught somewhere upstream or it crashes the collaboration editor entirely, leaving the person mid-comment with no indication of what broke or how to retry.
Fix
Return an empty result with a visible inline error state instead of throwing, or ensure the thrown error is caught by a boundary that shows a retry action.
if ("error" in response) {
throw new Error("Problem resolving users");
}
return response.data;if ("error" in response) {
// Surface an empty list instead of throwing, so a lookup failure
// degrades gracefully rather than crashing the editor.
return [];
}
return response.data;Typography
Color
Spacing
Components
Motion
Craft
Working well
- The desktop actions row separates "Contact" (ghost), "Sign in" (outline), and "Sign up" (filled default) into three distinct visual weights. This is correct: the least committed action reads lightest and the primary conversion action "Sign up" carries the most visual weight, so users scan the row and immediately know which button matters most.
- External links in the mobile menu correctly set `rel="noopener noreferrer"` and `target="_blank"` conditionally based on `item.href.startsWith("http")`, which protects against reverse tabnabbing on external destinations like the docs link without penalizing internal links.
- Each logo in a row gets its own `[animation-delay]` and `[animation-duration]` value (e.g. BetterStack at -26s/30s vs Clerk at -8s/30s), so the marquee never falls into lockstep. This is the detail that keeps a repeating pattern from reading as a robotic template loop.
- The stacked avatars use `-space-x-1` with `ring-1 ring-background` on each `Avatar`: this is the correct way to overlap circular avatars: the ring punches a visible gap against the page background so overlapping edges stay legible instead of merging into a blob.
Score your own repo with Rams.
Free on public repos. Rams reviews every pull request for accessibility, color, type, spacing, components, motion, UX, and craft, and posts inline fixes.