Skip to content

feat(web): marketing page v2 — terminal hero + narrative scroll + analytics#20

Merged
Codehagen merged 28 commits into
mainfrom
feat/marketing-page-v2
Apr 20, 2026
Merged

feat(web): marketing page v2 — terminal hero + narrative scroll + analytics#20
Codehagen merged 28 commits into
mainfrom
feat/marketing-page-v2

Conversation

@Codehagen
Copy link
Copy Markdown
Owner

@Codehagen Codehagen commented Apr 19, 2026

Summary

Replaces the single-hero landing at / with a hybrid terminal hero + full narrative scroll that expresses the "shadcn for docs" positioning. Self-contained Supabase-native analytics. Header placeholder nav deleted as a prerequisite.

Marketing page v2 (app/(marketing)/page.tsx)

  • Hero: hybrid hero-six bones + hero-eight dual-CTA layout wrapping @magicui/terminal typing animation. Primary CTA is copy-to-clipboard pnpm dlx create-helpbase, secondary CTA is demo.helpbase.dev.
  • Comparator (3-col): Roll-your-own Next.js / Hosted SaaS / Helpbase, 8 feature rows (time-to-ship, ownership, MCP, llms.txt, deploy anywhere, cost, maintained, AI-grounding).
  • How-it-works (3 steps): Scaffold → Preview → Deploy.
  • Own-the-files block, AI-native bento (MCP / llms.txt / structured output / helpbase sync / hosted tier).
  • Demo cross-link with curl /llms.txt teaser.
  • Pricing, 3 open-core tiers (Self-host free, Hosted free, Hosted Pro coming soon).
  • Grouped FAQ (General / Hosted tier / MCP & AI), 9 questions.
  • Real footer with GitHub, docs, pricing, privacy links.

Header rewrite — killed placeholder NavigationMenu with ten fake links (#automation, Marketplace, Guides, Partnerships, etc.) and Continue button. Real nav: Docs, Pricing (anchor), GitHub. Right side: Sign in + Deploy now.

Supabase-native analytics

  • public.marketing_events table with RLS (anon insert-only, no read).
  • track edge function (deployed, v3): event allowlist, 2 KB metadata cap, session hash derived from cf-connecting-ip (not x-forwarded-for) + UA + day.
  • Client helper apps/web/lib/analytics.ts uses keepalive fetch and swallows all failures.

@workspace/ui CopyButton primitivenavigator.clipboard.writeText happy path with execCommand("copy") fallback for older Safari / non-secure contexts. onCopy callback fires only on success, data-copy-state attribute exposed for consumers.

Security fixes (pre-landing review)

  • cf-connecting-ip precedence in track handler — prevents session-hash forgery via spoofed X-Forwarded-For.
  • Scoped res.cloudinary.com image allowlist to /dohqjvu9k/** — prevents Next.js image optimizer from proxying arbitrary Cloudinary content.

Test Coverage

File Tests Coverage rationale
apps/web/lib/analytics.ts 7 SSR guard, missing-env guard, POST shape, path composition, sync-throw swallow, async-reject swallow
packages/ui/copy-button.tsx 8 clipboard happy, copiedLabel swap, state transition, execCommand fallback, error states, preventDefault short-circuit
supabase/functions/track/handler.ts 12 OPTIONS preflight, GET 405, bad JSON 400, non-object 400, event allowlist, session_hash determinism, cf-connecting-ip precedence (anti-spoof), XFF fallback, metadata cap, path cap, misconfig 500, insert error 500

Tests: 128 → 155 (+27 new). Full suite: 22 files, 155 tests passing.

Pre-Landing Review

Focused adversarial review ran on the full diff. Two real findings auto-fixed and re-tested:

  1. [P1] supabase/functions/track/handler.ts:88x-forwarded-for is client-spoofable; cf-connecting-ip now takes precedence for session_hash derivation. Anti-spoof test added.
  2. [P1] apps/web/next.config.mjs:19res.cloudinary.com allowlist was unscoped; added pathname: "/dohqjvu9k/**" so only the Tailark cloud account is proxyable through /_next/image.

Everything else clean: RLS correctly insert-only for anon, CopyButton DOM fallback cleans up its textarea, analytics env-var exposure is intentional (publishable anon key).

Plan Completion

Design doc: ~/.gstack/projects/help-center/christer-main-design-20260419-marketing-page.md (APPROVED)

  • PR-0 header rewrite (kills placeholder nav)
  • @magicui/terminal + 8 Tailark Pro blocks installed
  • CopyButton primitive with clipboard + execCommand fallback
  • Supabase migration marketing_events + RLS
  • Edge function track with SHA-256 session hash
  • Client analytics helper
  • Hero (hybrid six+eight, MagicUI Terminal, dual CTA, Draft A copy)
  • Comparator, How-it-works, Features, Bento, Demo, Pricing, FAQ, Footer — all adapted with helpbase copy
  • Page assembly + next.config.mjs image allowlist
  • [~] How-it-works shipped 3 steps instead of 4 (AI step folded into bento)
  • [~] Logo-cloud "built on" block dropped — footer names the stack instead
  • Lazy-load below-fold sections — deferred, v1.1
  • Bundle analysis — deferred, v1.1
  • E2E (install-copy, pricing render, reduced-motion) — deferred
  • A11y axe-core scan — deferred
  • Visual regression baseline — deferred
  • Pro dollar number — deferred (pricing TBD with first 10 teams)
  • Vegard walkthrough — post-merge assignment
  • demo.helpbase.dev polish — separate PR

Commits

  • 2e45d89 feat(web): rewrite header with real helpbase nav
  • 12bd686 chore(web): install @magicui/terminal + 8 Tailark Pro blocks
  • cba97a8 feat(web): marketing page v2 — hero terminal + narrative scroll + pricing
  • 115d18b chore(deps): lock hugeicons + radix-tooltip installed for tailark blocks
  • c0fb493 test(web): coverage for the three critical new surfaces
  • 22906e0 fix(sec): prefer cf-connecting-ip + scope cloudinary image allowlist
  • 23ba5e8 docs(changelog): unreleased — marketing page v2 + analytics + security fixes
  • 91bc639 test(web): tighten non-null assertions on mock.calls access

Test plan

  • pnpm test in apps/web: 155/155 passing
  • pnpm tsc --noEmit in apps/web: clean
  • pnpm lint: 0 errors on touched files
  • Dev server (pnpm dev) smoke test: / returns 200, hero + all sections render, no console errors
  • Supabase migration applied (marketing_events table live with RLS)
  • Edge function track deployed (v3, verify_jwt:false)
  • Post-merge: send Vegard the URL cold with the one-sentence walkthrough prompt; write observations to ~/.gstack/projects/help-center/vegard-walkthrough-{date}.md
  • Post-merge: polish pass on demo.helpbase.dev before driving marketing traffic

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Redesigned marketing landing (hero, comparator, how‑it‑works, AI bento, demo cross‑link, three‑tier pricing, grouped FAQs) and Supabase‑native analytics with edge tracking and client helper.
  • Visual & Design
    • New multi‑section footer, updated header navigation, particles, text‑scramble and many new illustrations.
  • UI Components
    • Added CopyButton primitive, Card system, charts, tooltips, terminal/typing and slider animations, code & map visuals.
  • Documentation
    • Updated core messaging to "The docs your AI tools can read".
  • Tests
    • New unit and integration tests for analytics, copy behavior, and edge handler.
  • Chores
    • Security tweaks: Cloudflare IP precedence and scoped Cloudinary allowlist.

Codehagen and others added 8 commits April 19, 2026 20:41
Kills placeholder nav (Automation/Scalability/Backup/Analytics dropdown,
Marketplace/Guides/API Integration/Partnerships items, Continue button
pointing to #). Replaces with real nav: Docs, Pricing (anchor), GitHub.
Primary CTA is "Deploy now" → /login. Secondary CTA is "Sign in".

Unblocks cold-visitor tests while the full marketing page rewrite is in
flight. Ship before any marketing traffic lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds @MagicUI registry to apps/web/components.json. Installs:
- @magicui/terminal (typing-animation hero primitive)
- @tailark-pro/comparator-7 (quick comparison vs roll-your-own vs hosted)
- @tailark-pro/how-it-works-3 (left-rail 4-step flow)
- @tailark-pro/features-1 (2-col feature with screenshot)
- @tailark-pro/bento-2 (3-cell AI-native bento)
- @tailark-pro/pricing-2 (3-col open-core tiers)
- @tailark-pro/faqs-3 (title-left grouped FAQ)
- @tailark-pro/footer-4 (product/resources/company/subscribe)
- @tailark-pro/logo-cloud-3 (built-on badges)

Raw Tailark block exports are kept so subsequent commits can wrap them
with helpbase copy. No page wiring yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cing

Replaces the single-hero landing with a hybrid terminal hero + full
narrative scroll that expresses the 'shadcn for docs' positioning.

Page flow: Hero → Comparator (vs roll-your-own vs hosted SaaS) →
HowItWorks (scaffold/preview/deploy) → FeaturesOwnIt → AI-native bento
(MCP, llms.txt, structured output, code-grounded sync, hosted tier) →
Demo cross-link → Pricing (3-tier open-core) → FAQs (grouped) → Footer.

Hero is a hybrid of hero-section/six bones (bevel frame, corner notch)
and hero-section/eight dual-CTA layout, wrapping MagicUI's Terminal
primitive for the typing animation. Primary CTA is a copy-to-clipboard
install command (`pnpm dlx create-helpbase`), secondary is the demo.

Supabase-native analytics: new `marketing_events` table (anon insert-only
via RLS), `track` edge function that hashes IP+UA+day into a session_hash
with no raw PII stored, and a client helper in apps/web/lib/analytics.ts
that uses keepalive fetch and swallows all errors so analytics never
break rendering.

Tailark blocks in use (via @tailark-pro registry):
  comparator-7, how-it-works-3, features-1, bento-2, pricing-2,
  faqs-3, footer-4. Each is edited in-place to carry helpbase copy
  instead of the shipped placeholder content.

CopyButton primitive lives in packages/ui with clipboard API +
execCommand fallback, so the hero CTA degrades gracefully on older
browsers and non-secure contexts.

Deletes the old hero-section.tsx (replaced by marketing/hero.tsx).
Whitelists avatars.githubusercontent.com and res.cloudinary.com in
next.config.mjs — needed for Tailark illustrations; explicit allowlist,
no wildcards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shadcn add for @tailark-pro/faqs-3 and adjacent blocks brought
@hugeicons/react, @hugeicons/core-free-icons, and @radix-ui/react-tooltip
as transitive needs. This lockfile change should have landed in the
registry-install commit; splitting it out so the ship workflow doesn't
leave pnpm-lock dirty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds unit tests for:
  - apps/web/lib/analytics.ts (7 tests): SSR guard, missing-env guard,
    POST shape (URL + apikey + keepalive), path/search composition,
    synchronous-throw swallow, async-reject swallow.
  - packages/ui copy-button (8 tests): clipboard happy path, copiedLabel
    swap, state transition copied→idle, execCommand fallback when the
    clipboard API rejects, error state on execCommand false / throw,
    onClick preventDefault short-circuit.
  - supabase/functions/track handler (11 tests): OPTIONS preflight,
    method gate (GET → 405), bad-JSON and non-object 400s, event
    allowlist (positive + negative), session_hash = sha256(ip|ua|day)
    with deterministic date, cf-connecting-ip fallback, metadata-size
    cap, path-length cap, missing client → 500, insert error → 500.

Refactors the Deno edge function so the pure handler lives in a new
handler.ts that vitest can import. index.ts becomes a thin Deno
runtime adapter (env + createClient + serve).

Deletes apps/web/components/logo-cloud.tsx which was an unreferenced
Tailark placeholder. The footer already names the built-on stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes from pre-landing review:

1. supabase/functions/track: prefer cf-connecting-ip over
   x-forwarded-for for the session_hash source. x-forwarded-for is
   client-spoofable — any caller can POST with an arbitrary
   X-Forwarded-For header and generate any session_hash they want.
   Cloudflare-backed Supabase Edge sets cf-connecting-ip as the
   authoritative real IP, so that becomes the primary source. XFF
   stays as fallback for local dev and tests. Redeployed track@v3.

2. apps/web/next.config.mjs: scope res.cloudinary.com to the single
   cloud account (/dohqjvu9k/**) that ships Tailark illustrations.
   Without the pathname constraint, any Cloudinary-hosted image could
   be proxied through /_next/image — moderate SSRF-lite plus quota
   abuse for arbitrary content.

Added test: "prefers cf-connecting-ip over x-forwarded-for (anti-spoof)"
to track-edge-handler.test.ts. Existing "falls back to x-forwarded-for
when cf-connecting-ip is absent" preserved so both branches are covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…y fixes

Names what landed on this branch under [Unreleased]:
  - marketing page v2 (terminal hero, dual-front comparator, how-it-works,
    AI-native bento, demo cross-link, 3-tier pricing, grouped FAQ, real footer)
  - Supabase-native analytics (marketing_events table + track edge function
    + client helper)
  - @workspace/ui CopyButton primitive
  - header rewrite (placeholder nav killed)
  - security fixes (cf-connecting-ip precedence, scoped cloudinary allowlist)
  - test coverage +26 and handler extraction for Deno→vitest testability

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TypeScript strict-mode flagged mock.calls[0] as possibly undefined. The
test flow guarantees at least one call by the assertion point, so a
non-null assertion is correct here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
helpbase Ready Ready Preview, Comment Apr 20, 2026 6:36am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 19, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR expands the marketing landing page into a multi-section layout, adds client-side marketing analytics and a Supabase edge tracking function with DB migrations, introduces many UI primitives/illustrations/SVGs, updates header/footer and templates, and adds tests and package dependencies.

Changes

Cohort / File(s) Summary
Marketing Landing Page
apps/web/app/(marketing)/page.tsx, apps/web/components/marketing/hero.tsx, apps/web/components/marketing/demo-cross-link.tsx
Replaced single Hero with full-page layout: Header, Footer, and multiple marketing sections (hero, comparator, how-it-works, bento, demo cross-link, pricing, FAQs); some sections loaded via dynamic imports.
Marketing Section Components
apps/web/components/comparator-7.tsx, apps/web/components/how-it-works-3.tsx, apps/web/components/bento-2.tsx, apps/web/components/pricing.tsx, apps/web/components/faqs-3.tsx, apps/web/components/features-1.tsx
Added new marketing components for comparison table, how-it-works steps, AI-native bento grid, tiered pricing (with analytics events), grouped FAQs, and feature highlight cards.
Header & Footer
apps/web/components/header.tsx, apps/web/components/footer.tsx, apps/web/components/hero-section.tsx, apps/web/components/logo-cloud.tsx
Simplified header (flat NAV_LINKS, updated CTAs), redesigned footer into grouped links with release CTA, removed legacy HeroSection and LogoCloud components (deleted).
Analytics Infra (client + edge + DB)
apps/web/lib/analytics.ts, supabase/functions/track/handler.ts, supabase/functions/track/index.ts, supabase/migrations/20260419000000_marketing_events_v1.sql, supabase/migrations/20260419000100_marketing_events_drop_anon_insert.sql
Added client track() helper, Supabase edge handler with ALLOWED_EVENTS, session_hash derivation, metadata/path normalization and size caps, insert client wiring, initial marketing_events table migration and follow-up migration removing anon insert policy.
Analytics & Edge Tests
apps/web/test/analytics.test.ts, apps/web/test/track-edge-handler.test.ts
New Vitest suites: client-side track() behavior (fetch headers, keepalive, resilience) and handler-level tests (allowlist, session hash, metadata/path normalization, error paths).
UI Primitives
apps/web/components/ui/card.tsx, apps/web/components/ui/chart.tsx, apps/web/components/ui/tooltip.tsx, apps/web/components/ui/input.tsx, apps/web/components/ui/label.tsx, apps/web/components/ui/infinite-slider.tsx, apps/web/components/ui/terminal.tsx, apps/web/components/ui/text-scramble.tsx
New reusable UI primitives: Card system, Recharts-integrated ChartContainer/tooltip/legend/style, Tooltip wrappers, Input/Label, InfiniteSlider, Terminal animation primitives, TextScramble.
Illustrations & Visuals
apps/web/components/ui/illustrations/*, apps/web/components/illustrations/*, apps/web/components/illustrations/...
Many new illustration components (chart/code/document/map/notification/reply/visualization, models, agent-tasks, document-analysis, flow, workflow, uptime, preview URL card, install-command preview, etc.) used across marketing sections.
SVG Icons
apps/web/components/ui/svgs/*
Added SVG icon components for model vendors and tools (OpenAI, Mistral, Cohere, Perplexity, Qwen, Deepseek), VS Code, Firebase, etc.
Utilities & Misc UI
apps/web/components/code-block.tsx, apps/web/components/particles.tsx, apps/web/components/map.tsx, apps/web/components/const.ts, apps/web/components/logo.tsx
Added a Shiki-based CodeBlock with in-memory cache, tsparticles wrapper, dotted-map-based Map, avatar constants, and logo components (Logo/LogoIcon with gradient/uniColor).
Copy Button (UI lib)
packages/ui/src/components/copy-button.tsx, apps/web/test/copy-button.test.tsx
New CopyButton primitive with clipboard API + execCommand fallback, copy state and onCopy callback; comprehensive tests cover happy path, fallback, error states, and onClick prevention.
Config, Packages & Images
apps/web/components.json, apps/web/package.json, apps/web/next.config.mjs
Added @magicui registry entry; new dependencies (HugeIcons, Radix tooltip, tsparticles, Recharts, dotted-map, hast-util-to-jsx-runtime, react-use-measure, etc.); extended Next Image remotePatterns for GitHub avatars and scoped Cloudinary path.
Template & Registry Sync
packages/create-helpbase/templates/..., registry/helpbase/components/...
Synchronized UI primitives and footer/logo across templates/registry; updated landing hero copy in templates from “AI-native knowledge layer” to “The docs your AI tools can read.”; adjusted footer/content transforms in sync script.
Scripts & Tests tweaks
scripts/smoke-deploy.sh, scripts/sync-templates.mjs, apps/web/app/(main)/docs/page.tsx, apps/web/test/middleware-subdomain.test.ts, CHANGELOG.md
Minor comment edits, expanded sync exclusions for marketing-only components, docs hero copy tweak, updated subdomain test fixture (vegard→acme), and added changelog entries.

Sequence Diagram

sequenceDiagram
    participant Client as Web Client
    participant AnalyticsLib as track()
    participant EdgeFn as Edge Function<br/>/functions/v1/track
    participant ServiceRole as Supabase<br/>service_role
    participant DB as PostgreSQL<br/>marketing_events

    Client->>AnalyticsLib: track("event", metadata)
    AnalyticsLib->>AnalyticsLib: ensure browser + env vars
    AnalyticsLib->>EdgeFn: POST /functions/v1/track (event,path,metadata)
    EdgeFn->>EdgeFn: validate event against ALLOWED_EVENTS
    EdgeFn->>EdgeFn: normalize path & metadata, derive ip/ua/day
    EdgeFn->>EdgeFn: sha256(ip|ua|day) => session_hash
    EdgeFn->>ServiceRole: create client (service role key)
    EdgeFn->>DB: INSERT marketing_events (event,session_hash,path,metadata)
    DB-->>EdgeFn: success / error
    EdgeFn-->>AnalyticsLib: 204 or 4xx/5xx response
    AnalyticsLib->>Client: fire-and-forget (no thrown errors)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through code and left a trail of blooms,

Hero, bento, charts — new sections in these rooms.
A tiny track function hums, counting curious feet,
Buttons copy magic, illustrations make it sweet.
Hop on, new pages: helpbase grows complete!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/marketing-page-v2

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (15)
apps/web/components/ui/illustrations/notification-illustration.tsx-26-26 (1)

26-26: ⚠️ Potential issue | 🟡 Minor

Minor: inconsistent spacing in size label.

"29 KB / 120KB" mixes spaced and unspaced units. Consider "29 KB / 120 KB" for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/notification-illustration.tsx` at line
26, Update the size label string in the NotificationIllustration component so
unit spacing is consistent: change the text node currently rendering "29 KB /
120KB" (the div with className "text-muted-foreground text-xs") to "29 KB / 120
KB" so both sizes use a space before "KB".
apps/web/components/ui/svgs/vs-code.tsx-11-128 (1)

11-128: ⚠️ Potential issue | 🟡 Minor

Make SVG definition IDs instance-safe.

The hardcoded IDs a, b, c, and d are document-scoped. This component has the same ID collision vulnerability found in other SVG components like Gemini and OpenAI, which are already rendered together in product-illustration.tsx. When multiple SVGs with the same IDs are in the DOM, mask/filter/gradient references resolve ambiguously.

♻️ Proposed fix using per-instance IDs
-import type { SVGProps } from 'react'
+import { useId, type SVGProps } from 'react'
 
-export const VisualStudioCode = (props: SVGProps<SVGSVGElement>) => (
+export const VisualStudioCode = (props: SVGProps<SVGSVGElement>) => {
+    const idPrefix = useId().replace(/:/g, '')
+    const maskId = `${idPrefix}-vscode-mask`
+    const filterBId = `${idPrefix}-vscode-filter-b`
+    const filterCId = `${idPrefix}-vscode-filter-c`
+    const gradientId = `${idPrefix}-vscode-gradient`
+
+    return (
     <svg
         xmlns="http://www.w3.org/2000/svg"
         fill="none"
         viewBox="0 0 100 100"
         width="1em"
         height="1em"
         {...props}>
         <mask
-            id="a"
+            id={maskId}
             width={100}
             height={100}
             x={0}
             y={0}
             mask-type="alpha"
             maskUnits="userSpaceOnUse">
@@
-        <g mask="url(`#a`)">
+        <g mask={`url(#${maskId})`}>
@@
-            <g filter="url(`#b`)">
+            <g filter={`url(#${filterBId})`}>
@@
-            <g filter="url(`#c`)">
+            <g filter={`url(#${filterCId})`}>
@@
-                fill="url(`#d`)"
+                fill={`url(#${gradientId})`}
@@
             <filter
-                id="b"
+                id={filterBId}
@@
             <filter
-                id="c"
+                id={filterCId}
@@
             <linearGradient
-                id="d"
+                id={gradientId}
@@
         </defs>
     </svg>
-)
+    )
+}

Consider applying the same fix pattern to gemini.tsx and open-ai.tsx, which have the same issue and are actively used together.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/svgs/vs-code.tsx` around lines 11 - 128, The SVG uses
hardcoded IDs "a", "b", "c", and "d" (used in mask="url(`#a`)", filter="url(`#b`)" /
"url(`#c`)" and fill="url(`#d`)"), which causes DOM ID collisions when multiple
instances are rendered; update the component to generate per-instance unique IDs
(e.g., with React's useId or a unique suffix prop), replace the id attributes
for mask/filter/linearGradient (ids "a","b","c","d") with the unique IDs, and
update all their references (mask="url(#...)", filter="url(#...)",
fill="url(#...)") to use the generated IDs so each VsCode SVG instance is
isolated.
apps/web/components/code-block.tsx-5-6 (1)

5-6: ⚠️ Potential issue | 🟡 Minor

Explicitly import React types to fix incorrect React.CSSProperties reference.

JSX should be imported as a type, and React.CSSProperties on line 86 is used without importing React, which is incorrect. Extract these as explicit type imports to resolve the issue.

Proposed fix
-import { JSX, useLayoutEffect, useState } from 'react'
+import type { CSSProperties, JSX } from 'react'
+import { useLayoutEffect, useState } from 'react'
@@
-            style={{ '--pre-max-height': `${maxHeight}px` } as React.CSSProperties}>
+            style={{ '--pre-max-height': `${maxHeight}px` } as CSSProperties}>

Also applies to: lines 83–86.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/code-block.tsx` around lines 5 - 6, The file imports JSX
and React runtime without bringing in React types — change the imports to
explicitly import types from react (e.g., add "import type { JSX, CSSProperties
} from 'react'") and replace any usage of "React.CSSProperties" with the
imported "CSSProperties" type; update the top-level imports in code-block.tsx
and the type references around the style/prop declarations (the CSSProperties
usage on the block component around lines that reference React.CSSProperties) so
the type is resolved without importing the entire React namespace.
packages/ui/src/components/copy-button.tsx-92-105 (1)

92-105: ⚠️ Potential issue | 🟡 Minor

Minor a11y nit: aria-live on an interactive element.

aria-live="polite" is typically applied to a non-interactive status region, not the button itself. Putting it on the <Button> means some screen readers will announce the entire button (label + content) on every state swap, rather than just the status change. Consider rendering a visually-hidden live region as a sibling and keeping the button itself free of aria-live, or using role="status" on a child that contains only the transient "Copied" text.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ui/src/components/copy-button.tsx` around lines 92 - 105, Remove
aria-live from the interactive Button and instead render a separate
visually-hidden status region that announces changes to the copy state;
specifically, keep the Button (component named Button in copy-button.tsx) free
of aria-live and update the JSX that currently uses state, copiedLabel and
children so that when state changes you update a sibling element (e.g., a span
with role="status" and visually-hidden class) to contain the transient text
(copiedLabel or an empty string), or alternatively add role="status" to a
non-interactive child element that only contains the "Copied" status; ensure
handleClick still updates state and the status region reflects that change while
Button no longer has aria-live.
apps/web/components/ui/chart.tsx-84-115 (1)

84-115: ⚠️ Potential issue | 🟡 Minor

Defensive note on ChartStyle CSS injection surface.

This is the standard shadcn pattern and safe as long as config keys and color values come from developer code (which is the case today). If that ever changes — e.g., config derived from API responses or URL params — the interpolated key and color could break out of the --color-… : …; declaration. Consider either documenting that ChartConfig must be developer-authored, or sanitising keys/values (e.g., /^[a-zA-Z0-9_-]+$/ for keys and rejecting ;/} in color values) to keep that guarantee enforceable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/chart.tsx` around lines 84 - 115, The ChartStyle
component injects CSS using ChartConfig values (see ChartStyle, ChartConfig,
THEMES, id, colorConfig, itemConfig) and currently trusts config keys and color
strings; add defensive validation to reject or sanitize unsafe inputs before
building the CSS: ensure each key matches a strict whitelist regex (e.g.
/^[A-Za-z0-9_-]+$/) and normalize/escape or reject any color value containing
characters that could break CSS (e.g. disallow ';', '}', or control chars),
falling back to a safe default or omitting the variable; alternatively, enforce
this at construction time by validating ChartConfig and throwing or logging on
invalid entries so only developer-authored configs are allowed.
apps/web/components/ui/illustrations/monitoring-barchart-illustration.tsx-20-69 (1)

20-69: ⚠️ Potential issue | 🟡 Minor

Months are out of order and chartConfig colors are never applied.

Two visible bugs in the illustration:

  1. chartData is ordered May, June, January, February, March, April. Recharts renders bars in array order, so under the "January - June 2024" caption the chart will display them in the wrong sequence. Reorder to Jan → Jun.
  2. chartConfig.mobile.color / chartConfig.desktop.color feed --color-mobile / --color-desktop via ChartStyle, but the <Bar> fill values use unrelated CSS vars (var(--color-emerald-400) and var(--color-indigo-500)). As a result the configured colors are dead, and the tooltip's indicator (which reads item.color from the bar's fill) won't match whatever theme the consumer configures. Either reference the config vars from fill, or drop color from chartConfig.
🐛 Proposed fix
 const chartData = [
-    { month: 'May', desktop: 156, mobile: 224, tablet: 180 },
-    { month: 'June', desktop: 156, mobile: 800, tablet: 190 },
-    { month: 'January', desktop: 126, mobile: 252, tablet: 175 },
-    { month: 'February', desktop: 205, mobile: 410, tablet: 300 },
-    { month: 'March', desktop: 200, mobile: 126, tablet: 150 },
-    { month: 'April', desktop: 400, mobile: 800, tablet: 450 },
+    { month: 'January', desktop: 126, mobile: 252, tablet: 175 },
+    { month: 'February', desktop: 205, mobile: 410, tablet: 300 },
+    { month: 'March', desktop: 200, mobile: 126, tablet: 150 },
+    { month: 'April', desktop: 400, mobile: 800, tablet: 450 },
+    { month: 'May', desktop: 156, mobile: 224, tablet: 180 },
+    { month: 'June', desktop: 156, mobile: 800, tablet: 190 },
 ]
@@
-                        <Bar
-                            radius={4}
-                            dataKey="mobile"
-                            fill="var(--color-emerald-400)"
-                        />
-                        <Bar
-                            radius={4}
-                            dataKey="desktop"
-                            fill="var(--color-indigo-500)"
-                        />
+                        <Bar
+                            radius={4}
+                            dataKey="mobile"
+                            fill="var(--color-mobile)"
+                        />
+                        <Bar
+                            radius={4}
+                            dataKey="desktop"
+                            fill="var(--color-desktop)"
+                        />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/monitoring-barchart-illustration.tsx`
around lines 20 - 69, chartData is out of chronological order and the Bar
components ignore the theme-managed colors in chartConfig; reorder the chartData
array to January → February → March → April → May → June and update the <Bar>
fills in MonitoringBarChart to use the CSS vars provided by ChartStyle (e.g.
replace "var(--color-emerald-400)" with "var(--color-mobile)" and
"var(--color-indigo-500)" with "var(--color-desktop)") so the
chartConfig.mobile.color and chartConfig.desktop.color are actually applied
(references: chartData, MonitoringBarChart, chartConfig, and the <Bar>
components).
apps/web/components/ui/illustrations/code-illustration.tsx-24-42 (1)

24-42: ⚠️ Potential issue | 🟡 Minor

theme prop is effectively a no-op.

CodeBlock accepts a theme prop but its highlight() hardcodes themes: { light: 'github-light', dark: 'vesper' } (see apps/web/components/code-block.tsx:20-49) and never passes the prop through. Callers of CodeIllustration that pass a non-default theme will get the same rendered colors, which is misleading. Either drop the prop here (and rely on the dark/light theme the Shiki pipeline already emits), or thread a real theme through CodeBlock.highlight.

♻️ Drop the unused theme prop
-export const CodeIllustration = ({ theme }: { theme?: string }) => (
+export const CodeIllustration = () => (
     <div
         aria-hidden
         className="bg-illustration ring-border-illustration shadow-black/6.5 h-fit max-w-[calc(100vw-6rem)] overflow-hidden rounded-2xl border border-transparent text-sm shadow-md ring-1">
@@
         <CodeBlock
             code={PHP_CODE}
             lang="php"
             maxHeight={300}
-            theme={theme || 'github-light'}
             lineNumbers
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/code-illustration.tsx` around lines 24 -
42, The CodeIllustration component exposes a theme prop that does nothing
because CodeBlock internally ignores it (CodeBlock.highlight hardcodes themes).
Fix by either removing the theme prop from CodeIllustration and callers (delete
the theme param and the theme={...} passed to <CodeBlock>) or update CodeBlock
(specifically the highlight function and where CodeBlock consumes props) to
accept and use a theme prop (e.g., pass the incoming theme through to highlight
instead of always using themes: { light: 'github-light', dark: 'vesper' });
update references to CodeBlock and CodeBlock.highlight accordingly so a
non-default theme passed into CodeIllustration is honored.
apps/web/components/ui/illustrations/document-illustration.tsx-3-3 (1)

3-3: ⚠️ Potential issue | 🟡 Minor

Fix the exported component typo before it becomes API surface.

DocumentIllustation appears to be missing the r in Illustration. Since this is a new export, rename it now and update the import sites.

✏️ Proposed rename
-export const DocumentIllustation = () => {
+export const DocumentIllustration = () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/document-illustration.tsx` at line 3,
The exported component name DocumentIllustation has a typo; rename the component
and its export to DocumentIllustration (fixing the missing "r") and update all
import sites accordingly (search for DocumentIllustation usages and replace with
DocumentIllustration), ensuring the component function/const name, default/named
export, and any prop type references are updated consistently to avoid breaking
the API surface.
apps/web/next.config.mjs-16-18 (1)

16-18: ⚠️ Potential issue | 🟡 Minor

Scope the GitHub avatar allowlist to the used path.

Line 18 currently allows every path on avatars.githubusercontent.com. Next.js treats an omitted pathname as a wildcard; the allowlist should be narrowed to /u/** to match actual usage and reduce image-proxy abuse surface.

Proposed change
-      { protocol: "https", hostname: "avatars.githubusercontent.com" },
+      {
+        protocol: "https",
+        hostname: "avatars.githubusercontent.com",
+        pathname: "/u/**",
+      },

All current avatar URLs in the codebase follow the /u/<id>?v=4 pattern, so this restriction is safe.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/next.config.mjs` around lines 16 - 18, The remote image allowlist
entry for GitHub avatars currently permits all paths on
avatars.githubusercontent.com; update the remote pattern used in next.config.mjs
(the object with protocol: "https", hostname: "avatars.githubusercontent.com")
to include a restrictive pathname of "/u/**" so only avatar paths like
/u/<id>?v=4 are allowed, reducing the image-proxy attack surface.
apps/web/components/particles.tsx-145-145 (1)

145-145: ⚠️ Potential issue | 🟡 Minor

Stale inline comment.

The comment says "Set move direction to right" but the value is MoveDirection.none. Either drop the comment or correct it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/particles.tsx` at line 145, The inline comment on the
move direction is stale: it says "Set move direction to right" but the code sets
direction: MoveDirection.none. Update the comment to accurately reflect the
value or remove it; locate the direction property (direction:
MoveDirection.none) in the particles configuration (in components/particles.tsx)
and either change the comment to something like "No movement" or delete the
comment entirely so it matches MoveDirection.none.
apps/web/lib/analytics.ts-1-10 (1)

1-10: ⚠️ Potential issue | 🟡 Minor

Docstring points to the wrong file.

Line 9 references supabase/functions/track/index.ts, but per the PR summary the handler was extracted to supabase/functions/track/handler.ts so vitest can exercise it directly. Update the reference so future readers land on the actual logic.

✏️ Suggested fix
- * See supabase/functions/track/index.ts for the server side.
+ * See supabase/functions/track/handler.ts for the server side.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/analytics.ts` around lines 1 - 10, The docstring in
apps/web/lib/analytics.ts incorrectly references the server-side file
`supabase/functions/track/index.ts`; update that reference to
`supabase/functions/track/handler.ts` so readers are directed to the extracted
handler implementation (i.e., change the path string in the top-of-file comment
that mentions the server side to point to handler.ts).
CHANGELOG.md-72-75 (1)

72-75: ⚠️ Potential issue | 🟡 Minor

Test count arithmetic is off by one.

The entry says "Test coverage + 26" but the three enumerated suites add up to 7 + 8 + 12 = 27, which also matches the PR objective's 128 → 155 delta.

✏️ Suggested fix
-- **Test coverage + 26.** Net-new tests: `apps/web/test/analytics.test.ts`
+- **Test coverage + 27.** Net-new tests: `apps/web/test/analytics.test.ts`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 72 - 75, The changelog test-count is incorrect:
the three test suites apps/web/test/analytics.test.ts (7),
apps/web/test/copy-button.test.tsx (8), and
apps/web/test/track-edge-handler.test.ts (12) sum to 27 not 26; update the line
that reads "**Test coverage + 26.**" to "**Test coverage + 27.**" so the stated
delta matches the enumerated counts (and the PR objective 128 → 155).
apps/web/components/particles.tsx-18-20 (1)

18-20: ⚠️ Potential issue | 🟡 Minor

Remove console.log debug artifact.

particlesLoaded currently just logs the container to the devtools console on every mount. This will fire on every page visit in production.

🧹 Proposed fix
-    const particlesLoaded = async (container?: Container): Promise<void> => {
-        console.log(container)
-    }
+    const particlesLoaded = async (_container?: Container): Promise<void> => {
+        // no-op: reserved for future telemetry
+    }

Or drop the prop entirely from the <Particles> usage if not needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/particles.tsx` around lines 18 - 20, The particlesLoaded
handler currently contains a console.log debug statement; remove the console.log
call from the particlesLoaded function (const particlesLoaded = async
(container?: Container): Promise<void> => { ... }) or, if the handler isn't used
elsewhere, remove the particlesLoaded function and stop passing it into the
<Particles> component to avoid logging on every mount in production. Ensure any
remaining code still compiles by deleting the import/type usage only if no other
references to Container or particlesLoaded exist.
apps/web/components/ui/illustrations/scan-illustration.tsx-15-28 (1)

15-28: ⚠️ Potential issue | 🟡 Minor

Inner hideTimer is never cleaned up on unmount.

The return () => clearTimeout(hideTimer) at Line 24 is returned from the setTimeout callback (where return values are ignored), not from the effect. If the component unmounts in the 100 ms window between the outer timer firing and the inner timer firing, setShow(false) / setShowName(true) will run on an unmounted component.

🛠 Proposed fix
     useEffect(() => {
-        const timer = setTimeout(() => {
+        let hideTimer: ReturnType<typeof setTimeout> | undefined
+        const timer = setTimeout(() => {
             setShow(true)
-
-            const hideTimer = setTimeout(() => {
+            hideTimer = setTimeout(() => {
                 setShow(false)
                 setShowName(true)
             }, 100)
-
-            return () => clearTimeout(hideTimer)
         }, 4000)
-
-        return () => clearTimeout(timer)
+        return () => {
+            clearTimeout(timer)
+            if (hideTimer) clearTimeout(hideTimer)
+        }
     }, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/scan-illustration.tsx` around lines 15 -
28, The inner hideTimer created inside the setTimeout callback is not reachable
by the effect cleanup because the callback's return is ignored; declare a
variable hideTimer in the outer scope of the useEffect (alongside timer), assign
it inside the outer timeout callback when creating the inner setTimeout that
calls setShow(false) and setShowName(true), and then update the effect's cleanup
to clear both timer and hideTimer (if set) so both timeouts are cancelled on
unmount; reference useEffect, timer, hideTimer, setShow, and setShowName when
making the change.
apps/web/components/ui/terminal.tsx-131-133 (1)

131-133: ⚠️ Potential issue | 🟡 Minor

Error message is truncated — no value after Received:.

The throw runs when children isn't a string, but the message ends in "Received:" with nothing appended, so the dev sees a dangling label. Include the actual type (or the value).

🐛 Proposed fix
-  if (typeof children !== "string") {
-    throw new Error("TypingAnimation: children must be a string. Received:")
-  }
+  if (typeof children !== "string") {
+    throw new Error(
+      `TypingAnimation: children must be a string. Received: ${typeof children}`,
+    )
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/terminal.tsx` around lines 131 - 133, The error thrown
in TypingAnimation when children isn't a string currently ends with "Received:"
and omits the actual value; change the throw in the children type check to
include the offending value or its type (e.g., append JSON.stringify(children)
or typeof children) so the message reads like "TypingAnimation: children must be
a string. Received: <type|value>"—update the check that references children in
the TypingAnimation component to include that information.
🧹 Nitpick comments (15)
apps/web/components/ui/illustrations/reply-illustration.tsx (1)

8-8: Remove the duplicate shadow utility.

shadow-black/6.5 is listed twice; keeping one copy is enough and makes the class list easier to scan.

Proposed cleanup
-            className={cn('bg-illustration ring-border-illustration shadow-black/6.5 shadow-black/6.5 mt-12 flex origin-bottom flex-col space-y-4 rounded-2xl border border-transparent px-4 pb-2 pt-4 shadow-md ring-1 transition-all duration-300', className)}>
+            className={cn('bg-illustration ring-border-illustration shadow-black/6.5 mt-12 flex origin-bottom flex-col space-y-4 rounded-2xl border border-transparent px-4 pb-2 pt-4 shadow-md ring-1 transition-all duration-300', className)}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/reply-illustration.tsx` at line 8, The
class list passed into the cn call in the ReplyIllustration component contains a
duplicated "shadow-black/6.5" utility; remove the extra occurrence so the
className string (in the cn invocation that builds the wrapper's className) only
includes "shadow-black/6.5" once, keeping all other utilities and the dynamic
className prop intact.
apps/web/components/code-block.tsx (1)

51-81: Either implement or remove the inert theme and lineNumbers props.

lineNumbers is never read, and theme only retriggers the effect while highlight() still hardcodes themes and uses a cache key that ignores theme. Callers can pass these props but cannot affect the rendered output.

♻️ Proposed cleanup if these are not supported yet
 type Props = {
     code: string | null
     lang: BundledLanguage
     initial?: JSX.Element
     preHighlighted?: JSX.Element | null
     maxHeight?: number
     className?: string
-    theme?: string
-    lineNumbers?: boolean // ← added
 }
 
-export default function CodeBlock({ code, lang, initial, maxHeight=940, preHighlighted, theme, className }: Props) {
+export default function CodeBlock({ code, lang, initial, maxHeight=940, preHighlighted, className }: Props) {
@@
-    }, [code, lang, theme, preHighlighted])
+    }, [code, lang, preHighlighted])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/code-block.tsx` around lines 51 - 81, The Props include
inert props: theme and lineNumbers; either remove them from the Props type and
the CodeBlock signature (and from the useLayoutEffect dependency list) and
delete any callers that pass them, or implement them: update highlight(code,
lang) to accept a theme param and include theme in its cache key and
highlighting logic, ensure CodeBlock passes theme into highlight and re-renders
correctly (keep theme in the effect deps), and use the lineNumbers prop inside
CodeBlock to render line number gutter/styling (or add a wrapper that toggles a
CSS class for line numbers) so callers can actually control both appearance and
numbering; references: Props, CodeBlock, highlight(), preHighlighted, theme,
lineNumbers.
apps/web/components/ui/illustrations/monitoring-barchart-illustration.tsx (1)

5-27: Dead tablet series.

tablet is declared in chartConfig and populated in every chartData row, but no <Bar dataKey="tablet"> is rendered. Either drop it from the config/data, or add the third bar if it was intended.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/monitoring-barchart-illustration.tsx`
around lines 5 - 27, chartConfig defines a "tablet" series and chartData
populates tablet values, but the JSX only renders Bars for "desktop" and
"mobile"; update the component to render a third <Bar> for the "tablet" dataKey
(use chartConfig.tablet.color for fill and the same stack/props used by the
other Bars) so the tablet series is actually displayed; alternatively, if the
tablet series is not needed, remove "tablet" from chartConfig and from every
chartData entry to keep config/data consistent.
apps/web/components/marketing/hero.tsx (1)

127-128: Stale comment referencing <BuiltOn />.

The comment mentions a logo cloud slot handled by <BuiltOn />, but no such component is rendered here. Per the PR notes, the placeholder logo cloud was removed — please clean up this stale comment to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/marketing/hero.tsx` around lines 127 - 128, Remove or
update the stale inline comment that references <BuiltOn /> in the Hero
component (the comment just above the closing div for the bottom strip); either
delete the comment entirely or replace it with an accurate note reflecting that
the logo cloud placeholder was removed so future readers aren't misled.
supabase/functions/track/index.ts (1)

6-21: Hoist Supabase client creation out of the per-request handler.

Deno.env.get and createClient run on every invocation. Env vars are static for the function lifetime, and the Supabase client is safe to reuse across requests. Initializing once at module scope reduces per-request allocation/latency.

♻️ Suggested refactor
-Deno.serve(async (req: Request) => {
-  const url = Deno.env.get("SUPABASE_URL")
-  const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")
-
-  let client: InsertClient | null = null
-  if (url && serviceKey) {
-    const supabase = createClient(url, serviceKey, {
-      auth: { persistSession: false, autoRefreshToken: false },
-    })
-    client = {
-      insert: (row) => supabase.from("marketing_events").insert(row),
-    }
-  }
-
-  return handleTrack(req, { client })
-})
+const url = Deno.env.get("SUPABASE_URL")
+const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")
+
+let client: InsertClient | null = null
+if (url && serviceKey) {
+  const supabase = createClient(url, serviceKey, {
+    auth: { persistSession: false, autoRefreshToken: false },
+  })
+  client = {
+    insert: (row) => supabase.from("marketing_events").insert(row),
+  }
+}
+
+Deno.serve((req: Request) => handleTrack(req, { client }))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/track/index.ts` around lines 6 - 21, The Supabase client
is being created inside Deno.serve for every request; hoist the environment
reads and createClient call to module scope so they run once at startup: move
the calls to Deno.env.get("SUPABASE_URL") and
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") and the createClient(...) invocation
out of the Deno.serve callback into top-level module scope, build the
InsertClient-compatible object (the same shape currently assigned to client)
once (preserving auth options persistSession:false and autoRefreshToken:false),
and then have Deno.serve/handleTrack reuse that single client instance
(referencing the client variable, createClient, and handleTrack to locate the
code to change).
apps/web/components/ui/illustrations/map-illustration.tsx (1)

5-40: LGTM!

Decorative map with avatar overlays. next/image usage is correct.

Minor consideration: the avatar <Image> elements have meaningful alt text but the overall illustration appears decorative. If callers don't already wrap this in an aria-hidden container, consider setting alt="" on the avatars (or wrap the fragment) to prevent screen readers from announcing names that have no semantic context.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/illustrations/map-illustration.tsx` around lines 5 -
40, The MapIllustration component renders decorative avatars with semantic alt
text which may be announced by screen readers; update MapIllustration so the
decorative images are hidden from assistive tech—either set each avatar Image
alt="" (replace GLODIE_AVATAR, THEO_AVATAR, BERNARD_AVATAR Image alt values) or
wrap the outer fragment/div in aria-hidden="true" (around the top-level fragment
or the container divs) to mark the entire illustration as decorative.
apps/web/components/map.tsx (1)

1-31: Consider lazy-initializing the map to defer bundle work.

new DottedMap(...) and map.getPoints() run at module-evaluation time on client. The dotted-map library is ~343 KB minified, so initializing it eagerly means this cost is paid whenever the module is imported, even if the Map component never renders. Move the initialization into the component using useMemo to defer this work until first render.

♻️ Suggested refactor
-'use client'
-import DottedMap from 'dotted-map'
-
-const map = new DottedMap({ height: 55, grid: 'vertical' })
-
-const points = map.getPoints()
-
-const svgOptions = {
+'use client'
+import { useMemo } from 'react'
+import DottedMap from 'dotted-map'
+
+const svgOptions = {
     backgroundColor: 'var(--color-background)',
     color: 'currentColor',
     radius: 0.15,
 }
 
 export const Map = () => {
+    const points = useMemo(
+        () => new DottedMap({ height: 55, grid: 'vertical' }).getPoints(),
+        [],
+    )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/map.tsx` around lines 1 - 31, The module currently
constructs the heavy DottedMap and calls map.getPoints() at import time; move
that work into the Map component and lazy-initialize it with React's useMemo so
the DottedMap instantiation and map.getPoints() only run on first render (not at
module evaluation). Specifically, inside the Map function use useMemo to create
new DottedMap(...) and to compute points = map.getPoints(), keep svgOptions
as-is or memoize if needed, and then render points as before; reference the
DottedMap constructor, map.getPoints(), and the Map component when making the
change.
apps/web/components/how-it-works-3.tsx (1)

3-3: Typo in imported symbol name (DocumentIllustation).

The export name in apps/web/components/ui/illustrations/document-illustration.tsx is spelled DocumentIllustation (missing the second r). It's consistent here, but worth correcting at the source + all call sites while this PR is still open, to avoid it hardening into API you have to grep for.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/how-it-works-3.tsx` at line 3, The import/export symbol
is misspelled as DocumentIllustation (missing "r"); rename the export to
DocumentIllustration in the illustration component and update all call sites to
import DocumentIllustration instead of DocumentIllustation (e.g., change the
export in the component that currently declares DocumentIllustation and replace
any imports like import { DocumentIllustation } with import {
DocumentIllustration }), run the build/tests to ensure no remaining references.
apps/web/components/comparator-7.tsx (1)

131-148: Remove redundant per-row TooltipProvider wrappers.

Tooltip already renders internally within a TooltipProvider (see lines 19-25 of tooltip.tsx). Wrapping each row in an additional provider is redundant — hoist a single provider to the section root or remove the outer wrapper entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/comparator-7.tsx` around lines 131 - 148, The per-row
TooltipProvider inside the features.map loop is redundant because Tooltip
already uses a provider internally; remove the TooltipProvider wrapper that
surrounds each Tooltip (the instance that wraps Tooltip, TooltipTrigger, and
TooltipContent) and instead wrap the outer features container with a single
TooltipProvider (or remove providers entirely if Tooltip handles them), updating
the JSX around the features.map so only one TooltipProvider surrounds the list
and leaving Tooltip, TooltipTrigger, and TooltipContent unchanged.
supabase/functions/track/handler.ts (2)

80-86: Minor: path/metadata caps measure characters, not bytes.

body.path.length and metadataSerialized.length count UTF-16 code units, so a payload of mostly multibyte characters can exceed 2 KiB on disk while still passing the check. If the 2048 was chosen as a byte budget for the DB, use new TextEncoder().encode(...).byteLength instead. If the intent was just "some reasonable cap," current code is fine — ignore.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/track/handler.ts` around lines 80 - 86, The length checks
for path and metadataSerialized use .length (UTF-16 code units) which can
undercount bytes; change the checks to measure actual UTF-8 byte size using new
TextEncoder().encode(...).byteLength (e.g. compute byte length for body.path and
for JSON.stringify(metadata)) when comparing to the 2048 limit so path,
metadataSerialized, and metadataTrimmed use byte-length checks instead of
.length.

113-118: Optional: log the insert error before returning 500.

Right now a failed insert returns "insert failed" with no trace — if this fires in production, you'll have no signal as to why (RLS denial, column mismatch, DB down). Cheap win to console.error the error.message here; Supabase captures function stderr.

🔧 Proposed tweak
   if (error) {
+    console.error("marketing_events insert failed:", error.message)
     return new Response("insert failed", {
       status: 500,
       headers: CORS_HEADERS,
     })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/track/handler.ts` around lines 113 - 118, The
failed-insert branch currently returns a 500 without logging details; update the
handler to log the DB error before returning by calling console.error with the
captured error (e.g. log error.message and error.stack) in the branch that
checks `if (error)` (the same block that returns new Response("insert failed", {
status: 500, headers: CORS_HEADERS })), so Supabase will capture the stderr and
you can diagnose RLS/constraint/connection issues.
apps/web/test/track-edge-handler.test.ts (1)

61-66: Optional: also assert non-string event is rejected.

The allowlist test only covers a disallowed string. Consider one extra case where body.event is a non-string (e.g., { event: 123 }) to lock in the typeof body.event === "string" coercion branch in the handler. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/test/track-edge-handler.test.ts` around lines 61 - 66, Add an extra
test that verifies non-string event values are rejected: use makeReq({ event:
123 }) and call handleTrack(req, { client: makeClient() }) and assert the
response status is 400 and body text is "event not allowed" so the typeof
body.event === "string" branch in the handler is exercised; place this alongside
the existing allowlist test that uses "suspicious_event".
supabase/migrations/20260419000000_marketing_events_v1.sql (2)

4-18: Operational: plan retention / partitioning before this gets large.

marketing_events is insert-heavy with no retention policy. Once traffic picks up, the session_hash btree index and unbounded table growth will become the first pain point. Consider (post-merge, not blocking):

  • A monthly pg_cron job that deletes rows older than N days (e.g., 90).
  • Or range-partitioning by ts if you want cheap drops.
  • Confirm session_hash_idx is actually queried; if it's only used for cohort counting, a hash index or none at all may be cheaper than btree.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260419000000_marketing_events_v1.sql` around lines 4 -
18, The marketing_events table (marketing_events) and its indexes
(marketing_events_event_ts_idx, marketing_events_session_hash_idx) are unbounded
and insert-heavy—add a post-merge operational plan: implement either a retention
job (e.g., schedule a pg_cron DELETE FROM public.marketing_events WHERE ts <
now() - interval '90 days') or convert the table to range-partitioning by ts to
allow cheap partition drops, and re-evaluate the session_hash index
(session_hash column) to switch to a hash index or remove it if it's only used
for batch cohort counts; update migrations/docs to include the chosen
retention/partitioning strategy and the rationale for index type.

4-12: Optional: ts and created_at look redundant.

Both columns are timestamptz not null default now() with no distinct semantic. Consider dropping one (typical pattern: keep created_at for audit, use it in the (event, ts desc) index; or keep only ts as the event time). Harmless but adds a few bytes per row and an extra column to reason about.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260419000000_marketing_events_v1.sql` around lines 4 -
12, The table public.marketing_events defines two identical timestamp columns
(ts and created_at) which are redundant; remove one (choose either keep
created_at for audit or keep ts for event time) by dropping the unwanted column
from the CREATE TABLE and update any indexes/usages (e.g., the (event, ts DESC)
index referenced in comments) to use the retained column; ensure default now()
remains on the kept column and update any code/queries that referenced the
dropped symbol (ts or created_at) to the retained column name
(marketing_events.ts or marketing_events.created_at) so schema and queries
remain consistent.
apps/web/components/ui/terminal.tsx (1)

236-300: Consider respecting prefers-reduced-motion for the typing animation.

Users with prefers-reduced-motion: reduce currently see the full character-by-character reveal. The TypingAnimation component should import and check useReducedMotion() from motion/react. When reduced motion is enabled, skip the setInterval loop and directly set displayedText to the full children, then call completeItem immediately. This improves accessibility for users with vestibular disorders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/terminal.tsx` around lines 236 - 300, The
TypingAnimation component currently always runs the character-by-character
setInterval reveal; import and call useReducedMotion() from "motion/react"
inside TypingAnimation and, when it returns true, skip the interval animation:
immediately set displayedText state to the full children string and invoke
completeItem(index) (or the component's completion callback) so the sequence
advances; otherwise keep the existing interval logic. Ensure you reference and
update the displayedText state, the existing setInterval/clearInterval logic,
and the completeItem call so reduced-motion users get the full text instantly
and the sequence still completes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 32fe0abe-5bbb-462d-ac10-8593a1deb729

📥 Commits

Reviewing files that changed from the base of the PR and between 57e28f0 and 91bc639.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (51)
  • CHANGELOG.md
  • apps/web/app/(marketing)/page.tsx
  • apps/web/components.json
  • apps/web/components/bento-2.tsx
  • apps/web/components/code-block.tsx
  • apps/web/components/comparator-7.tsx
  • apps/web/components/const.ts
  • apps/web/components/faqs-3.tsx
  • apps/web/components/features-1.tsx
  • apps/web/components/footer.tsx
  • apps/web/components/header.tsx
  • apps/web/components/hero-section.tsx
  • apps/web/components/how-it-works-3.tsx
  • apps/web/components/logo-cloud.tsx
  • apps/web/components/logo.tsx
  • apps/web/components/map.tsx
  • apps/web/components/marketing/demo-cross-link.tsx
  • apps/web/components/marketing/hero.tsx
  • apps/web/components/particles.tsx
  • apps/web/components/pricing.tsx
  • apps/web/components/ui/accordion.tsx
  • apps/web/components/ui/card.tsx
  • apps/web/components/ui/chart.tsx
  • apps/web/components/ui/illustrations/chart-illustration.tsx
  • apps/web/components/ui/illustrations/code-illustration.tsx
  • apps/web/components/ui/illustrations/currency-illustration.tsx
  • apps/web/components/ui/illustrations/document-illustration.tsx
  • apps/web/components/ui/illustrations/invoice-illustration.tsx
  • apps/web/components/ui/illustrations/map-illustration.tsx
  • apps/web/components/ui/illustrations/monitoring-barchart-illustration.tsx
  • apps/web/components/ui/illustrations/notification-illustration.tsx
  • apps/web/components/ui/illustrations/reply-illustration.tsx
  • apps/web/components/ui/illustrations/scan-illustration.tsx
  • apps/web/components/ui/illustrations/visualization-illustration.tsx
  • apps/web/components/ui/infinite-slider.tsx
  • apps/web/components/ui/input.tsx
  • apps/web/components/ui/label.tsx
  • apps/web/components/ui/svgs/vs-code.tsx
  • apps/web/components/ui/terminal.tsx
  • apps/web/components/ui/text-scramble.tsx
  • apps/web/components/ui/tooltip.tsx
  • apps/web/lib/analytics.ts
  • apps/web/next.config.mjs
  • apps/web/package.json
  • apps/web/test/analytics.test.ts
  • apps/web/test/copy-button.test.tsx
  • apps/web/test/track-edge-handler.test.ts
  • packages/ui/src/components/copy-button.tsx
  • supabase/functions/track/handler.ts
  • supabase/functions/track/index.ts
  • supabase/migrations/20260419000000_marketing_events_v1.sql
💤 Files with no reviewable changes (2)
  • apps/web/components/hero-section.tsx
  • apps/web/components/logo-cloud.tsx

Comment on lines +20 to +46
export async function highlight(code: string, lang: BundledLanguage) {
const cacheKey = `${lang}:${code.length}:${code.slice(0, 50)}:${code.slice(-50)}`

const cached = highlightCache.get(cacheKey)
if (cached) return cached

const { codeToHast } = await getShiki()

const hast = await codeToHast(code, {
lang,
themes: {
light: 'github-light',
dark: 'vesper',
},
})

const result = toJsxRuntime(hast, {
Fragment,
jsx,
jsxs,
}) as JSX.Element

if (highlightCache.size > 100) {
const firstKey = highlightCache.keys().next().value
if (firstKey) highlightCache.delete(firstKey)
}
highlightCache.set(cacheKey, result)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a collision-free cache key for highlighted code.

The current key only fingerprints the length plus first/last 50 chars. Two different snippets with the same prefix/suffix/length will return the first snippet’s cached JSX, rendering incorrect code. Also, the cache can grow to 101 entries because eviction happens only when size > 100.

🐛 Proposed fix
 export async function highlight(code: string, lang: BundledLanguage) {
-    const cacheKey = `${lang}:${code.length}:${code.slice(0, 50)}:${code.slice(-50)}`
+    const cacheKey = `${lang}:${code}`
 
     const cached = highlightCache.get(cacheKey)
     if (cached) return cached
@@
-    if (highlightCache.size > 100) {
+    if (highlightCache.size >= 100) {
         const firstKey = highlightCache.keys().next().value
         if (firstKey) highlightCache.delete(firstKey)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/code-block.tsx` around lines 20 - 46, The cache key in
highlight() is collision-prone and eviction off-by-one: replace the brittle
`${lang}:${code.length}:${code.slice(0,50)}:${code.slice(-50)}` with a
collision-free key (e.g., compute a stable hash of the full code plus lang, such
as SHA-1 or another fast hash, and use `${lang}:${hash}`) so different snippets
never collide, and enforce eviction deterministically (check highlightCache.size
>= 100 before inserting or trim after set to keep max 100 entries) around the
existing highlightCache.set(cacheKey, result) call.

Comment thread apps/web/components/footer.tsx Outdated
Comment on lines +7 to +16
export const LightDarkParticles = ({ id }: { id: string }) => {
const [init, setInit] = useState(true)

useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine)
}).then(() => {
setInit(true)
})
}, [])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

init should start as false — current init state defeats the engine-load gate.

useState(true) makes init already truthy on first render, so <Particles> mounts before initParticlesEngine(...).then(setInit(true)) resolves. The whole gate at Line 395 becomes a no-op (and the else branch is unreachable), which can race with loadSlim and trigger tsparticles warnings / silently-wrong rendering on first paint.

🐛 Proposed fix
-    const [init, setInit] = useState(true)
+    const [init, setInit] = useState(false)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/particles.tsx` around lines 7 - 16, The component
LightDarkParticles initializes the init flag incorrectly; change the useState
call so init starts as false (useState(false)) and keep the useEffect that calls
initParticlesEngine(async (engine) => { await loadSlim(engine) }).then(() =>
setInit(true)) so that setInit(true) only runs after loadSlim completes; this
ensures the render gate that checks init (and mounts <Particles>) behaves
correctly and prevents the race with initParticlesEngine/loadSlim.

@@ -0,0 +1,91 @@
'use client'
import { cn } from '@workspace/ui/lib/utils'
import { useMotionValue, animate, motion } from 'motion/react'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does the Motion for React package motion/reactexport theuseReducedMotion hook?

💡 Result:

Yes, the Motion for React package "motion/react" exports the useReducedMotion hook.

Citations:


🏁 Script executed:

cat -n apps/web/components/ui/infinite-slider.tsx

Repository: Codehagen/Helpbase

Length of output: 3808


Respect prefers-reduced-motion before starting the infinite loop.

The marquee animates continuously even for users who request reduced motion. Gate the animation and render a static position for that preference.

♿ Proposed fix
-import { useMotionValue, animate, motion } from 'motion/react'
+import { useMotionValue, animate, motion, useReducedMotion } from 'motion/react'
@@
     const translation = useMotionValue(0)
+    const shouldReduceMotion = useReducedMotion()
     const [isTransitioning, setIsTransitioning] = useState(false)
@@
     useEffect(() => {
+        if (shouldReduceMotion) {
+            translation.set(0)
+            return
+        }
+
         let controls
@@
-    }, [key, translation, currentSpeed, width, height, gap, isTransitioning, direction, reverse])
+    }, [key, translation, currentSpeed, width, height, gap, isTransitioning, direction, reverse, shouldReduceMotion])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { useMotionValue, animate, motion } from 'motion/react'
import { useMotionValue, animate, motion, useReducedMotion } from 'motion/react'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/infinite-slider.tsx` at line 3, The infinite marquee
starts animating unconditionally; respect the user's prefers-reduced-motion
setting by detecting it (e.g., via window.matchMedia('(prefers-reduced-motion:
reduce)') or a React hook) inside the InfiniteSlider component and skip starting
any animate/useMotionValue loops when reduced motion is requested; instead
render the motion elements in a single static position (no animate calls) and
ensure imports (useMotionValue, animate, motion) are only used when motion is
allowed so the component returns a non-animated markup path for reduced-motion
users.

Comment on lines +75 to +88
return (
<div className={cn('overflow-hidden', className)}>
<motion.div
className="flex w-max"
style={{
...(direction === 'horizontal' ? { x: translation } : { y: translation }),
gap: `${gap}px`,
flexDirection: direction === 'horizontal' ? 'row' : 'column',
}}
ref={ref}
{...hoverProps}>
{children}
{children}
</motion.div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does @types/react19 include the HTMLinert attribute on JSX intrinsic elements?

💡 Result:

Yes, @types/react 19 includes the HTML inert attribute on JSX intrinsic elements. It is defined in the HTMLAttributes interface as inert?: boolean, making it available on all HTML elements via inheritance. This aligns with React 19's runtime support for the inert prop as a boolean.

Citations:


🏁 Script executed:

# Find package.json and check React version
fd -t f "package.json" | head -5 | xargs -I {} cat {}

Repository: Codehagen/Helpbase

Length of output: 7988


🏁 Script executed:

# Read the infinite-slider.tsx file to understand the context
fd -t f "infinite-slider.tsx" | head -1 | xargs cat

Repository: Codehagen/Helpbase

Length of output: 3171


Hide the cloned slide set from assistive tech and tab order.

Rendering {children} twice exposes duplicate content; if any child is interactive, keyboard and screen-reader users will encounter every item twice. Mark the cloned set as hidden/inert.

♿ Proposed fix
             <motion.div
                 className="flex w-max"
                 style={{
                     ...(direction === 'horizontal' ? { x: translation } : { y: translation }),
                     gap: `${gap}px`,
                     flexDirection: direction === 'horizontal' ? 'row' : 'column',
                 }}
                 ref={ref}
                 {...hoverProps}>
-                {children}
-                {children}
+                <div className="flex shrink-0" style={{ gap: `${gap}px`, flexDirection: direction === 'horizontal' ? 'row' : 'column' }}>
+                    {children}
+                </div>
+                <div aria-hidden="true" inert className="flex shrink-0" style={{ gap: `${gap}px`, flexDirection: direction === 'horizontal' ? 'row' : 'column' }}>
+                    {children}
+                </div>
             </motion.div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<div className={cn('overflow-hidden', className)}>
<motion.div
className="flex w-max"
style={{
...(direction === 'horizontal' ? { x: translation } : { y: translation }),
gap: `${gap}px`,
flexDirection: direction === 'horizontal' ? 'row' : 'column',
}}
ref={ref}
{...hoverProps}>
{children}
{children}
</motion.div>
return (
<div className={cn('overflow-hidden', className)}>
<motion.div
className="flex w-max"
style={{
...(direction === 'horizontal' ? { x: translation } : { y: translation }),
gap: `${gap}px`,
flexDirection: direction === 'horizontal' ? 'row' : 'column',
}}
ref={ref}
{...hoverProps}>
<div className="flex shrink-0" style={{ gap: `${gap}px`, flexDirection: direction === 'horizontal' ? 'row' : 'column' }}>
{children}
</div>
<div aria-hidden="true" inert className="flex shrink-0" style={{ gap: `${gap}px`, flexDirection: direction === 'horizontal' ? 'row' : 'column' }}>
{children}
</div>
</motion.div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/infinite-slider.tsx` around lines 75 - 88, The
duplicated slide set should be hidden from assistive tech and removed from the
tab order: wrap the cloned children (the second {children} render inside the
motion.div) in a wrapper element with aria-hidden="true" and tabIndex={-1} (or
add those attributes directly if you render the clone inline) so screen readers
and keyboard users do not see or focus the duplicated items; keep the original
{children} unchanged and ensure the wrapper is used only for the visual clone in
the motion.div (refer to the motion.div, children, and any wrapper you add for
the cloned set).

Comment on lines +30 to +79
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const [displayText, setDisplayText] = useState(children);
const [isAnimating, setIsAnimating] = useState(false);
const text = children;

const scramble = async () => {
if (isAnimating) return;
setIsAnimating(true);

const steps = duration / speed;
let step = 0;

const interval = setInterval(() => {
let scrambled = '';
const progress = step / steps;

for (let i = 0; i < text.length; i++) {
if (text[i] === ' ') {
scrambled += ' ';
continue;
}

if (progress * text.length > i) {
scrambled += text[i];
} else {
scrambled +=
characterSet[Math.floor(Math.random() * characterSet.length)];
}
}

setDisplayText(scrambled);
step++;

if (step > steps) {
clearInterval(interval);
setDisplayText(text);
setIsAnimating(false);
onScrambleComplete?.();
}
}, speed * 1000);
};

useEffect(() => {
if (!trigger) return;

scramble();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trigger]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

motion/react motion.create memoization best practice re-render

💡 Result:

Best practices for memoization with motion/react's motion.create to prevent unnecessary re-renders: Motion components (including those created with motion.create) are designed to bypass React's render cycle for animations. Animated values update directly via the browser's native animation pipeline at up to 120fps without triggering React re-renders. This is the primary performance optimization—no additional memoization needed for the animation itself. Key guidelines: 1. Create motion components outside render: Call motion.create(Component) at module level, not inside components or render functions. Calling it during render creates a new component each time, breaking animations and causing re-renders. Example: const MotionMyComponent = motion.create(MyComponent); // Good, at top-level 2. For custom components passed to motion.create: - Use forwardRef (React 18) or props.ref (React 19) to pass ref to the animatable element. - Ensure the component accepts style prop. Example (React 18): const MyComponent = React.forwardRef((props, ref) => <div ref={ref} {...props} />); 3. Use React.memo on wrapper/parent components only if needed: For lists of motion components, wrap list items in React.memo to skip re-renders when props unchanged. Memoize non-primitive props (objects, functions, arrays) with useMemo/useCallback for memo to work. However, MotionContext may cause sibling re-renders in large lists if shared motion ancestor changes—memoize items and ensure stable props. 4. Prefer motion values for dynamic styles: Use useMotionValue for values updated outside React (e.g., in useEffect), avoiding state-driven re-renders. const x = useMotionValue(0); x.set(100); // No re-render 5. Other optimizations: - layoutDependency prop reduces layout measurements. - LazyMotion for bundle size (not directly re-renders). - Profile with React DevTools before/after. Motion's design minimizes re-render needs; focus on stable props and correct motion.create usage over heavy memoization.

Citations:


Fix interval cleanup, motion component memoization, and race condition.

Three bugs in this effect chain:

  1. Missing interval cleanup. The useEffect on line 74 does not return a cleanup function. If the component unmounts (route change, conditional render) while an animation is running, the interval keeps firing and calls setDisplayText on an unmounted component, triggering "Can't perform a React state update on an unmounted component" warnings. The interval also leaks across re-mounts when trigger toggles.

  2. motion.create(Component) called inside render (line 30). This creates a new motion component identity on every render, remounting the underlying DOM node and discarding any in-progress animation state. Wrap it in useMemo with [Component] as a dependency.

  3. Stale isAnimating guard. Because scramble is redefined each render, its closure over isAnimating is stale—the guard on line 38 is ineffective, allowing rapid trigger toggles to spawn multiple concurrent intervals. Move the interval logic directly into the effect and remove the isAnimating state.

🐛 Proposed fix
-  const MotionComponent = motion.create(
-    Component as keyof JSX.IntrinsicElements
-  );
+  const MotionComponent = useMemo(
+    () => motion.create(Component as keyof JSX.IntrinsicElements),
+    [Component]
+  );
   const [displayText, setDisplayText] = useState(children);
-  const [isAnimating, setIsAnimating] = useState(false);
   const text = children;

-  const scramble = async () => {
-    if (isAnimating) return;
-    setIsAnimating(true);
-
-    const steps = duration / speed;
-    let step = 0;
-
-    const interval = setInterval(() => {
-      let scrambled = '';
-      const progress = step / steps;
-
-      for (let i = 0; i < text.length; i++) {
-        if (text[i] === ' ') {
-          scrambled += ' ';
-          continue;
-        }
-
-        if (progress * text.length > i) {
-          scrambled += text[i];
-        } else {
-          scrambled +=
-            characterSet[Math.floor(Math.random() * characterSet.length)];
-        }
-      }
-
-      setDisplayText(scrambled);
-      step++;
-
-      if (step > steps) {
-        clearInterval(interval);
-        setDisplayText(text);
-        setIsAnimating(false);
-        onScrambleComplete?.();
-      }
-    }, speed * 1000);
-  };
-
   useEffect(() => {
     if (!trigger) return;
-
-    scramble();
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [trigger]);
+    
+    const steps = Math.max(1, duration / speed);
+    let step = 0;
+    
+    const interval = setInterval(() => {
+      let scrambled = '';
+      const progress = step / steps;
+      for (let i = 0; i < text.length; i++) {
+        if (text[i] === ' ') {
+          scrambled += ' ';
+          continue;
+        }
+        if (progress * text.length > i) {
+          scrambled += text[i];
+        } else {
+          scrambled +=
+            characterSet[Math.floor(Math.random() * characterSet.length)];
+        }
+      }
+      setDisplayText(scrambled);
+      step++;
+      if (step > steps) {
+        clearInterval(interval);
+        setDisplayText(text);
+        onScrambleComplete?.();
+      }
+    }, speed * 1000);
+    
+    return () => clearInterval(interval);
+  }, [trigger, text, duration, speed, characterSet, onScrambleComplete]);

Add useMemo to the import.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const [displayText, setDisplayText] = useState(children);
const [isAnimating, setIsAnimating] = useState(false);
const text = children;
const scramble = async () => {
if (isAnimating) return;
setIsAnimating(true);
const steps = duration / speed;
let step = 0;
const interval = setInterval(() => {
let scrambled = '';
const progress = step / steps;
for (let i = 0; i < text.length; i++) {
if (text[i] === ' ') {
scrambled += ' ';
continue;
}
if (progress * text.length > i) {
scrambled += text[i];
} else {
scrambled +=
characterSet[Math.floor(Math.random() * characterSet.length)];
}
}
setDisplayText(scrambled);
step++;
if (step > steps) {
clearInterval(interval);
setDisplayText(text);
setIsAnimating(false);
onScrambleComplete?.();
}
}, speed * 1000);
};
useEffect(() => {
if (!trigger) return;
scramble();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trigger]);
const MotionComponent = useMemo(
() => motion.create(Component as keyof JSX.IntrinsicElements),
[Component]
);
const [displayText, setDisplayText] = useState(children);
const text = children;
useEffect(() => {
if (!trigger) return;
const steps = Math.max(1, duration / speed);
let step = 0;
const interval = setInterval(() => {
let scrambled = '';
const progress = step / steps;
for (let i = 0; i < text.length; i++) {
if (text[i] === ' ') {
scrambled += ' ';
continue;
}
if (progress * text.length > i) {
scrambled += text[i];
} else {
scrambled +=
characterSet[Math.floor(Math.random() * characterSet.length)];
}
}
setDisplayText(scrambled);
step++;
if (step > steps) {
clearInterval(interval);
setDisplayText(text);
onScrambleComplete?.();
}
}, speed * 1000);
return () => clearInterval(interval);
}, [trigger, text, duration, speed, characterSet, onScrambleComplete]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/text-scramble.tsx` around lines 30 - 79, The effect
chain has three issues: motion.create(Component) is called on every render, the
interval started by scramble isn't cleaned up, and the isAnimating guard is
stale. Fix by memoizing the motion component (wrap MotionComponent =
motion.create(Component ...) in useMemo with [Component]); move the
scrambling/interval logic out of the scramble function into the useEffect that
depends on trigger so you can start/clear the timer there; remove isAnimating
state and use a local variable/closure inside the effect to prevent concurrent
intervals; ensure the effect returns a cleanup that clears the interval and
avoids calling setDisplayText/onScrambleComplete after unmount (e.g., guard with
an "aborted" flag or clearInterval before calling state updaters). Reference:
MotionComponent, motion.create, scramble, useEffect (trigger), setInterval,
setDisplayText, onScrambleComplete, duration, speed, characterSet.

Comment on lines +22 to +28
-- Anon can insert events (edge function routes inserts through anon role).
drop policy if exists "marketing_events_anon_insert" on public.marketing_events;
create policy "marketing_events_anon_insert"
on public.marketing_events
for insert
to anon
with check (true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Anon insert policy bypasses the edge function's validation — tighten or drop it.

Per the PR wiring, supabase/functions/track/index.ts uses SUPABASE_SERVICE_ROLE_KEY, which bypasses RLS regardless of this policy. Meanwhile, the anon key is publicly exposed to the browser (via apps/web/lib/analytics.ts), so this policy lets any client POST /rest/v1/marketing_events directly through PostgREST and bypass every server-side guardrail in handler.ts — the event allowlist, the 2 KB metadata cap, the path length cap, and (critically) the cf-connecting-ip-derived session_hash. A drive-by attacker can stuff arbitrary event/metadata rows and forge session_hash.

Two options:

  1. If the edge function continues to use service_role, drop the anon insert policy entirely — it's unused and only adds attack surface.
  2. If you want the edge function to use the anon role instead (so RLS is actually enforcing), keep the policy but add a WITH CHECK that mirrors the handler's validation.
🛡️ Option 1: remove the anon policy
-- Anon can insert events (edge function routes inserts through anon role).
-drop policy if exists "marketing_events_anon_insert" on public.marketing_events;
-create policy "marketing_events_anon_insert"
-  on public.marketing_events
-  for insert
-  to anon
-  with check (true);
🛡️ Option 2: constrain the anon policy to match the handler
 create policy "marketing_events_anon_insert"
   on public.marketing_events
   for insert
   to anon
-  with check (true);
+  with check (
+    event in (
+      'page_view','hero_install_copied','hero_demo_clicked',
+      'pricing_tier_clicked','demo_opened','faq_expanded'
+    )
+    and (path is null or length(path) <= 2048)
+    and octet_length(metadata::text) <= 2048
+    and session_hash ~ '^[0-9a-f]{64}$'
+  );

Either way, please also reconcile the comment on line 1 (“Insert-only from anon, select-only from service role”) with whichever role the edge function actually uses.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
-- Anon can insert events (edge function routes inserts through anon role).
drop policy if exists "marketing_events_anon_insert" on public.marketing_events;
create policy "marketing_events_anon_insert"
on public.marketing_events
for insert
to anon
with check (true);
-- Anon can insert events (edge function routes inserts through anon role).
drop policy if exists "marketing_events_anon_insert" on public.marketing_events;
create policy "marketing_events_anon_insert"
on public.marketing_events
for insert
to anon
with check (
event in (
'page_view','hero_install_copied','hero_demo_clicked',
'pricing_tier_clicked','demo_opened','faq_expanded'
)
and (path is null or length(path) <= 2048)
and octet_length(metadata::text) <= 2048
and session_hash ~ '^[0-9a-f]{64}$'
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260419000000_marketing_events_v1.sql` around lines 22 -
28, The anon insert policy marketing_events_anon_insert currently allows any
client to bypass handler.ts validation because the edge function uses
SUPABASE_SERVICE_ROLE_KEY (see supabase/functions/track/index.ts) — either
remove the marketing_events_anon_insert policy entirely to eliminate that attack
surface, or replace its permissive WITH CHECK (true) with constraints that
mirror handler.ts validations (validate event allowlist, metadata size <= 2KB,
path length cap, and require session_hash derived from cf-connecting-ip) so RLS
enforces the same guards; also update or remove the file comment that claims
“Insert-only from anon, select-only from service role” to reflect whether the
edge function actually uses the service role or anon role
(apps/web/lib/analytics.ts and track/index.ts).

Codehagen and others added 3 commits April 19, 2026 21:29
Unblocks the CI sync:templates drift gate.

New exclusions in HOSTED_TIER_EXCLUDES:
  components/marketing/  (hero + demo-cross-link — @workspace/ui import)
  components/bento-2.tsx, comparator-7.tsx, features-1.tsx, faqs-3.tsx,
  components/how-it-works-3.tsx, pricing.tsx
    (Tailark marketing blocks — scaffold ships docs only)
  components/code-block.tsx, map.tsx, particles.tsx, const.ts
    (internal deps of the above)
  components/ui/terminal.tsx  (MagicUI — hero-only)
  lib/analytics.ts  (Supabase track client — helpbase.dev-only)

Extended footer.tsx content transform: scaffolded sites get a brand-
neutral footer. External hrefs to Codehagen/helpbase, x.com/codehagen,
demo.helpbase.dev rewritten to '#'. Tagline, copyright, and newsletter
copy rewritten to generic product-docs phrasing. Regex now matches
single-quoted hrefs too (the JS link objects use single quotes).

Also: sync surfaced new scaffold deps that Tailark pulled in (logo,
ui/card, chart, infinite-slider, input, label, text-scramble,
tooltip). These are generic primitives — legitimate to ship to users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit surfaced this as a major finding: the anon insert policy let
any script POST directly to /rest/v1/marketing_events via PostgREST
using the publicly-exposed anon key, bypassing every guardrail in the
track edge function (event allowlist, 2 KB metadata cap, path cap,
cf-connecting-ip-derived session_hash).

The edge function already uses SUPABASE_SERVICE_ROLE_KEY, which bypasses
RLS. The anon policy was pure attack surface. Dropping it.

After: service_role client inside the track edge function is the only
path that can write the table. anon and authenticated roles cannot
SELECT/UPDATE/DELETE either (no policies, no grants). End-to-end
edge-function-owned.

Verified live:
  POST /functions/v1/track (with anon apikey) → 204 (works)
  POST /rest/v1/marketing_events (direct, with anon key) → 401 + RLS
    "new row violates row-level security policy" (blocked)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit P0/P1 that we owned:
- Footer newsletter form no longer submits via GET (leaked email to URL
  on reload). Replaced with a direct 'Follow on GitHub Releases' link —
  the real update channel, no fake subscribe endpoint.
- Marketing event table tightened: dropped the anon insert RLS policy
  so the edge function is the only writer (service_role bypasses RLS).
  Direct PostgREST POST with the anon key now returns 401 + RLS
  violation. Smoke-tested live.

Hero copy rewritten to lead with the category shift: 'Docs are
infrastructure now. Helpbase ships the code.' Subhead names the three
artifacts (MCP, llms.txt, Next.js help center) and the 30-second
scaffold, closing with the self-host-or-hosted choice.

FAQ header: 'The questions founders ask before scaffolding.' (stripped
the named-user reference.) Pro tier pricing: $29/mo per site as the
early-access indicator, CTA stays 'Join the Pro waitlist' (pre-GA).
Terminal animation preview URL: 'docs-a1b2c3.helpbase.dev' (anonymous
slug hash, no named user).

Lazy-load every below-fold section via `next/dynamic` in
app/(marketing)/page.tsx. Hero + Comparator stay eager for first-paint
content; HowItWorks, FeaturesOwnIt, AiNativeBento, DemoCrossLink,
Pricing, FAQs all code-split. SSR stays on so the HTML still ships
pre-rendered.

Also: sync-templates drift gate (CI blocker) fixed by excluding
marketing-only files from scaffolder + registry sync, plus a content
transform that neutralizes helpbase brand copy in the scaffolded
footer.

Bundle-analyzer experiment abandoned — Turbopack builds don't support
@next/bundle-analyzer, and the structural lazy-load already covered the
perf concern without measurement.

Test fixture cleanup: middleware-subdomain.test.ts renamed 'vegard'
fixtures to 'acme'.

155 tests passing, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the CurrencyIllustration in the bento-2 llms.txt card with an
inline <LlmsTxtPreview /> that renders a mini manifest (Docs + MCP
URLs + a 4-article list). Show-don't-tell: for a docs tool, seeing the
actual artifact is more informative than a pictogram, and the money
iconography was semantically off for a plain-text file.

Deletes 5 unused Tailark illustrations that Tailark blocks pulled in
but nothing currently imports:
  currency-illustration.tsx      (replaced above)
  monitoring-barchart-illustration.tsx  (how-it-works pivoted away from it)
  invoice-illustration.tsx       (never rendered)
  product-illustration.tsx       (never rendered)
  scan-illustration.tsx          (hardcoded unsplash face, wrong fit for a dev tool)

Remaining illustrations (code, document, chart, notification, reply,
visualization, map) are all in active use and acceptably neutral for
their cards. A fuller illustration audit waits for real user feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maintainer-facing code comments should capture invariants, not the
story of the day they were written. Scrubbed:

  apps/web/components/bento-2.tsx  — dropped a ~3-line comment about
    why LlmsTxtPreview replaced an illustration; the component is
    self-explanatory.
  apps/web/app/(marketing)/page.tsx  — tightened the lazy-load
    comment to 3 lines about the code-split invariant, no "cold
    visitor in the first scroll" narrative.
  scripts/sync-templates.mjs  — replaced a dated "(2026-04-19)"
    block with a one-liner about why marketing/ is excluded.
  scripts/smoke-deploy.sh  — removed a user-named reference to "what
    <user> runs" and the PR-review provenance line; kept the what/why.

No behavior change. Also verified 155 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codehagen and others added 10 commits April 19, 2026 22:08
…ytics auth

- Hero H1: swap fallback ui-serif (Georgia) for on-brand sans, weight 500,
  tighter tracking, scale to text-6xl on md. The old font-serif class
  fell through Tailwind's default stack because no display serif was
  registered — result was AI-slop Georgia. Brand is Geist, heading is sans.
- Header nav links: add py-2 for 36px hit area (WCAG 2.2 AA requires 24px
  minimum; links were rendering at 18px).
- H2 normalization: every section heading now uses text-3xl md:text-4xl
  font-semibold. Was inconsistent: 48px (comparator, pricing), 36px
  (bento, faqs), 30px (how-it-works). Hero is the only 60px headline now.
- Card H3s (bento, features): bump to text-lg so they separate from
  16px body copy.
- Analytics 401 fix: Supabase edge functions require both apikey AND
  Authorization: Bearer <jwt>. We only sent apikey. Adds the Bearer
  header, updates the test to assert it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminated: scaffold / scaffolded / scaffolding, AI-native, ships (as
non-literal verb), out of the box, ground on / grounding, wedge,
convenience, lock-in step, rent your docs, as code you own, zero
infra, no magic runtime. None of these words survive a read-aloud
test with a real user.

Hero:
- H1: "Docs are infrastructure now. Helpbase ships the code."
   → "A help center for your product. In your repo. In 30 seconds."
- Sub: rewritten to lead with the one command and the three concrete
  things you get, not the abstract "interface to AI agents".
- Terminal line: "scaffolded into" → "created at".

Comparator: "rent your docs" → "get locked in". Feature row "AI
coding tools can ground on it" → "AI tools can read the docs".

How-it-works: step 1 renamed "Scaffold" → "Install". Body rewrites
for all three steps. H2 tightened. Removed "no lock-in step" phrase.

Features: H2 "files are in your repo from minute one" → "Every file
is yours, from the first commit." Body rewritten to drop "scaffolds".

Bento: H2 "AI-native by default. Not an upsell." → "Claude and Cursor
can read your docs. Day one." Every card body rewritten. Card 2 title
"llms.txt out of the box" → "llms.txt, always fresh". Card 4 title
"Code-grounded doc sync" → "Docs that stay in sync with code".

Demo cross-link: "ground on" → "answer from".

Pricing: sub "wedge / convenience" → concrete "free forever / saves
you a server". Feature list "create-helpbase scaffold" → just the
actual command. Tier descriptions rewritten.

FAQs: subhead "before scaffolding" → "before running the install".
Six answers rewritten. Question "What does 'AI-native' actually mean"
→ "What do you mean by AI built in".

Docs landing H1 + sub: same treatment. Propagated to scaffold
template via sync-templates.

Tests 155/155, typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
H1: "The help center agents can read."
Sub-line (muted): "Lives in your repo, not ours."

This is the same pivot Mintlify made at Series B — docs are now
mostly read by agents, not humans, so the positioning has to lead
with that. Our differentiator against Mintlify is ownership: their
docs live in their cloud, ours live in your git history. Saying
that plainly in six words is the wedge.

Sub-paragraph rewritten: leads with the two concrete AI primitives
(MCP + llms.txt) and what they do for the user (Claude and Cursor
answer from your real docs), then the install promise, then the
host choice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
H1: "Half your docs readers are AI agents now."
Sub-line (muted): "Helpbase is the help center built for them."

Mintlify's Series B announcement reported that ~50% of their
documentation traffic now comes from AI agents. That's not an
abstract trend — it's the incumbent's own audience metric. Leading
the hero with that stat does two things:
  1. Stops the scroll. A number in an H1 is a scroll-stopper.
  2. Reframes the incumbent's insight as our positioning. Mintlify
     calls it "knowledge infrastructure for AI." We just say who
     it's for and why.

The comparator section 200px below carries the ownership wedge
(roll-your-own / hosted SaaS / helpbase), so the H1 is free to be
pure agent-first without losing the self-host differentiator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… padding

Comparator sat at max-w-6xl (1152px) between 5xl sections above and
below, creating a visible bulge mid-scroll. Also had md:px-6 with no
mobile padding, so content touched screen edges on phones.

Pulled to max-w-5xl px-6 to match every other body section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Outer was px-1 md:px-6 (4px mobile vs 24px everywhere else). Inner
col1 had max-md:px-6 and the mobile-only bottom p had px-6 to
compensate. Normalized: outer is now px-6 unconditionally, removed
the two inner compensations so padding doesn't double up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed xl:px-0 so the section keeps px-6 at every breakpoint,
matching the other 5xl body sections. Above xl (1280px+) the section
no longer sat flush to the container edge \u2014 now reads consistently
with features, bento, pricing on wide monitors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-2xl

Pricing intro used max-w-xl while every other section intro
(bento, features) uses max-w-2xl. Bumped to 2xl for visual rhythm
consistency across section subheads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Killed the three absolute-positioned decorative rails on the hero
(max-w-6xl, max-w-332, max-w-316). They were Tailark hero-section/six
framing that assumed the header + hero shared a single 6xl canvas.
After the 5xl width normalization, the header sits at 5xl while these
rails stayed at 6xl-83rem \u2014 so they peeked out from behind the fixed
header, looking like rounded frames floating in empty space above the
headline.

The hero's inner content frame (corner-t-notch max-w-6xl border-x
border-t on the content wrapper) stays. That's enough framing: hero
still gets its rounded top corner and wider-than-body canvas, without
rails that collide with the header.

Verified against localhost:3000 screenshot: rails gone, hero reads
clean, no regression in comparator or body sections below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three illustrations that didn't match their section's meaning:
  - how-it-works Deploy step: ChartIllustration \u2192 WorkflowIllustration
    (shows workflow completed + issue/branch/preview deployment history;
     literally a deploy pipeline)
  - bento MCP server card: NotificationIllustration \u2192 ModelsIllustration
    (shows Gemini / Perplexity / Deepseek / Mistral / Cohere / OpenAI / Qwen;
     MCP = Model Context Protocol, so showing the models that consume the
     endpoint is thematically correct)
  - features Zero vendor runtime card: ChartIllustration \u2192 FlowIllustration
    (shows Vercel / Supabase / Firebase feeding a hub, producing output;
     matches the copy's claim that you can deploy anywhere)

Installed via pnpm dlx shadcn@latest add @tailark-pro/{workflow,models,flow}.
shadcn drops illustrations at components/illustrations/ (not the legacy
components/ui/illustrations/ path), so added that path to
HOSTED_TIER_EXCLUDES in sync-templates.mjs. The new SVG deps
(cohere, deepseek, firebase, mistral-ai, openai, perplexity-ai, qwen)
land at components/ui/svgs/ which was already excluded.

Tests 155/155, typecheck clean, localhost verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codehagen and others added 2 commits April 19, 2026 23:03
…d 2)

Second-pass audit swapped the remaining weak-fit illustrations:

  - features MDX card:
      DocumentIllustation \u2192 DocumentMdIllustration
      (literal 'MD' badge on a document \u2014 matches '.mdx file in repo')

  - bento Structured agent output:
      ReplyIllustration \u2192 AgentTasksIllustration
      (shows '1/3 tasks done' with Fetch/Analyze/Generate list \u2014
       matches 'typed tool calls: list articles, read a specific slug, search')

  - bento Docs sync with code:
      VisualizationIllustration \u2192 DocumentAnalysisIllustration
      (stylized source document \u2014 matches 'sync reads your source and
       proposes MDX edits'; the registry ships an animate-scan overlay that
       requires custom keyframes we haven't added, so the doc renders
       statically for now, which still reads well)

  - bento Hosted tier:
      MapIllustration \u2192 UptimeIllustration
      (clean 'Uptime 99.9%' bar chart \u2014 matches 'helpbase deploy...
       no servers to manage' better than a map)

Installed via: pnpm dlx shadcn@latest add @tailark-pro/{document-md,
agent-tasks,document-analysis,uptime}

components/illustrations/ was already in HOSTED_TIER_EXCLUDES from
the first round, so no template drift. Tests 155/155, typecheck clean,
localhost verified.

Kept CodeIllustration (Install step) and DocumentIllustation (Preview
step) \u2014 they fit their sections adequately and swapping would be churn
without meaningful upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tern)

Round 2 picked document-md and a generic doc illustration for 'MDX all
the way down' and 'Preview', respectively. Both rendered as small icons
with barely any on-topic content \u2014 the MDX badge alone doesn't say
'MDX = markdown + React components', and a generic doc doesn't say
'shareable preview URL'. User flagged both.

Built two custom illustrations matching the LlmsTxtPreview pattern
(the page's strongest illustration, since it shows real manifest
content instead of a pictogram):

  MdxSourcePreview (features \u2192 MDX card):
    Shows content/getting-started.mdx with real MDX source:
    frontmatter, markdown heading + body, a <Callout type="tip">
    JSX component with children. The card now literally demonstrates
    the copy's claim that you can import React components into .mdx.

  PreviewUrlCard (how-it-works \u2192 Preview step):
    Shows a 'Preview ready' card with docs-pr-42.helpbase.dev URL,
    a globe icon, a Copy link button, and a 'Deploy check passed'
    indicator with pulse-animated status dot. Reads like Vercel's
    preview deployment card \u2014 exactly the mental model the copy
    is reaching for.

Removed the unused document-md.tsx (shadcn-installed in round 2,
now orphaned).

components/illustrations/ is in HOSTED_TIER_EXCLUDES, so no template
drift. Tests 155/155, typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

♻️ Duplicate comments (1)
registry/helpbase/components/ui/text-scramble.tsx (1)

30-79: ⚠️ Potential issue | 🔴 Critical

Same lifecycle bugs as the template copy: missing cleanup, per-render motion.create, racy guard.

This file is identical to packages/create-helpbase/templates/components/ui/text-scramble.tsx (and apps/web/components/ui/text-scramble.tsx). The same three issues apply here and should be fixed in lockstep:

  1. useEffect (Lines 74-79) has no cleanup, so the setInterval started in scramble (Line 44) outlives unmount and triggers setDisplayText / setIsAnimating on an unmounted component.
  2. motion.create(Component) (Lines 30-32) is invoked on every render, producing a new component identity and remounting the motion wrapper unnecessarily.
  3. The isAnimating re-entry guard (Lines 38-39) reads a stale closure value because setIsAnimating is async; use a useRef instead.

See the suggested patch on the template file for a concrete fix to apply identically here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@registry/helpbase/components/ui/text-scramble.tsx` around lines 30 - 79, The
MotionComponent is being recreated each render, the scramble interval isn't
cleaned up on unmount, and the isAnimating guard uses state (stale closure); fix
by memoizing the motion wrapper (create MotionComponent once with useMemo or
move creation outside the component) instead of calling motion.create(Component)
every render, replace isAnimating state-guard with a useRef (e.g.,
isAnimatingRef) that you set/read synchronously in scramble, store the interval
id in a ref (e.g., intervalRef) and clearInterval(intervalRef.current) when
scramble completes and in a useEffect cleanup return to avoid setState on
unmounted components, and keep setIsAnimating state updates for UI but rely on
the ref for re-entry checks; apply these same changes to MotionComponent
creation, scramble(), and the useEffect that triggers scramble.
🟡 Minor comments (14)
apps/web/components/illustrations/agent-tasks.tsx-8-8 (1)

8-8: ⚠️ Potential issue | 🟡 Minor

Placeholder copy shipping to production marketing page.

The text "create, agent-tasks illustration" reads like a dev placeholder. Since this illustration is rendered on the public marketing landing in the AI-native bento (see apps/web/components/bento-2.tsx:48-52), this string will be visible to end users. Replace it with the intended chat/prompt copy (e.g., a realistic user request that motivates the tasks below).

✏️ Suggested replacement
-            <div className="bg-illustration max-w-4/5 ring-border-illustration shadow-black/6.5 ml-auto w-fit rounded-l-xl rounded-br rounded-tr-xl px-3 py-2 text-sm shadow ring-1">create, agent-tasks illustration</div>
+            <div className="bg-illustration max-w-4/5 ring-border-illustration shadow-black/6.5 ml-auto w-fit rounded-l-xl rounded-br rounded-tr-xl px-3 py-2 text-sm shadow ring-1">Recommend products for user 4821.</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/agent-tasks.tsx` at line 8, The div in the
AgentTasks illustration component (agent-tasks.tsx) currently contains a
developer placeholder string "create, agent-tasks illustration"; update that
inner text to a realistic user-facing chat/prompt that motivates the listed
tasks (for example a concise user request like a short product or task brief),
so the illustration shows meaningful copy on the public marketing page; locate
the div with className "bg-illustration ... text-sm shadow ring-1" and replace
the placeholder string with the intended user prompt/copy.
apps/web/components/ui/svgs/qwen.tsx-6-6 (1)

6-6: ⚠️ Potential issue | 🟡 Minor

Invalid hex color #ffff.

fill="#ffff" is not a valid CSS hex color (valid forms are 3, 4, 6, or 8 hex digits — #ffff has 4 digits but would be interpreted as #ffff = RGBA #ff ff ff ff? No — 4-digit hex is #RGBA, so #ffff = white with alpha f = opaque white, which by chance renders correctly in modern browsers, but is confusing and non-idiomatic). Use #fff or #ffffff explicitly to avoid reader confusion and potential older-renderer fallback. Also note: since {...props} is spread before fill, callers cannot override the fill on QwenDark, which defeats the purpose of having two variants if you ever want to theme it.

♻️ Suggested change
-        {...props}
-        fill="#ffff"
+        fill="#fff"
+        {...props}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/svgs/qwen.tsx` at line 6, The SVG uses an ambiguous
4-digit hex fill ("#ffff") and also prevents callers from overriding fill
because props are spread before the hardcoded fill; in the QwenDark (and/or
Qwen) component change the fill to a standard value like "#fff" or "#ffffff" and
ensure props can override it by applying {...props} after the fill or by reading
fill from props with a default (e.g., const { fill = "#fff", ...rest } = props)
so external callers can theme the icon.
apps/web/components/illustrations/document-analysis.tsx-9-15 (1)

9-15: ⚠️ Potential issue | 🟡 Minor

Respect reduced-motion preferences for the scan animation.

The decorative scan and hue-rotate effects run unconditionally. Gate them behind motion-safe: and hide or freeze them for prefers-reduced-motion.

♿ Proposed reduced-motion-safe update
-            <div className="animate-scan absolute inset-x-4 inset-y-6 z-10 mix-blend-color">
-                <div className="bg-linear-to-b via-primary animate-hue-rotate h-8.5 absolute -inset-x-[9px] m-auto -translate-y-1/2 rounded-md from-transparent to-transparent" />
+            <div className="motion-safe:animate-scan motion-reduce:hidden absolute inset-x-4 inset-y-6 z-10 mix-blend-color">
+                <div className="bg-linear-to-b via-primary motion-safe:animate-hue-rotate h-8.5 absolute -inset-x-[9px] m-auto -translate-y-1/2 rounded-md from-transparent to-transparent" />
             </div>
-            <div className="animate-scan absolute inset-x-4 inset-y-6 z-10">
+            <div className="motion-safe:animate-scan motion-reduce:hidden absolute inset-x-4 inset-y-6 z-10">
                 <div className="bg-foreground absolute -inset-x-4 m-auto h-px rounded-full" />
                 <div className="bg-linear-to-r to-primary absolute -inset-x-4 m-auto h-px rounded-full from-emerald-500 blur" />
             </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/document-analysis.tsx` around lines 9 - 15,
The decorative scan/hue animations run unconditionally; update the two outer
divs that currently use "animate-scan" and the inner element with
"animate-hue-rotate" (and the blurred line using "blur") to respect
prefers-reduced-motion: replace "animate-scan" with "motion-safe:animate-scan
motion-reduce:opacity-0" on the outer containers, change "animate-hue-rotate" to
"motion-safe:animate-hue-rotate motion-reduce:animate-none", and add
"motion-reduce:opacity-0" to the blurred line ("blur") element so all animations
are gated by motion-safe and hidden/frozen when motion is reduced.
registry/helpbase/components/ui/chart.tsx-215-216 (1)

215-216: ⚠️ Potential issue | 🟡 Minor

Call the formatter for valid falsy series names.

item.name can be 0 or ""; the current truthy check skips the custom formatter for those valid names.

🐛 Proposed fix
-                {formatter && item?.value !== undefined && item.name ? (
+                {formatter &&
+                item?.value !== undefined &&
+                item.name !== undefined ? (
                   formatter(item.value, item.name, item, index, item.payload)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@registry/helpbase/components/ui/chart.tsx` around lines 215 - 216, The
conditional that decides whether to call formatter skips valid falsy series
names like 0 or "" because it checks item.name truthiness; update the check in
the render logic (where formatter is invoked with formatter(item.value,
item.name, item, index, item.payload)) to test for undefined/null instead (e.g.,
formatter && item?.value !== undefined && item.name !== undefined && item.name
!== null) so the formatter is called for valid falsy names.
registry/helpbase/components/ui/chart.tsx-299-322 (1)

299-322: ⚠️ Potential issue | 🟡 Minor

Fallback to the Recharts legend value when config labels are omitted.

ChartConfig.label is optional, but the legend renders no text without it. Use the payload value/dataKey as a fallback so legends remain readable with minimal config.

🐛 Proposed fix
         .filter((item) => item.type !== "none")
         .map((item, index) => {
           const key = `${nameKey ?? item.dataKey ?? "value"}`
           const itemConfig = getPayloadConfigFromPayload(config, item, key)
+          const label = itemConfig?.label ?? item.value ?? item.dataKey
 
           return (
@@
-              {itemConfig?.label}
+              {label != null ? <span>{label}</span> : null}
             </div>
           )
         })}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@registry/helpbase/components/ui/chart.tsx` around lines 299 - 322, The legend
currently shows nothing when ChartConfig.label is omitted; change the rendered
label in the JSX to fall back to the payload key/value used above. Replace the
final output expression {itemConfig?.label} with a fallback that uses the
computed key (the same const key = `${nameKey ?? item.dataKey ?? "value"}`) —
e.g. render {itemConfig?.label ?? key} so the legend shows nameKey or
item.dataKey (or "value") when label is missing.
apps/web/components/how-it-works-3.tsx-31-34 (1)

31-34: ⚠️ Potential issue | 🟡 Minor

Use valid calc() spacing in arbitrary translate classes.

calc(50%-0.5px) generates invalid CSS because the binary - operator requires whitespace. In Tailwind arbitrary values, represent spaces with underscores so Tailwind converts them to proper CSS spacing and the decorator positioning is not dropped.

🎨 Proposed fix for decorator positioning classes
-                    <PlusDecorator className="-translate-[calc(50%-0.5px)]" />
-                    <PlusDecorator className="right-0 -translate-y-[calc(50%-0.5px)] translate-x-[calc(50%-0.5px)]" />
-                    <PlusDecorator className="bottom-0 right-0 translate-x-[calc(50%-0.5px)] translate-y-[calc(50%-0.5px)]" />
-                    <PlusDecorator className="bottom-0 -translate-x-[calc(50%-0.5px)] translate-y-[calc(50%-0.5px)]" />
+                    <PlusDecorator className="-translate-[calc(50%_-_0.5px)]" />
+                    <PlusDecorator className="right-0 -translate-y-[calc(50%_-_0.5px)] translate-x-[calc(50%_-_0.5px)]" />
+                    <PlusDecorator className="bottom-0 right-0 translate-x-[calc(50%_-_0.5px)] translate-y-[calc(50%_-_0.5px)]" />
+                    <PlusDecorator className="bottom-0 -translate-x-[calc(50%_-_0.5px)] translate-y-[calc(50%_-_0.5px)]" />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/how-it-works-3.tsx` around lines 31 - 34, The Tailwind
arbitrary translate classes use invalid calc syntax (calc(50%-0.5px)) so update
the four PlusDecorator className values to use a valid calc with spaces encoded
as underscores for Tailwind, e.g. change -translate-[calc(50%-0.5px)] to
-translate-[calc(50%_-_0.5px)], translate-x-[calc(50%-0.5px)] to
translate-x-[calc(50%_-_0.5px)], the pair translate-x-[calc(50%-0.5px)]
translate-y-[calc(50%-0.5px)] to translate-x-[calc(50%_-_0.5px)]
translate-y-[calc(50%_-_0.5px)], and -translate-x-[calc(50%-0.5px)]
translate-y-[calc(50%-0.5px)] to -translate-x-[calc(50%_-_0.5px)]
translate-y-[calc(50%_-_0.5px)] so Tailwind emits valid CSS for the
PlusDecorator positions.
scripts/sync-templates.mjs-193-200 (1)

193-200: ⚠️ Potential issue | 🟡 Minor

Rewrite object-style /docs footer links too.

The current transform only catches JSX href="/docs", but the new footer stores links as { href: '/docs' }, so the generated template still points Docs at /docs instead of /.

🔧 Proposed fix
       // single quotes inside JS link objects).
       .replace(/href="\/docs"/g, 'href="/"')
+      .replace(/href:\s*(['"])\/docs\1/g, "href: '/'")
       .replace(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/sync-templates.mjs` around lines 193 - 200, The current replacement
chain only handles JSX attributes (.replace(/href="\/docs"/g, 'href="/"')) but
misses object-style links like { href: '/docs' }; update the replacement
sequence in scripts/sync-templates.mjs (the chain of .replace(...) calls that
transforms `content`) to also replace object-style href values by adding a regex
replacement for patterns like href:\s*['"]/docs['"] so those links are rewritten
to href: '/' (preserve the existing replacement order and flags to run on the
same `content` string).
apps/web/components/comparator-7.tsx-135-142 (1)

135-142: ⚠️ Potential issue | 🟡 Minor

Give tooltip triggers descriptive accessible names.

The trigger’s accessible name is currently just ?, which does not tell assistive-tech users what it opens. Add an aria-label tied to the feature name.

Proposed fix
-                                                <TooltipTrigger className="flex size-7">
+                                                <TooltipTrigger
+                                                    aria-label={`More about ${feature.name}`}
+                                                    className="flex size-7">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/comparator-7.tsx` around lines 135 - 142, The tooltip
trigger currently uses a bare "?" which is not accessible; update the
TooltipTrigger element (the trigger for the Tooltip that renders when
feature.description is present) to include a descriptive accessible name tied to
the feature (e.g., set an aria-label that references feature.name such as "More
information about {feature.name}" or similar), ensuring the label clearly
describes what the tooltip opens so assistive tech users can understand its
purpose without seeing the visual "?" icon.
packages/create-helpbase/templates/components/ui/tooltip.tsx-18-26 (1)

18-26: ⚠️ Potential issue | 🟡 Minor

Remove the nested TooltipProvider from the Tooltip component.

The inner TooltipProvider (with default delayDuration={0}) shadows any outer provider configuration, making the exported TooltipProvider ineffective for consumers who want to customize shared delay settings. Let Tooltip render only the Radix root and require consumers to wrap with the explicit TooltipProvider.

Proposed fix
 function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
-    return (
-        <TooltipProvider>
-            <TooltipPrimitive.Root
-                data-slot="tooltip"
-                {...props}
-            />
-        </TooltipProvider>
-    )
+    return (
+        <TooltipPrimitive.Root
+            data-slot="tooltip"
+            {...props}
+        />
+    )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/ui/tooltip.tsx` around lines 18
- 26, Remove the nested TooltipProvider from the Tooltip component so it no
longer overrides outer provider settings: update the Tooltip function (exported
component named Tooltip) to render only <TooltipPrimitive.Root ...props /> (keep
data-slot="tooltip" and prop forwarding) and delete the inner TooltipProvider
wrapper; require consumers to wrap usage with the exported TooltipProvider when
customizing delayDuration or other shared settings.
packages/create-helpbase/templates/components/ui/chart.tsx-312-322 (1)

312-322: ⚠️ Potential issue | 🟡 Minor

Use item.name for legend text fallback, not item.value.

ChartConfig.label is optional, but the legend renders only itemConfig?.label without fallback. The tooltip already demonstrates the correct pattern: itemConfig?.label ?? item.name. The proposed fix uses item.value (numeric data), which is inconsistent with the tooltip behavior and unsuitable for display text.

🐛 Corrected fix
-              {itemConfig?.label}
+              {itemConfig?.label ?? item.name}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/ui/chart.tsx` around lines 312
- 322, The legend rendering uses itemConfig?.label with no fallback and
currently the tooltip uses itemConfig?.label ?? item.name; update the legend to
follow the same pattern by replacing the fallback (do not use item.value) with
itemConfig?.label ?? item.name so display text shows the item's name when
ChartConfig.label is absent; locate the legend JSX where itemConfig, item, and
hideIcon are used (the block rendering the icon/div and `{itemConfig?.label}`)
and change that expression to use the nullish coalescing fallback to item.name,
ensuring types align with the component's props.
packages/create-helpbase/templates/components/ui/chart.tsx-13-13 (1)

13-13: ⚠️ Potential issue | 🟡 Minor

Don't skip custom tooltip formatting for falsy names.

TooltipNameType allows numbers and strings, so 0 or "" are valid names but currently bypass formatter due to the truthy check.

Proposed fix
-                {formatter && item?.value !== undefined && item.name ? (
+                {formatter && item?.value !== undefined && item.name != null ? (
                   formatter(item.value, item.name, item, index, item.payload)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/ui/chart.tsx` at line 13, The
tooltip currently skips calling the formatter when the name is falsy, which
drops valid names like 0 or an empty string; update the logic that uses
TooltipNameType to call the formatter for any value except null or undefined
(e.g., use an explicit nullish check like formatter != null or name !== null &&
name !== undefined) and ensure the fallback behavior converts the name to a
string only when formatter is absent; adjust the code paths referencing
TooltipNameType and the formatter in the Chart tooltip handling to use that
nullish check.
packages/create-helpbase/templates/components/logo.tsx-14-22 (1)

14-22: ⚠️ Potential issue | 🟡 Minor

Replace hardcoded SVG IDs with React's useId() hook.

Both Logo and LogoIcon components in this template use hardcoded id="paint_logo". When scaffolded into generated apps, multiple component instances will create duplicate document IDs and ambiguous url(#paint_logo) resolution.

Suggested fix
+import { useId } from 'react'
 import { cn } from '@/lib/utils'
 
 export const Logo = ({ className, uniColor }: { className?: string; uniColor?: boolean }) => {
+    const gradientId = useId()
+
     return (
         <svg
             className={cn('text-foreground h-5 w-full', className)}
@@
-                fill={uniColor ? 'currentColor' : 'url(`#paint_logo`)'}
+                fill={uniColor ? 'currentColor' : `url(#${gradientId})`}
@@
-                    id="paint_logo"
+                    id={gradientId}
@@
 export const LogoIcon = ({ className, uniColor }: { className?: string; uniColor?: boolean }) => {
+    const gradientId = useId()
+
     return (
         <svg
             className={cn('size-5', className)}
@@
-                fill={uniColor ? 'currentColor' : 'url(`#paint_logo`)'}
+                fill={uniColor ? 'currentColor' : `url(#${gradientId})`}
@@
-                    id="paint_logo"
+                    id={gradientId}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/logo.tsx` around lines 14 - 22,
The SVG uses a hardcoded id ("paint_logo") causing duplicate IDs; update both
Logo and LogoIcon to generate a unique id via React's useId() (or createUniqueId
helper), set the linearGradient id to that generated id, and change any
url(`#paint_logo`) usages to url(#${generatedId}); keep the uniColor branch using
'currentColor' and ensure the generated id is used in both components' fill
attributes and the <linearGradient id=...> declaration so each scaffolded
instance has a unique gradient id.
apps/web/components/illustrations/flow.tsx-14-26 (1)

14-26: ⚠️ Potential issue | 🟡 Minor

Make the beam keyframes global and motion-safe.

<style jsx> scopes @keyframes with a hash (e.g., beam-move-jsx-123456789), while the Tailwind animate-[beam-move_...] classes reference the unscoped name, leaving the beams static. Use <style jsx global> to prevent hashing. Also gate the infinite decorative animation for users with reduced-motion preferences, following the project's existing pattern.

Additionally, delay-2000 is a transition-delay utility and won't delay CSS animations; use motion-safe:[animation-delay:2s] instead.

Suggested fix
-            <style jsx>{`
-                `@keyframes` beam-move {
+            <style jsx global>{`
+                `@keyframes` flow-beam-move {
                     to {
                         stroke-dashoffset: -780;
                     }
                 }
 
-                `@keyframes` beam-move-down {
+                `@keyframes` flow-beam-move-down {
                     to {
                         stroke-dashoffset: 780;
                     }
                 }
@@
-                    className="animate-[beam-move_6.4s_linear_infinite]"
+                    className="motion-safe:animate-[flow-beam-move_6.4s_linear_infinite] motion-reduce:animate-none"
@@
-                    className="drop-shadow-purple-300 dark:drop-shadow-transparent delay-2000 animate-[beam-move_6.4s_linear_infinite] drop-shadow-sm"
+                    className="drop-shadow-purple-300 dark:drop-shadow-transparent motion-safe:animate-[flow-beam-move_6.4s_linear_infinite] motion-safe:[animation-delay:2s] motion-reduce:animate-none drop-shadow-sm"
@@
-                    className="animate-[beam-move_6.4s_linear_infinite]"
+                    className="motion-safe:animate-[flow-beam-move_6.4s_linear_infinite] motion-reduce:animate-none"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/flow.tsx` around lines 14 - 26, The beam
keyframes are currently scoped by <style jsx> so Tailwind classes like
animate-[beam-move_...] don't match; change the block to <style jsx global> and
declare the `@keyframes` names beam-move and beam-move-down so they are global,
and wrap the animation rules with the motion-safe pattern (e.g., `@media`
(prefers-reduced-motion: no-preference) or Tailwind's motion-safe) so users who
prefer reduced motion don't see the infinite decoration; also replace the
Tailwind delay-2000 utility (which affects transitions) with
motion-safe:[animation-delay:2s] on the elements using the animation to actually
delay the CSS animation.
registry/helpbase/components/logo.tsx-14-22 (1)

14-22: ⚠️ Potential issue | 🟡 Minor

Use unique SVG gradient ids for each component instance.

Both Logo and LogoIcon define and reference id="paint_logo". When multiple instances render on the same page, duplicate document IDs cause SVG fragment references to resolve to the wrong gradient definition. Use React's useId hook to generate per-instance ids.

Suggested fix
+import { useId } from 'react'
 import { cn } from '@/lib/utils'
 
 export const Logo = ({ className, uniColor }: { className?: string; uniColor?: boolean }) => {
+    const gradientId = useId()
+
     return (
         <svg
             className={cn('text-foreground h-5 w-full', className)}
@@ -11,7 +14,7 @@
                 fillRule="evenodd"
                 clipRule="evenodd"
                 d="M80 100H28C12.536 100 0 87.464 0 72V28C0 12.536 12.536 0 28 0H72C87.464 0 100 12.536 100 28V80H160C171.046 80 180 88.9543 180 100V167.639C180 175.215 175.72 182.14 168.944 185.528L103.416 218.292C101.17 219.415 98.6923 220 96.1803 220C87.2442 220 80 212.756 80 203.82V100ZM28 20C23.5817 20 20 23.5817 20 28V72C20 76.4183 23.5817 80 28 80H80V28C80 23.5817 76.4183 20 72 20H28ZM100 100H152C156.418 100 160 103.582 160 108V165.092C160 168.103 158.309 170.859 155.625 172.224L111.625 194.591C106.303 197.296 100 193.429 100 187.459V100Z"
-                fill={uniColor ? 'currentColor' : 'url(`#paint_logo`)'}
+                fill={uniColor ? 'currentColor' : `url(#${gradientId})`}
             />
             <path
                 d="M272.366 96.0719V150.886C272.366 154.6 273.205 157.296 274.884 158.973C276.682 160.531 279.679 161.309 283.874 161.309H296.461V178.383H280.278C271.048 178.383 263.975 176.226 259.06 171.913C254.145 167.599 251.688 160.59 251.688 150.886V96.0719H240V79.3582H251.688V54.7368H272.366V79.3582H296.461V96.0719H272.366ZM306.723 128.421C306.723 118.477 308.761 109.671 312.837 102.003C317.032 94.3346 322.666 88.4039 329.739 84.2105C336.932 79.8973 344.843 77.7407 353.474 77.7407C361.266 77.7407 368.039 79.2982 373.793 82.4133C379.667 85.4086 384.342 89.1827 387.818 93.7356V79.3582H408.497V178.383H387.818V163.646C384.342 168.318 379.607 172.212 373.613 175.327C367.62 178.442 360.787 180 353.115 180C344.604 180 336.812 177.843 329.739 173.53C322.666 169.097 317.032 162.987 312.837 155.199C308.761 147.291 306.723 138.365 306.723 128.421ZM387.818 128.78C387.818 121.951 386.38 116.021 383.503 110.988C380.746 105.956 377.09 102.122 372.534 99.4865C367.979 96.8507 363.064 95.5327 357.79 95.5327C352.515 95.5327 347.6 96.8507 343.045 99.4865C338.49 102.003 334.774 105.777 331.897 110.809C329.14 115.721 327.761 121.592 327.761 128.421C327.761 135.25 329.14 141.241 331.897 146.393C334.774 151.545 338.49 155.498 343.045 158.254C347.72 160.89 352.635 162.208 357.79 162.208C363.064 162.208 367.979 160.89 372.534 158.254C377.09 155.618 380.746 151.784 383.503 146.752C386.38 141.6 387.818 135.61 387.818 128.78ZM444.052 66.2388C440.336 66.2388 437.219 64.9807 434.702 62.4647C432.184 59.9487 430.926 56.8336 430.926 53.1194C430.926 49.4052 432.184 46.2901 434.702 43.7741C437.219 41.258 440.336 40 444.052 40C447.648 40 450.705 41.258 453.222 43.7741C455.74 46.2901 456.999 49.4052 456.999 53.1194C456.999 56.8336 455.74 59.9487 453.222 62.4647C450.705 64.9807 447.648 66.2388 444.052 66.2388ZM454.122 79.3582V178.383H433.623V79.3582H454.122ZM499.735 45.3915V178.383H479.236V45.3915H499.735ZM518.017 128.421C518.017 118.477 520.055 109.671 524.13 102.003C528.326 94.3346 533.96 88.4039 541.033 84.2105C548.225 79.8973 556.137 77.7407 564.768 77.7407C572.56 77.7407 579.333 79.2982 585.087 82.4133C590.961 85.4086 595.636 89.1827 599.112 93.7356V79.3582H619.791V178.383H599.112V163.646C595.636 168.318 590.901 172.212 584.907 175.327C578.913 178.442 572.08 180 564.408 180C555.897 180 548.105 177.843 541.033 173.53C533.96 169.097 528.326 162.987 524.13 155.199C520.055 147.291 518.017 138.365 518.017 128.421ZM599.112 128.78C599.112 121.951 597.674 116.021 594.797 110.988C592.04 105.956 588.383 102.122 583.828 99.4865C579.273 96.8507 574.358 95.5327 569.084 95.5327C563.809 95.5327 558.894 96.8507 554.339 99.4865C549.784 102.003 546.068 105.777 543.191 110.809C540.433 115.721 539.055 121.592 539.055 128.421C539.055 135.25 540.433 141.241 543.191 146.393C546.068 151.545 549.784 155.498 554.339 158.254C559.014 160.89 563.929 162.208 569.084 162.208C574.358 162.208 579.273 160.89 583.828 158.254C588.383 155.618 592.04 151.784 594.797 146.752C597.674 141.6 599.112 135.61 599.112 128.78ZM665.415 93.7356C668.412 88.7035 672.368 84.8096 677.283 82.0539C682.318 79.1784 688.251 77.7407 695.084 77.7407V98.9474H689.87C681.838 98.9474 675.724 100.984 671.529 105.058C667.453 109.131 665.415 116.2 665.415 126.264V178.383H644.917V79.3582H665.415V93.7356ZM751.328 128.96L797 178.383H769.309L732.627 135.789V178.383H712.129V45.3915H732.627V122.67L768.59 79.3582H797L751.328 128.96Z"
-                fill="currentColor"
+                fill={uniColor ? 'currentColor' : `url(#${gradientId})`}
             />
             <defs>
                 <linearGradient
-                    id="paint_logo"
+                    id={gradientId}
                     x1="90"
                     y1="0"
                     x2="90"
@@ -42,7 +45,10 @@
     )
 }
 
 export const LogoIcon = ({ className, uniColor }: { className?: string; uniColor?: boolean }) => {
+    const gradientId = useId()
+
     return (
         <svg
             className={cn('size-5', className)}
@@ -53,7 +59,7 @@
                 fillRule="evenodd"
                 clipRule="evenodd"
                 d="M80 100H28C12.536 100 0 87.464 0 72V28C0 12.536 12.536 0 28 0H72C87.464 0 100 12.536 100 28V80H160C171.046 80 180 88.9543 180 100V167.639C180 175.215 175.72 182.14 168.944 185.528L103.416 218.292C101.17 219.415 98.6923 220 96.1803 220C87.2442 220 80 212.756 80 203.82V100ZM28 20C23.5817 20 20 23.5817 20 28V72C20 76.4183 23.5817 80 28 80H80V28C80 23.5817 76.4183 20 72 20H28ZM100 100H152C156.418 100 160 103.582 160 108V165.092C160 168.103 158.309 170.859 155.625 172.224L111.625 194.591C106.303 197.296 100 193.429 100 187.459V100Z"
-                fill={uniColor ? 'currentColor' : 'url(`#paint_logo`)'}
+                fill={uniColor ? 'currentColor' : `url(#${gradientId})`}
             />
             <defs>
                 <linearGradient
-                    id="paint_logo"
+                    id={gradientId}
                     x1="90"
                     y1="0"
                     x2="90"

The same issue applies to duplicate logos in packages/create-helpbase/templates/components/logo.tsx and apps/web/components/logo.tsx.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@registry/helpbase/components/logo.tsx` around lines 14 - 22, The SVG gradient
id "paint_logo" is duplicated across component instances (in Logo and LogoIcon),
causing incorrect gradient references when multiple logos render; update the
components (e.g., Logo, LogoIcon) to generate a unique id per instance using
React's useId and replace the hardcoded id="paint_logo" and its URL reference
(fill="url(`#paint_logo`)") with the generated id (e.g., id={paintId} and
fill={`url(#${paintId})`}); apply the same change to the other duplicate files
mentioned so each component instance has a unique gradient id.
🧹 Nitpick comments (9)
apps/web/components/footer.tsx (2)

37-49: Nit: md:col-span-2 has no effect on a flex parent.

The outer container at line 37 is flex flex-wrap, so the md:col-span-2 on this child (line 38) is a no-op. Safe to drop, or switch the parent to a grid if the intent was two-column sizing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/footer.tsx` around lines 37 - 49, The child div in Footer
component using className "max-w-xs space-y-6 md:col-span-2" is inside a parent
with "flex flex-wrap", so the responsive utility md:col-span-2 is ineffective;
either remove the md:col-span-2 token from that div to clean up the markup or
change the parent container (the element with className "flex flex-wrap
justify-between gap-6") to use a grid layout (e.g., replace flex with grid and
appropriate grid-cols classes) if you intended column spanning—update the
className on the div and/or parent accordingly in the Footer component to make
the layout behavior explicit.

98-108: Minor: consider including noopener alongside noreferrer for consistency.

External links in the top section (lines 55, 69) use rel="noopener noreferrer", but the data-driven link loop sets only rel: 'noreferrer'. Modern browsers treat noreferrer as implying noopener, so this is not a security bug — just a consistency nit with the rest of this file.

Proposed tweak
                                             {...(item.href.startsWith('http') && {
                                                 target: '_blank',
-                                                rel: 'noreferrer',
+                                                rel: 'noopener noreferrer',
                                             })}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/footer.tsx` around lines 98 - 108, The data-driven Link
rendering in the footer (the Link element that uses item.title and item.href)
currently sets rel: 'noreferrer' for external URLs; update that spread to
include rel: 'noopener noreferrer' when item.href.startsWith('http') so it
matches the rest of the file and is consistent across external links (i.e.,
change the conditional spread for target/rel to set rel to 'noopener noreferrer'
alongside target: '_blank').
apps/web/components/illustrations/agent-tasks.tsx (1)

21-21: Conflicting block and flex utilities on the same element.

className contains both block and flex; Tailwind emits both display declarations and the later one wins, which is fragile and confusing. Since the badge centers its number, drop block.

♻️ Proposed cleanup
-                            <span className="bg-muted-foreground/75 text-background block flex size-4 items-center justify-center rounded-full text-[10px]">1</span>
+                            <span className="bg-muted-foreground/75 text-background flex size-4 items-center justify-center rounded-full text-[10px]">1</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/agent-tasks.tsx` at line 21, The span in
agent-tasks.tsx uses conflicting Tailwind display utilities ("block" and
"flex"); remove the "block" token from the className on the span that renders
the badge (the element with className containing "bg-muted-foreground/75
text-background block flex size-4 ...") so only "flex" remains to center the
content and avoid emitting two display declarations.
apps/web/components/illustrations/models.tsx (2)

10-13: Type name should be PascalCase.

type model violates the TypeScript convention that types/interfaces use PascalCase (Model). This also shadows the loop variable model on lines 36/49/54, making the code harder to read.

♻️ Suggested change
-type model = {
+type Model = {
     name: string
     icon: React.ReactNode
 }
@@
-    const upModels: model[] = [
+    const upModels: Model[] = [
@@
-    const bottomModels: model[] = [
+    const bottomModels: Model[] = [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/models.tsx` around lines 10 - 13, The type
name "model" should be renamed to PascalCase "Model" and all references updated
(e.g., the type declaration and any usages) to follow TypeScript conventions;
also remove the shadowing of the loop variable named "model" by renaming either
the loop variable (e.g., to "m" or "item") or the type if used as a value, and
update usages in functions/components that reference the type or the loop
variable (look for the declaration "type model" and the loop occurrences where
"model" is iterated around lines with icons/title rendering). Ensure
imports/exports and JSX prop typings that referenced the old name are updated
accordingly.

36-57: Array index as React key + duplicated render block.

Two minor points:

  1. key={index} is acceptable for static arrays but it's conventional to key on a stable identifier like model.name, which is unique here.
  2. The up/bottom map bodies are nearly identical — only the style transform differs. Consider extracting a small ModelRow helper to DRY the render and centralize the opacity/rotateX formula.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/models.tsx` around lines 36 - 57, Both map
blocks use unstable index keys and duplicate JSX; replace key={index} with a
stable key like model.name in the upModels and bottomModels maps, and extract a
small presentational helper component (e.g., ModelRow) that accepts props
(model, rotateDeg, origin) or a boolean variant to centralize the opacity and
rotateX calculation; then render <ModelRow model={model} .../> for both upModels
and bottomModels and keep the middle MistralAi block as-is.
apps/web/components/ui/svgs/deepseek.tsx (1)

4-4: Props spread is overridden by hardcoded style.

Spreading {...props} before style means any style passed by the caller is silently discarded. If the intent is to allow style overrides (common for icon components), swap the order; otherwise this is fine as a deliberate lockdown. Same pattern appears in qwen.tsx.

♻️ Suggested change
-  <svg {...props} style={{ flex: "none", lineHeight: "1" }} viewBox="0 0 24 24">
+  <svg style={{ flex: "none", lineHeight: "1" }} viewBox="0 0 24 24" {...props}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/ui/svgs/deepseek.tsx` at line 4, The SVG component
currently spreads {...props} before applying a hardcoded style which prevents
caller-provided style from taking effect; update deepseek.tsx (and the same
pattern in qwen.tsx) so caller styles can override: either move {...props} after
the explicit style on the <svg> element or explicitly merge styles (e.g.,
style={{ flex: "none", lineHeight: "1", ...(props.style || {}) }} while
spreading the rest of props without style). Ensure you reference the <svg
{...props} style={{...}}> usage and adjust accordingly so external style props
are not discarded.
packages/create-helpbase/templates/components/ui/infinite-slider.tsx (1)

17-22: Initial render animates over near-zero duration.

Before useMeasure reports a size, width/height are 0, so contentSize = gap and the first animation runs for gap / speed seconds (e.g. 0.16s at defaults) before the effect re-runs with real measurements. In practice this is a brief flash of fast motion on mount. Consider early-returning when size === 0:

♻️ Proposed guard
     useEffect(() => {
         let controls
         const size = direction === 'horizontal' ? width : height
+        if (!size) return
         const contentSize = size + gap
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/ui/infinite-slider.tsx` around
lines 17 - 22, InfiniteSlider currently starts its animation before useMeasure
reports real dimensions (width/height === 0) which causes a brief flash; update
the component (inside InfiniteSlider) to guard early — compute contentSize from
width/height and gap and if contentSize === 0 return a non-animated placeholder
(e.g., render children wrapped but skip motion/value setup and effects) until
useMeasure provides a non-zero size; ensure any state like translation,
isTransitioning, and effects that start animations (the logic driven by
currentSpeed/key/translation) are skipped when size is zero so the first
animation only runs after real measurements are available.
packages/create-helpbase/templates/components/ui/text-scramble.tsx (1)

1-86: Triplicated source — apps/web, registry/helpbase, and this template now diverge by copy.

Per the relevant code snippet, this file is byte-for-byte identical to apps/web/components/ui/text-scramble.tsx and to registry/helpbase/components/ui/text-scramble.tsx. Any future bug fix (including the lifecycle issues flagged above) must be applied in three places, which historically drifts. Consider generating the template/registry copies from a single source (e.g., a build-time copy step from the registry into packages/create-helpbase/templates) so fixes propagate automatically.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/ui/text-scramble.tsx` around
lines 1 - 86, This file duplicates the TextScramble component across multiple
locations; refactor so there's a single source of truth (the TextScramble
component) and have the template/registry copies generated automatically at
build or publish time. Move the TextScramble implementation into one shared
module (or export it from a single package) and update the templates step to
copy or import that single source during the build/publish pipeline so fixes to
the TextScramble function/class propagate automatically; ensure references to
the motion-based MotionComponent and props (TextScramble, scramble,
onScrambleComplete) are preserved in the shared implementation.
registry/helpbase/components/ui/tooltip.tsx (1)

18-25: Don’t create a new provider inside every Tooltip root.

This makes the exported TooltipProvider ineffective for callers that wrap a subtree with custom provider props, because each Tooltip adds an inner provider with default settings.

♻️ Proposed refactor
 function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
     return (
-        <TooltipProvider>
-            <TooltipPrimitive.Root
-                data-slot="tooltip"
-                {...props}
-            />
-        </TooltipProvider>
+        <TooltipPrimitive.Root
+            data-slot="tooltip"
+            {...props}
+        />
     )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@registry/helpbase/components/ui/tooltip.tsx` around lines 18 - 25, The
Tooltip component currently wraps each instance with a TooltipProvider which
prevents callers from using a shared/custom provider; remove the inner provider
so Tooltip simply renders TooltipPrimitive.Root (keep data-slot and props) and
rely on consumers to wrap their tree with TooltipProvider (export
TooltipProvider as-is if not already). Update any tests/docs to reflect that
Tooltip no longer creates its own provider and ensure consumers that need
provider behavior wrap a parent with TooltipProvider.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: db5b6a60-6a95-4b96-843c-49f6bd6db0a1

📥 Commits

Reviewing files that changed from the base of the PR and between 91bc639 and e04c99c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (53)
  • apps/web/app/(main)/docs/page.tsx
  • apps/web/app/(marketing)/page.tsx
  • apps/web/components/bento-2.tsx
  • apps/web/components/comparator-7.tsx
  • apps/web/components/faqs-3.tsx
  • apps/web/components/features-1.tsx
  • apps/web/components/footer.tsx
  • apps/web/components/header.tsx
  • apps/web/components/how-it-works-3.tsx
  • apps/web/components/illustrations/agent-tasks.tsx
  • apps/web/components/illustrations/document-analysis.tsx
  • apps/web/components/illustrations/flow.tsx
  • apps/web/components/illustrations/mdx-source-preview.tsx
  • apps/web/components/illustrations/models.tsx
  • apps/web/components/illustrations/preview-url-card.tsx
  • apps/web/components/illustrations/uptime.tsx
  • apps/web/components/illustrations/workflow.tsx
  • apps/web/components/marketing/demo-cross-link.tsx
  • apps/web/components/marketing/hero.tsx
  • apps/web/components/pricing.tsx
  • apps/web/components/ui/illustrations/product-illustration.tsx
  • apps/web/components/ui/svgs/cohere.tsx
  • apps/web/components/ui/svgs/deepseek.tsx
  • apps/web/components/ui/svgs/firebase.tsx
  • apps/web/components/ui/svgs/mistral-ai.tsx
  • apps/web/components/ui/svgs/openai.tsx
  • apps/web/components/ui/svgs/perplexity-ai.tsx
  • apps/web/components/ui/svgs/qwen.tsx
  • apps/web/lib/analytics.ts
  • apps/web/test/analytics.test.ts
  • apps/web/test/middleware-subdomain.test.ts
  • packages/create-helpbase/templates/app/(main)/page.tsx
  • packages/create-helpbase/templates/components/footer.tsx
  • packages/create-helpbase/templates/components/logo.tsx
  • packages/create-helpbase/templates/components/ui/card.tsx
  • packages/create-helpbase/templates/components/ui/chart.tsx
  • packages/create-helpbase/templates/components/ui/infinite-slider.tsx
  • packages/create-helpbase/templates/components/ui/input.tsx
  • packages/create-helpbase/templates/components/ui/label.tsx
  • packages/create-helpbase/templates/components/ui/text-scramble.tsx
  • packages/create-helpbase/templates/components/ui/tooltip.tsx
  • registry/helpbase/components/footer.tsx
  • registry/helpbase/components/logo.tsx
  • registry/helpbase/components/ui/card.tsx
  • registry/helpbase/components/ui/chart.tsx
  • registry/helpbase/components/ui/infinite-slider.tsx
  • registry/helpbase/components/ui/input.tsx
  • registry/helpbase/components/ui/label.tsx
  • registry/helpbase/components/ui/text-scramble.tsx
  • registry/helpbase/components/ui/tooltip.tsx
  • scripts/smoke-deploy.sh
  • scripts/sync-templates.mjs
  • supabase/migrations/20260419000100_marketing_events_drop_anon_insert.sql
💤 Files with no reviewable changes (1)
  • apps/web/components/ui/illustrations/product-illustration.tsx
✅ Files skipped from review due to trivial changes (10)
  • scripts/smoke-deploy.sh
  • apps/web/components/ui/svgs/firebase.tsx
  • apps/web/components/illustrations/mdx-source-preview.tsx
  • apps/web/test/middleware-subdomain.test.ts
  • apps/web/components/illustrations/preview-url-card.tsx
  • apps/web/components/features-1.tsx
  • apps/web/components/ui/svgs/mistral-ai.tsx
  • apps/web/app/(main)/docs/page.tsx
  • packages/create-helpbase/templates/app/(main)/page.tsx
  • apps/web/components/ui/svgs/cohere.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/web/components/marketing/demo-cross-link.tsx
  • apps/web/components/faqs-3.tsx
  • apps/web/components/bento-2.tsx
  • apps/web/app/(marketing)/page.tsx
  • apps/web/components/pricing.tsx
  • apps/web/components/header.tsx

Comment on lines +65 to +101
const renderPlanColumn = (plan: Plan) => {
const isPrimary = plan === 'helpbase'
const header = (
<div className={cn('sticky top-0 flex h-14 flex-col items-center justify-center gap-1.5 px-4 text-center lg:px-6', isPrimary && 'rounded-t-xl')}>
<span className={cn('text-foreground text-sm font-semibold', isPrimary && 'text-primary')}>{planLabels[plan]}</span>
</div>
)

return (
<div
data-plan={plan}
className={cn(isPrimary && 'ring-border bg-card/50 shadow-black/6.5 relative z-10 rounded-xl shadow-xl ring-1')}>
{header}

<div>
{features.map((feature, index) => {
const value = feature.plans[plan]
return (
<div
key={index}
className="odd:bg-card flex h-14 items-center justify-center px-4 text-sm last:h-[calc(3.5rem+1px)] last:border-b group-last:odd:rounded-r-lg">
<div className="text-center">
{value === true ? (
<Indicator checked />
) : value === false ? (
<Indicator />
) : (
<span className={cn('text-muted-foreground text-xs font-medium', isPrimary && 'text-foreground')}>{value}</span>
)}
</div>
</div>
)
})}
<div className="h-6"></div>
</div>
</div>
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expose the comparison as a real table or equivalent ARIA structure.

This is visually a feature comparison table, but the current <div> grid does not provide row/column header associations, and boolean cells expose only an icon or . Use a native <table> with <th scope="col|row"> where possible, or add complete table/grid roles plus accessible labels such as “Included” / “Not included”.

Also applies to: 124-134, 149-155, 163-172

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/comparator-7.tsx` around lines 65 - 101, The comparison
grid built in renderPlanColumn currently uses divs and icons which lack proper
table semantics; change the comparator to use a native table or a fully
accessible ARIA grid by converting plan headers into <th scope="col"> elements
(the header returned by renderPlanColumn) and emitting row headers for each
feature (the feature label cells produced in the features.map), and ensure
boolean cells (where Indicator is used) expose readable state via visible or
visually-hidden text like "Included" / "Not included" and an appropriate
aria-label or aria-labelledby tied to the corresponding row/column headers;
update the Indicator usage so it includes accessible text for screen readers and
apply the same table/ARIA header and cell-label fixes to the other column
renderers referenced in the comment (the other comparator column render
functions that produce the grid at the noted ranges).

Comment on lines +13 to +15
title: "Preview",
body: "pnpm dev runs it locally. helpbase deploy --preview pushes a draft to a shareable URL without touching your production site.",
visual: <PreviewUrlCard />,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the decorative preview card out of the tab order.

PreviewUrlCard is used here as an illustration, but the referenced component contains a real <button> inside an aria-hidden container. That creates a focusable fake control that may be hidden from assistive tech and does nothing when activated. Prefer making the button markup non-interactive in apps/web/components/illustrations/preview-url-card.tsx.

♿ Proposed fix in the preview illustration component
-      <button
-        type="button"
+      <span
         className="bg-foreground/5 ring-border/60 text-foreground hover:bg-foreground/10 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 ring-1 transition-colors">
         <Copy className="size-3" />
         Copy link
-      </button>
+      </span>

Also applies to: 59-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/how-it-works-3.tsx` around lines 13 - 15, The
PreviewUrlCard used in how-it-works-3.tsx contains an actual <button> inside an
aria-hidden decorative container; replace that interactive element inside
apps/web/components/illustrations/preview-url-card.tsx with non-interactive
markup so it cannot receive focus or be activated (e.g., change the <button> to
a <div> or <span>, remove any onClick/tabIndex/role that makes it focusable, and
ensure the surrounding decorative wrapper remains aria-hidden="true"), and
verify the component (PreviewUrlCard) no longer exposes focusable controls when
used as a visual-only illustration.

Comment on lines +11 to +13
<div
aria-hidden
className="relative flex min-h-[420px] w-fit min-w-[420px] flex-col items-center">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find FlowIllustration call sites and inspect nearby layout wrappers.
rg -n -C5 '<FlowIllustration|FlowIllustration' --glob '*.tsx'

Repository: Codehagen/Helpbase

Length of output: 2736


🏁 Script executed:

# Get the full context of features-1.tsx to see outer wrappers
cat -n apps/web/components/features-1.tsx

Repository: Codehagen/Helpbase

Length of output: 3099


🏁 Script executed:

# Check if FlowIllustration has any responsive utilities or media queries
cat -n apps/web/components/illustrations/flow.tsx

Repository: Codehagen/Helpbase

Length of output: 12485


Remove or make responsive the min-w-[420px] constraint on mobile viewports.

On 320–390px devices, the illustration overflows its parent container (which has overflow-hidden), causing content to be clipped. The outer grid in features-1.tsx provides only ~276px of width on mobile (after px-8 padding), but min-w-[420px] forces the illustration wider. Consider:

  • Removing min-w-[420px] and letting w-fit size naturally, or
  • Adding responsive variants like md:min-w-[420px] to apply the constraint only above mobile breakpoints.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/flow.tsx` around lines 11 - 13, The div in
apps/web/components/illustrations/flow.tsx currently forces min-w-[420px],
causing overflow on small viewports; update the element's className to remove
the hard min width or make it responsive (e.g., replace min-w-[420px] with a
breakpoint-limited variant such as md:min-w-[420px]) so the illustration can
shrink on mobile while retaining the constraint on larger screens; locate the
div with className="relative flex min-h-[420px] w-fit min-w-[420px] flex-col
items-center" and apply the change there.

Comment on lines +10 to +14
{Array.from({ length: 40 }).map((_, index) => (
<div
key={index}
className="[:nth-child(10)]:bg-muted-foreground [:nth-child(11)]:bg-muted-foreground [:nth-child(22)]:bg-muted-foreground [:nth-child(23)]:bg-muted-foreground [:nth-child(24)]:bg-muted-foreground [:nth-child(32)]:bg-muted-foreground h-7 w-0.5 bg-emerald-500"></div>
))}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Arbitrary variant selectors are missing & — nth-child highlights will never apply.

In Tailwind arbitrary variants, the selector must reference the element itself with &. [:nth-child(10)]:bg-muted-foreground compiles to a selector like .\[\:nth-child\(10\)\]\:bg-muted-foreground:nth-child(10) only when written as [&:nth-child(10)]:.... Without the &, Tailwind treats it as a descendant selector (:nth-child(10) .bg-...) which matches nothing here, so every bar renders as bg-emerald-500 and the intended muted markers at positions 10, 11, 22–24, and 32 are lost.

🛠️ Proposed fix
-                    className="[:nth-child(10)]:bg-muted-foreground [:nth-child(11)]:bg-muted-foreground [:nth-child(22)]:bg-muted-foreground [:nth-child(23)]:bg-muted-foreground [:nth-child(24)]:bg-muted-foreground [:nth-child(32)]:bg-muted-foreground h-7 w-0.5 bg-emerald-500"></div>
+                    className="[&:nth-child(10)]:bg-muted-foreground [&:nth-child(11)]:bg-muted-foreground [&:nth-child(22)]:bg-muted-foreground [&:nth-child(23)]:bg-muted-foreground [&:nth-child(24)]:bg-muted-foreground [&:nth-child(32)]:bg-muted-foreground h-7 w-0.5 bg-emerald-500"></div>
Tailwind CSS arbitrary variant syntax for nth-child — is & required?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/illustrations/uptime.tsx` around lines 10 - 14, The
nth-child arbitrary variant selectors in the className string on the mapped divs
are missing the required "&" so the highlights never apply; update each
`[:nth-child(...)]` token to use `[&:nth-child(...)]` (e.g., change
`[:nth-child(10)]:bg-muted-foreground` to
`[&:nth-child(10)]:bg-muted-foreground`) inside the mapped div's className so
the Tailwind selectors target the element itself and the muted markers at
indices 10, 11, 22–24, and 32 render correctly.

Comment on lines +32 to +35
Object.defineProperty(window, "location", {
value: { pathname: "/", search: "" },
writable: true,
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Potential window.location redefinitions:"
rg -n 'Object\.defineProperty\(window,\s*["'\'']location["'\'']' --glob '*test*' || true

echo
echo "JSDOM/Vitest versions in manifests and lockfiles:"
fd -HI '^(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$' -x sh -c '
  printf "\n--- %s ---\n" "$1"
  rg -n "jsdom|vitest" "$1" || true
' sh {}

Repository: Codehagen/Helpbase

Length of output: 5950


🏁 Script executed:

cat -n apps/web/test/analytics.test.ts | head -80

Repository: Codehagen/Helpbase

Length of output: 3276


🏁 Script executed:

cat -n apps/web/src/lib/analytics.ts

Repository: Codehagen/Helpbase

Length of output: 123


🏁 Script executed:

fd -type f -name "analytics*" apps/web/src

Repository: Codehagen/Helpbase

Length of output: 232


🏁 Script executed:

fd -type f -name "analytics*" apps/web/src

Repository: Codehagen/Helpbase

Length of output: 232


🏁 Script executed:

fd -type f "analytics" apps/web/src

Repository: Codehagen/Helpbase

Length of output: 232


🏁 Script executed:

find apps/web/src -name "*analytics*"

Repository: Codehagen/Helpbase

Length of output: 109


🏁 Script executed:

find apps/web -name "*analytics*" -type f

Repository: Codehagen/Helpbase

Length of output: 119


🏁 Script executed:

cat -n apps/web/lib/analytics.ts

Repository: Codehagen/Helpbase

Length of output: 1800


🏁 Script executed:

web_search
JSDOM 29.0.2 window.location Object.defineProperty redefine behavior

Repository: Codehagen/Helpbase

Length of output: 156


🌐 Web query:

JSDOM 29.0.2 window.location non-configurable unforgeable defineProperty behavior

💡 Result:

In JSDOM version 29.0.2, the window.location property is non-configurable and unforgeable, matching web browser standards. This means attempts to redefine it using Object.defineProperty(window, 'location', {...}) will throw a "Cannot redefine property: location" error. This behavior was introduced in JSDOM 21.0.0 as a bug fix to make window, document, location, and top non-configurable, aligning with web specifications where these are [LegacyUnforgeable] or [Unforgeable]. It has been maintained through version 29.0.2, as confirmed by ongoing GitHub issues (#3492, #3739) where users report the same behavior in recent versions, and no changelog entries in 29.x reverse it. Official JSDOM documentation notes that properties like top (and similarly location) cannot be overridden by normal code running inside JSDOM, even with Object.defineProperty. To change the URL, use jsdom.reconfigure({ url: 'new-url' }) instead of direct property manipulation. This design prevents test code from easily mocking window.location (common in Jest), leading to widespread workarounds like patching JSDOM source, using history.pushState, or propertyOverrides in the JSDOM constructor.

Citations:


🏁 Script executed:

cat -n apps/web/test/analytics.test.ts | tail -30

Repository: Codehagen/Helpbase

Length of output: 1253


Use the History API instead of redefining window.location.

Lines 32 and 67 will throw in JSDOM 29.0.2 because window.location is non-configurable per web standards. Use window.history.replaceState() / pushState() to set the pathname and search parameters while preserving the real Location object.

Proposed test fix
-    Object.defineProperty(window, "location", {
-      value: { pathname: "/", search: "" },
-      writable: true,
-    })
+    window.history.replaceState(null, "", "/")
@@
-    Object.defineProperty(window, "location", {
-      value: { pathname: "/docs/intro", search: "?ref=twitter" },
-      writable: true,
-    })
+    window.history.pushState(null, "", "/docs/intro?ref=twitter")

Also applies to: 67-70

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/test/analytics.test.ts` around lines 32 - 35, The tests currently
redefine window.location using Object.defineProperty (the block setting value: {
pathname: "/", search: "" }) which fails under modern JSDOM because Location is
non-configurable; instead update the tests in analytics.test.ts to use the
History API (window.history.replaceState or pushState) to set the desired
pathname and search before asserting analytics behavior, and remove the
Object.defineProperty calls (also replace the similar usage around lines where
pathname/search are set later in the file). Ensure you call
replaceState/pushState with the correct URL (including query string) so the real
window.location reflects the intended pathname and search for the rest of the
test.

</div>
</footer>
)
import { Button } from '@/components/ui/button'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Avoid importing a primitive the scaffold does not ship.

@/components/ui/button is excluded by scripts/sync-templates.mjs, and the template sync only inlines Badge, so this footer can generate an unresolved import in scaffolded projects. Either ship the Button primitive or make this CTA a styled Link.

🐛 Proposed fix: remove the missing Button dependency
-import { Button } from '@/components/ui/button'
 import Link from 'next/link'
@@
-                                <Button
-                                    asChild
-                                    variant="outline"
-                                    size="sm">
-                                    <Link
-                                        href='#'
-                                        target="_blank"
-                                        rel="noreferrer">
-                                        Follow on GitHub Releases
-                                        <span
-                                            aria-hidden
-                                            className="text-muted-foreground ml-1">
-
-                                        </span>
-                                    </Link>
-                                </Button>
+                                <Link
+                                    href="#"
+                                    target="_blank"
+                                    rel="noreferrer"
+                                    className="inline-flex h-8 items-center justify-center rounded-md border px-3 text-sm font-medium transition-colors hover:bg-muted">
+                                    Follow on GitHub Releases
+                                    <span
+                                        aria-hidden
+                                        className="text-muted-foreground ml-1">
+
+                                    </span>
+                                </Link>

Also applies to: 118-133

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/footer.tsx` at line 1, The
footer currently imports and uses the Button primitive (symbol: Button) which
isn’t shipped by the scaffold; remove the unresolved import and replace the
Button usage in the footer component with a plain anchor or existing Link
component styled via className (preserve the CTA text and behavior), or
alternatively inline the minimal button styles directly in the footer JSX;
update the import line to remove Button and ensure any references to Button in
the footer component (CTA block) are replaced with the styled Link/anchor.

Comment on lines +59 to +60
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "chart.tsx" --path "*/create-helpbase/*"

Repository: Codehagen/Helpbase

Length of output: 295


🏁 Script executed:

fd -t f "chart.tsx" "create-helpbase"

Repository: Codehagen/Helpbase

Length of output: 164


🏁 Script executed:

find . -name "chart.tsx" -type f

Repository: Codehagen/Helpbase

Length of output: 201


🏁 Script executed:

wc -l "./packages/create-helpbase/templates/components/ui/chart.tsx"

Repository: Codehagen/Helpbase

Length of output: 126


🏁 Script executed:

cat -n "./packages/create-helpbase/templates/components/ui/chart.tsx" | sed -n '1,120p'

Repository: Codehagen/Helpbase

Length of output: 4209


🏁 Script executed:

cat -n "./packages/create-helpbase/templates/components/ui/chart.tsx" | sed -n '42,82p'

Repository: Codehagen/Helpbase

Length of output: 1946


Sanitize dynamic CSS and quote attribute selectors before injecting chart styles.

Line 99 uses an unquoted CSS attribute selector [data-chart=${id}] where id comes from the caller (React div prop). A caller-provided id with spaces or special characters breaks the selector syntax, and special sequences could inject CSS. Additionally, config keys on line 105 are interpolated directly into CSS custom-property names without validation. Add a sanitization function and quote the attribute selector to prevent CSS injection and malformed declarations.

Proposed fix
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
+const CSS_IDENTIFIER_RE = /[^a-zA-Z0-9_-]/g
+
+function sanitizeCssIdentifier(value: string) {
+  return value.replace(CSS_IDENTIFIER_RE, "_")
+}

  const uniqueId = React.useId()
-  const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
+  const chartId = `chart-${sanitizeCssIdentifier(id ?? uniqueId)}`

-${prefix} [data-chart=${id}] {
+${prefix} [data-chart="${id}"] {
   ${colorConfig
     .map(([key, itemConfig]) => {
       const color =
         itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
         itemConfig.color
-      return color ? `  --color-${key}: ${color};` : null
+      return color
+        ? `  --color-${sanitizeCssIdentifier(key)}: ${color};`
+        : null
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/ui/chart.tsx` around lines 59 -
60, The attribute selector and CSS custom-property names are built from
untrusted values (id and config keys); fix by sanitizing and safely quoting them
before injecting into the stylesheet: generate a sanitizedId from uniqueId/id
(e.g., remove or replace characters invalid in CSS identifiers and escape
quotes) and use a quoted attribute selector like [data-chart="..."] (use the
sanitizedId), and normalize config keys used in custom-property names inside the
style injection (e.g., map keys through a sanitizeConfigKey function that
strips/replace characters not allowed in CSS custom-property names and/or
prepend a fixed prefix) so CSS selectors and property names cannot be broken or
injected; apply these changes where chartId is created (uniqueId/chartId) and
where the styles are interpolated for [data-chart=...] and --hb-<key>
declarations.

Comment on lines +30 to +79
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const [displayText, setDisplayText] = useState(children);
const [isAnimating, setIsAnimating] = useState(false);
const text = children;

const scramble = async () => {
if (isAnimating) return;
setIsAnimating(true);

const steps = duration / speed;
let step = 0;

const interval = setInterval(() => {
let scrambled = '';
const progress = step / steps;

for (let i = 0; i < text.length; i++) {
if (text[i] === ' ') {
scrambled += ' ';
continue;
}

if (progress * text.length > i) {
scrambled += text[i];
} else {
scrambled +=
characterSet[Math.floor(Math.random() * characterSet.length)];
}
}

setDisplayText(scrambled);
step++;

if (step > steps) {
clearInterval(interval);
setDisplayText(text);
setIsAnimating(false);
onScrambleComplete?.();
}
}, speed * 1000);
};

useEffect(() => {
if (!trigger) return;

scramble();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trigger]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

motion/react motion.create memoization recommendation re-render

💡 Result:

For Motion (formerly Framer Motion) in React, the recommendation for memoization to prevent unnecessary re-renders is to wrap custom animated components created with motion.create in React.memo. This prevents child re-renders when parent state changes unrelated to the motion component's props. Key practices: - Use React.memo on motion components that receive stable props, especially in lists or when siblings update. - Memoize callbacks and values passed as props with useCallback and useMemo to maintain referential equality. - For motion.create(Component), define it outside render: const MotionMyComp = motion.create(MyComp); then export default React.memo(MotionMyComp); - Avoid calling motion.create inside render functions, as it creates new components each time, breaking animations and memoization. - Use useMotionValue for values that update frequently without triggering React re-renders. - Profile with React DevTools to identify re-renders; Motion animations themselves bypass React re-renders by updating DOM directly. Caveats: Older versions had bugs with React.memo breaking layout projections (fixed in v5+). In large lists of motion elements, memoize items and ensure stable keys/props. Motion prioritizes transform/opacity animations for best performance, as they are compositor-only and hardware-accelerated.

Citations:


Fix three critical lifecycle bugs: missing interval cleanup, unmemoized motion.create, and stale re-entry guard.

This component has three defects that will cause runtime failures:

  1. Unmount-unsafe interval (Lines 44, 74-79): The interval is local to scramble(), so useEffect cannot clean it up. If the component unmounts mid-animation or trigger flips, the interval keeps firing setDisplayText after unmount—a memory leak that can persist ~800ms in production.

  2. motion.create recreated on every render (Lines 30-32): Each render call creates a new component identity, forcing React to unmount and remount the motion wrapper, breaking animations and causing DOM churn. Must be memoized by Component prop.

  3. Racy re-entry guard (Lines 38-39): setIsAnimating(true) is asynchronous; rapid trigger toggles allow both calls to read isAnimating === false from stale closure and start two competing intervals. Use a useRef for synchronous re-entry checks.

Suggested fix
-import { type JSX, useEffect, useState } from 'react';
+import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
 import { motion, MotionProps } from 'motion/react';
@@
 }: TextScrambleProps) {
-  const MotionComponent = motion.create(
-    Component as keyof JSX.IntrinsicElements
-  );
+  const MotionComponent = useMemo(
+    () => motion.create(Component as keyof JSX.IntrinsicElements),
+    [Component]
+  );
   const [displayText, setDisplayText] = useState(children);
-  const [isAnimating, setIsAnimating] = useState(false);
-  const text = children;
-
-  const scramble = async () => {
-    if (isAnimating) return;
-    setIsAnimating(true);
-
-    const steps = duration / speed;
-    let step = 0;
-
-    const interval = setInterval(() => {
-      ...
-    }, speed * 1000);
-  };
-
-  useEffect(() => {
-    if (!trigger) return;
-
-    scramble();
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [trigger]);
+  const isAnimatingRef = useRef(false);
+
+  useEffect(() => {
+    if (!trigger || isAnimatingRef.current) return;
+    isAnimatingRef.current = true;
+
+    const text = children;
+    const steps = duration / speed;
+    let step = 0;
+
+    const interval = setInterval(() => {
+      // ...existing scramble body, replacing setIsAnimating(false) with
+      // isAnimatingRef.current = false
+    }, speed * 1000);
+
+    return () => {
+      clearInterval(interval);
+      isAnimatingRef.current = false;
+    };
+  }, [trigger, children, duration, speed, characterSet, onScrambleComplete]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/create-helpbase/templates/components/ui/text-scramble.tsx` around
lines 30 - 79, The component creates motion.create on every render, uses an
async state guard for re-entry, and leaves the interval untracked so it can't be
cleared on unmount; to fix, memoize the MotionComponent via useMemo keyed on the
Component prop (referencing MotionComponent and motion.create), replace the
isAnimating boolean guard with a ref (e.g., isAnimatingRef) used for immediate
re-entry checks while still keeping setIsAnimating for UI state (references:
isAnimating, setIsAnimating, scramble), store the interval id in a ref (e.g.,
intervalRef) so scramble can clear any existing interval before starting and so
useEffect cleanup can clear it on unmount or when trigger changes, and ensure
you update both the ref and state to false when the animation finishes and call
onScrambleComplete.

Comment on lines +59 to +60
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Constrain values before injecting generated CSS.

id, config keys, and color/theme values are interpolated into a raw <style> block. Static chart configs are fine, but this reusable component can break or inject CSS rules if any of those strings come from dynamic input.

🛡️ Proposed defensive hardening
 const INITIAL_DIMENSION = { width: 320, height: 200 } as const
 type TooltipNameType = number | string
+
+const sanitizeCssIdentifier = (value: string) =>
+  value.replace(/[^a-zA-Z0-9_-]/g, "_")
+
+const escapeCssAttributeValue = (value: string) =>
+  value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
+
+const isSafeCssValue = (value: string) => !/[;{}]/.test(value)
 
 export type ChartConfig = Record<
@@
   const uniqueId = React.useId()
-  const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
+  const chartId = `chart-${sanitizeCssIdentifier(
+    id ?? uniqueId.replace(/:/g, "")
+  )}`
@@
           .map(
             ([theme, prefix]) => `
-${prefix} [data-chart=${id}] {
+${prefix} [data-chart="${escapeCssAttributeValue(id)}"] {
 ${colorConfig
   .map(([key, itemConfig]) => {
     const color =
       itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
       itemConfig.color
-    return color ? `  --color-${key}: ${color};` : null
+    if (!color || !isSafeCssValue(color)) {
+      return null
+    }
+
+    return `  --color-${sanitizeCssIdentifier(key)}: ${color};`
   })

Also applies to: 94-107

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@registry/helpbase/components/ui/chart.tsx` around lines 59 - 60, The
component currently interpolates id, config keys and color/theme strings
directly into a generated <style> block (see uniqueId, chartId and places where
config values are injected), which can break CSS or allow injection if values
are dynamic; fix by constraining and escaping those values before interpolation:
normalize/trim and enforce a safe charset/length (allowlist [a-z0-9-_] or
similar) for id and config keys, clamp color/theme strings to validated CSS
color formats (or map to a known palette), and use a proper escaping routine
(e.g. CSS.escape or equivalent) or switch to CSS variables/classnames instead of
raw string interpolation when composing the style tag (apply these changes to
the chartId construction and the later style-injection sites).

'use client'

import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether every Radix Tooltip import has a matching declared dependency.
# Expected: Either `@radix-ui/react-tooltip` is declared wherever this component is shipped,
# or the component imports Tooltip from the declared consolidated radix-ui package.

rg -n -C2 '"@radix-ui/react-tooltip"|"radix-ui"|@radix-ui/react-tooltip|from ["'"'"']radix-ui["'"'"']'

Repository: Codehagen/Helpbase

Length of output: 8471


Align the Tooltip import with shipped dependencies.

The templates generated by scripts/sync-templates.mjs declare radix-ui, but this component imports @radix-ui/react-tooltip. While radix-ui includes Tooltip as a re-export (visible in pnpm-lock.yaml), the direct scoped import creates a dependency mismatch. This inconsistency breaks the template contract—other components like label.tsx correctly import from the consolidated package. With strict package managers, scaffolded projects will fail unless they also explicitly declare @radix-ui/react-tooltip.

Update the import to use the consolidated package:

Suggested fix
-import * as TooltipPrimitive from '@radix-ui/react-tooltip'
+import { Tooltip as TooltipPrimitive } from 'radix-ui'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { Tooltip as TooltipPrimitive } from 'radix-ui'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@registry/helpbase/components/ui/tooltip.tsx` at line 4, The import in
tooltip.tsx uses the scoped package '@radix-ui/react-tooltip' (see
TooltipPrimitive) which conflicts with the template's declared dependency
'radix-ui'; change the import to the consolidated package used by other
components (match how label.tsx imports from 'radix-ui') so TooltipPrimitive is
imported from the unified export to avoid a dependency mismatch with strict
package managers.

Codehagen and others added 3 commits April 20, 2026 08:31
The fixed h-14 row height meant any feature label that wrapped past 2
lines overflowed into the next row \u2014 'MCP server (agent-consumable
docs)' stacked visually on top of 'llms.txt + structured agent output',
so readers couldn't tell which column value belonged to which feature.

Shortened 5 labels so they fit in \u2264 2 lines at every viewport:
  Time to first help center \u2192 Time to first site
  MCP server (agent-consumable docs) \u2192 MCP server
  llms.txt + structured agent output \u2192 llms.txt + typed output
  Deploy anywhere (Vercel / Fly / self-host) \u2192 Deploy anywhere
  AI tools can read the docs \u2192 Agents can read it

Detail that was trimmed from the labels moves into the tooltip
descriptions (the ? icons) so hover still surfaces the full context.
The descriptions were already there \u2014 just now carrying more weight.

Verified against localhost:3000: every feature row aligns with its
three plan values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…HP curl stub

CodeIllustration ships with a hardcoded PHP curl example (<?php,
curl_setopt, CURLOPT_URL). The how-it-works Install step copy says
'Run pnpm dlx create-helpbase in your repo' \u2014 the illustration was
showing PHP, which is wildly off-topic and the first thing a founding
engineer sees after the hero.

New InstallCommandPreview follows the LlmsTxtPreview / MdxSourcePreview /
PreviewUrlCard pattern (font-mono, card-in-card layout, show real
on-topic content). Renders the actual command + success output:

  $ pnpm dlx create-helpbase
  \u25c7 Help center created at ./help-center
    MDX, shadcn/ui, MCP server, llms.txt
  \u25c7 Installing dependencies\u2026
  \u25c7 Ready. Next: cd help-center && pnpm dev

The macOS-style dot row at the top (red/amber/emerald) anchors the
card as a terminal without requiring the full MagicUI Terminal
component (which is reserved for the hero's animated version).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…voice to docs

The Tailark registry ships flow-default as a cloud-usage-metering
illustration: platforms feed 'Usage' data to a central node that
produces an INVOICE.pdf. We imported it for the 'Zero vendor runtime'
card to carry the 'deploy anywhere' narrative, but the invoice output
read like billing, not like docs \u2014 wrong mental model for the copy.

Two edits:
  - Dropped the 'Usage' subtitle from each platform card. Was rendered
    as a two-line 'Vercel / Usage' label; now just 'Vercel' (same for
    Supabase, Firebase). Matches 'deploy to any of these' not
    'meter usage from these'.
  - Output node: 'INVOICE' title + rose 'PDF' badge \u2192 'help-center'
    title + emerald 'MDX' badge. Now the flow reads: platforms \u2194 helpbase
    \u2194 help-center/MDX. Aligned with the card's narrative that your repo
    lives in MDX and ships to any platform.

The animated beam paths and hub structure are untouched \u2014 the
visual vocabulary stays, just the text labels now say what we mean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Codehagen Codehagen merged commit fb004ed into main Apr 20, 2026
3 of 5 checks passed
@Codehagen Codehagen deleted the feat/marketing-page-v2 branch April 20, 2026 06:37
Codehagen added a commit that referenced this pull request Apr 20, 2026
PR #20 added components/footer.tsx with an import of @/components/ui/button,
but apps/web's button.tsx is in HOSTED_TIER_EXCLUDES because it carries
marketing-only press feedback styling. The synced footer landed in the
scaffold templates and registry without a Button to resolve, breaking
both smoke:install and smoke:registry on Turbopack with
'Module not found: @/components/ui/button'. CI has been red on main
since fb004ed; this fix restores green.

Three coordinated changes:

1. inlineShadcnPrimitivesToTemplates now inlines BOTH Badge and Button
   from packages/ui/src/components/ into templates/components/ui/.
   Button uses the umbrella radix-ui import (Slot.Root pattern), works
   cleanly with @workspace import rewrites.

2. registry.json adds 'button' to registryDependencies and
   '@radix-ui/react-slot' to dependencies, so consumers running
   'shadcn add helpbase' get Button auto-installed from the official
   shadcn registry and the slot primitive resolves for any other
   shadcn primitive that needs it.

3. Templates package.json now ships @radix-ui/react-slot directly. The
   umbrella radix-ui package re-exports it under Slot.Root with a
   different API, so primitives that import { Slot } from
   '@radix-ui/react-slot' need the individual package.

Plus: deleted 7 dead UI primitives from templates AND registry that
were copied from apps/web by the default sync rule. None of them
were used by anything in the scaffold path:

  - card.tsx, chart.tsx, infinite-slider.tsx, input.tsx, label.tsx,
    text-scramble.tsx, tooltip.tsx

Their only consumers in apps/web were components in HOSTED_TIER_EXCLUDES
(pricing, bento, chart-illustration). Shipping them as dead code dragged
in recharts, motion/react, and other marketing-only deps the scaffold
doesn't need. Added all 7 to HOSTED_TIER_EXCLUDES so future syncs don't
recreate them. Scaffold consumers can shadcn-add card / tooltip / etc.
on demand.

Locally verified:
  - pnpm smoke:install passes (templates path)
  - pnpm smoke:registry passes (shadcn add path)
  - pnpm registry:build regenerates public/r/*.json clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codehagen added a commit that referenced this pull request Apr 20, 2026
* feat(marketing): replace comparator table with 3-card options block

The old Tailark comparator-7 rendered a 3-column feature table with
tooltip-gated descriptions. It was the only table on a page of
cards \u2014 the visual language mismatched the rest of the narrative
scroll (hero \u2192 bento cards \u2192 how-it-works \u2192 feature cards \u2192 pricing
cards \u2192 FAQ). Reader's eye hit the tabular row and snagged.

Rewritten as a 3-col OptionCard grid. Each card:
  - option name (small label)
  - headline metric (tabular-nums large number)
  - time-to-site subtitle
  - short paragraph explaining the option
  - 3 signal bullets (Check/X icon in rounded pill, emerald for
    positive, muted for negative)
  - cost footer with border-t separator

Helpbase card is emphasized:
  - heavier shadow, opaque bg-card (the other two are bg-card/40)
  - 'Recommended' badge top-right
  - all positive signals

No tooltips required \u2014 the signal text is short enough to read inline,
and more detail is available in the other sections below.

Behavior preserved: same 3-way comparison (roll your own / hosted SaaS
/ helpbase), same 'to first site' metric, same cost ladder. Just
rendered as cards that sit naturally next to the pricing section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): remove hero decorative frame

The hero's content wrapper carried 'corner-t-notch rounded-t-[2rem]
border-x border-t' \u2014 a Tailark hero-section/six poster frame at
max-w-6xl that wrapped the headline + sub + CTAs + terminal.

Three reasons it had to go:

  1. The frame's bottom edge floated orphan in the viewport when you
     scrolled past it. The frame ended at the hero's bottom with
     nothing visually continuing it below \u2014 a horizontal line that
     pointed at empty space.

  2. The page already has section frames where they pay rent
     (how-it-works grid, demo cross-link card, faqs sidebar+accordion).
     Each of those frames groups a content cluster. The hero frame
     wrapped one large content block but didn't group anything that
     wasn't already visually unified by typography and the terminal.

  3. The 6xl frame width didn't align with anything below \u2014 every
     other framed section sits at 5xl. Two different widths, two
     different visual languages on one page.

Mintlify, Stripe, and Anthropic don't frame their heroes. The hero
is the hero because of the headline, the terminal, and the install
command \u2014 not because of a border.

Three classes removed (corner-t-notch, rounded-t-[2rem], border-x,
border-t) plus the corner-t-notch decoration. Hero typography and
content untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(design): swap Tailark hub icon for helpbase 'h' + upgrade MDX preview to source+rendered

Two improvements in the 'Every file is yours' row.

FlowIllustration hub:
  The center node was the Tailark logomark (LogoIcon \u2014 a purple/teal
  gradient geometric shape that ships with their illustration kit).
  That's their brand bleeding through ours. Replaced with a clean white
  'h' letterform on the dark circle, matching the helpbase wordmark in
  the header. Bumped the circle bg from black/75 to black/85 so the
  letterform reads cleanly in light mode. Removed the LogoIcon import.

MdxSourcePreview:
  Was source-only \u2014 a single panel of fake .mdx code. The card's copy
  promises 'Import React components, version control every change, diff
  in PR review' but the visual didn't show the moment that matters most:
  MDX source rendering as actual docs.

  Rebuilt as a stacked card:
    - Header tab: filename + 'SOURCE' label
    - Source panel: syntax-highlighted MDX (violet frontmatter keys,
      amber numbers, emerald strings, sky-blue JSX tags) \u2014 a real-feeling
      code block, not flat mono text
    - Divider
    - Rendered panel: 'RENDERED' label, then the h1 styled as a real
      heading, body text, and the <Callout type="tip"> shown as an
      actual amber-tinted callout box with a lucide Lightbulb icon

  This makes the abstract claim concrete: MDX is code AND it's docs.
  The visual literally shows the transformation the platform performs.

Both files are in components/illustrations/ which is in
HOSTED_TIER_EXCLUDES \u2014 no template drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(anim): add tasteful press feedback to button component

The marketing page button (used by header CTAs, hero 'See live demo',
pricing tier CTAs, and demo cross-link CTAs) had only transition-colors
\u2014 hover worked but clicks had zero visual feedback. The shared
packages/ui button had active:translate-y-px so CopyButton already
felt right, but everything else didn't.

Applied Emil Kowalski's micro-interaction blueprint from animations.dev:

  - Transition widened from colors-only to
    transition-[color,background-color,transform,box-shadow]
    so transform and shadow can animate together.
  - duration-150 ease-out \u2014 micro-interaction range (100\u2013150ms),
    ease-out for the element entering its pressed state.
  - active:scale-[0.98] \u2014 2% squeeze on press. Buttons are h-9/h-10,
    3% would read as cartoonish; 2% feels like a tap, not a gimmick.
  - active:shadow-sm (default, destructive) / active:shadow-xs (outline)
    \u2014 shadow reduces on press, mimicking a physical button being
    depressed. Subtle but readable.
  - motion-reduce:transition-none motion-reduce:active:scale-100
    \u2014 respects prefers-reduced-motion, disables animation for users
    who've opted out.
  - Removed the stray duration-200 override on outline (now inherits
    the base duration-150 for consistency across variants).

Tailwind v4's hover: modifier already guards against mobile tap-flicker
via @media (hover: hover), so no extra work needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): inline Button into scaffold + drop dead UI primitives

PR #20 added components/footer.tsx with an import of @/components/ui/button,
but apps/web's button.tsx is in HOSTED_TIER_EXCLUDES because it carries
marketing-only press feedback styling. The synced footer landed in the
scaffold templates and registry without a Button to resolve, breaking
both smoke:install and smoke:registry on Turbopack with
'Module not found: @/components/ui/button'. CI has been red on main
since fb004ed; this fix restores green.

Three coordinated changes:

1. inlineShadcnPrimitivesToTemplates now inlines BOTH Badge and Button
   from packages/ui/src/components/ into templates/components/ui/.
   Button uses the umbrella radix-ui import (Slot.Root pattern), works
   cleanly with @workspace import rewrites.

2. registry.json adds 'button' to registryDependencies and
   '@radix-ui/react-slot' to dependencies, so consumers running
   'shadcn add helpbase' get Button auto-installed from the official
   shadcn registry and the slot primitive resolves for any other
   shadcn primitive that needs it.

3. Templates package.json now ships @radix-ui/react-slot directly. The
   umbrella radix-ui package re-exports it under Slot.Root with a
   different API, so primitives that import { Slot } from
   '@radix-ui/react-slot' need the individual package.

Plus: deleted 7 dead UI primitives from templates AND registry that
were copied from apps/web by the default sync rule. None of them
were used by anything in the scaffold path:

  - card.tsx, chart.tsx, infinite-slider.tsx, input.tsx, label.tsx,
    text-scramble.tsx, tooltip.tsx

Their only consumers in apps/web were components in HOSTED_TIER_EXCLUDES
(pricing, bento, chart-illustration). Shipping them as dead code dragged
in recharts, motion/react, and other marketing-only deps the scaffold
doesn't need. Added all 7 to HOSTED_TIER_EXCLUDES so future syncs don't
recreate them. Scaffold consumers can shadcn-add card / tooltip / etc.
on demand.

Locally verified:
  - pnpm smoke:install passes (templates path)
  - pnpm smoke:registry passes (shadcn add path)
  - pnpm registry:build regenerates public/r/*.json clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant