feat(web): marketing page v2 — terminal hero + narrative scroll + analytics#20
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthroughThis 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
Sequence DiagramsequenceDiagram
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
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 | 🟡 MinorMinor: 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 | 🟡 MinorMake SVG definition IDs instance-safe.
The hardcoded IDs
a,b,c, anddare document-scoped. This component has the same ID collision vulnerability found in other SVG components likeGeminiandOpenAI, which are already rendered together inproduct-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.tsxandopen-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 | 🟡 MinorExplicitly import React types to fix incorrect
React.CSSPropertiesreference.
JSXshould be imported as a type, andReact.CSSPropertieson line 86 is used without importingReact, 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 | 🟡 MinorMinor a11y nit:
aria-liveon 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 ofaria-live, or usingrole="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 | 🟡 MinorDefensive note on
ChartStyleCSS injection surface.This is the standard shadcn pattern and safe as long as
configkeys andcolorvalues come from developer code (which is the case today). If that ever changes — e.g., config derived from API responses or URL params — the interpolatedkeyandcolorcould break out of the--color-… : …;declaration. Consider either documenting thatChartConfigmust 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 | 🟡 MinorMonths are out of order and
chartConfigcolors are never applied.Two visible bugs in the illustration:
chartDatais orderedMay, 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.chartConfig.mobile.color/chartConfig.desktop.colorfeed--color-mobile/--color-desktopviaChartStyle, but the<Bar>fillvalues use unrelated CSS vars (var(--color-emerald-400)andvar(--color-indigo-500)). As a result the configured colors are dead, and the tooltip's indicator (which readsitem.colorfrom the bar's fill) won't match whatever theme the consumer configures. Either reference the config vars fromfill, or dropcolorfromchartConfig.🐛 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
themeprop is effectively a no-op.
CodeBlockaccepts athemeprop but itshighlight()hardcodesthemes: { light: 'github-light', dark: 'vesper' }(seeapps/web/components/code-block.tsx:20-49) and never passes the prop through. Callers ofCodeIllustrationthat pass a non-defaultthemewill 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 throughCodeBlock.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 | 🟡 MinorFix the exported component typo before it becomes API surface.
DocumentIllustationappears to be missing therinIllustration. 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 | 🟡 MinorScope the GitHub avatar allowlist to the used path.
Line 18 currently allows every path on
avatars.githubusercontent.com. Next.js treats an omittedpathnameas 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=4pattern, 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 | 🟡 MinorStale 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 | 🟡 MinorDocstring points to the wrong file.
Line 9 references
supabase/functions/track/index.ts, but per the PR summary the handler was extracted tosupabase/functions/track/handler.tsso 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 | 🟡 MinorTest 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 | 🟡 MinorRemove
console.logdebug artifact.
particlesLoadedcurrently 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 | 🟡 MinorInner
hideTimeris never cleaned up on unmount.The
return () => clearTimeout(hideTimer)at Line 24 is returned from thesetTimeoutcallback (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 | 🟡 MinorError message is truncated — no value after
Received:.The
throwruns whenchildrenisn'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.5is 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 inertthemeandlineNumbersprops.
lineNumbersis never read, andthemeonly retriggers the effect whilehighlight()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: Deadtabletseries.
tabletis declared inchartConfigand populated in everychartDatarow, 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.getandcreateClientrun 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/imageusage is correct.Minor consideration: the avatar
<Image>elements have meaningfulalttext but the overall illustration appears decorative. If callers don't already wrap this in anaria-hiddencontainer, consider settingalt=""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(...)andmap.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 theMapcomponent never renders. Move the initialization into the component usinguseMemoto 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.tsxis spelledDocumentIllustation(missing the secondr). 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-rowTooltipProviderwrappers.
Tooltipalready renders internally within aTooltipProvider(see lines 19-25 oftooltip.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/metadatacaps measure characters, not bytes.
body.path.lengthandmetadataSerialized.lengthcount 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, usenew TextEncoder().encode(...).byteLengthinstead. 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 toconsole.errortheerror.messagehere; 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-stringeventis rejected.The allowlist test only covers a disallowed string. Consider one extra case where
body.eventis a non-string (e.g.,{ event: 123 }) to lock in thetypeof 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_eventsis insert-heavy with no retention policy. Once traffic picks up, thesession_hashbtree index and unbounded table growth will become the first pain point. Consider (post-merge, not blocking):
- A monthly
pg_cronjob that deletes rows older than N days (e.g., 90).- Or range-partitioning by
tsif you want cheap drops.- Confirm
session_hash_idxis actually queried; if it's only used for cohort counting, ahashindex 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:tsandcreated_atlook redundant.Both columns are
timestamptz not null default now()with no distinct semantic. Consider dropping one (typical pattern: keepcreated_atfor audit, use it in the(event, ts desc)index; or keep onlytsas 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 respectingprefers-reduced-motionfor the typing animation.Users with
prefers-reduced-motion: reducecurrently see the full character-by-character reveal. TheTypingAnimationcomponent should import and checkuseReducedMotion()frommotion/react. When reduced motion is enabled, skip the setInterval loop and directly setdisplayedTextto the fullchildren, then callcompleteItemimmediately. 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (51)
CHANGELOG.mdapps/web/app/(marketing)/page.tsxapps/web/components.jsonapps/web/components/bento-2.tsxapps/web/components/code-block.tsxapps/web/components/comparator-7.tsxapps/web/components/const.tsapps/web/components/faqs-3.tsxapps/web/components/features-1.tsxapps/web/components/footer.tsxapps/web/components/header.tsxapps/web/components/hero-section.tsxapps/web/components/how-it-works-3.tsxapps/web/components/logo-cloud.tsxapps/web/components/logo.tsxapps/web/components/map.tsxapps/web/components/marketing/demo-cross-link.tsxapps/web/components/marketing/hero.tsxapps/web/components/particles.tsxapps/web/components/pricing.tsxapps/web/components/ui/accordion.tsxapps/web/components/ui/card.tsxapps/web/components/ui/chart.tsxapps/web/components/ui/illustrations/chart-illustration.tsxapps/web/components/ui/illustrations/code-illustration.tsxapps/web/components/ui/illustrations/currency-illustration.tsxapps/web/components/ui/illustrations/document-illustration.tsxapps/web/components/ui/illustrations/invoice-illustration.tsxapps/web/components/ui/illustrations/map-illustration.tsxapps/web/components/ui/illustrations/monitoring-barchart-illustration.tsxapps/web/components/ui/illustrations/notification-illustration.tsxapps/web/components/ui/illustrations/reply-illustration.tsxapps/web/components/ui/illustrations/scan-illustration.tsxapps/web/components/ui/illustrations/visualization-illustration.tsxapps/web/components/ui/infinite-slider.tsxapps/web/components/ui/input.tsxapps/web/components/ui/label.tsxapps/web/components/ui/svgs/vs-code.tsxapps/web/components/ui/terminal.tsxapps/web/components/ui/text-scramble.tsxapps/web/components/ui/tooltip.tsxapps/web/lib/analytics.tsapps/web/next.config.mjsapps/web/package.jsonapps/web/test/analytics.test.tsapps/web/test/copy-button.test.tsxapps/web/test/track-edge-handler.test.tspackages/ui/src/components/copy-button.tsxsupabase/functions/track/handler.tssupabase/functions/track/index.tssupabase/migrations/20260419000000_marketing_events_v1.sql
💤 Files with no reviewable changes (2)
- apps/web/components/hero-section.tsx
- apps/web/components/logo-cloud.tsx
| 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) |
There was a problem hiding this comment.
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.
| export const LightDarkParticles = ({ id }: { id: string }) => { | ||
| const [init, setInit] = useState(true) | ||
|
|
||
| useEffect(() => { | ||
| initParticlesEngine(async (engine) => { | ||
| await loadSlim(engine) | ||
| }).then(() => { | ||
| setInit(true) | ||
| }) | ||
| }, []) |
There was a problem hiding this comment.
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' | |||
There was a problem hiding this comment.
🧩 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:
- 1: https://motion.dev/docs/react-use-reduced-motion
- 2: https://www.framer.com/motion/use-reduced-motion/
- 3: https://motion.dev/docs/react
- 4: https://motion.dev/docs/react-accessibility
🏁 Script executed:
cat -n apps/web/components/ui/infinite-slider.tsxRepository: 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.
| 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.
| 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> |
There was a problem hiding this comment.
🧩 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 catRepository: 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.
| 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).
| 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]); |
There was a problem hiding this comment.
🧩 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:
- 1: https://motion.dev/docs/react-motion-component
- 2: https://www.framer.com/motion/component/
- 3: [Performance issue] How to optimize large list of motion elements. (how to avoid unnecessary rerender?) motiondivision/motion#1715
- 4: https://react.dev/reference/react/memo
- 5: https://app.studyraid.com/en/read/7850/206069/reducing-re-renders-during-animations
- 6: https://kyleshevlin.com/using-react-memo-to-avoid-unnecessary-rerenders/
- 7: https://stackoverflow.com/questions/79178760/how-to-properly-use-react-memo-with-framer-motion-and-next-js-image-component
Fix interval cleanup, motion component memoization, and race condition.
Three bugs in this effect chain:
-
Missing interval cleanup. The
useEffecton 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 callssetDisplayTexton an unmounted component, triggering "Can't perform a React state update on an unmounted component" warnings. The interval also leaks across re-mounts whentriggertoggles. -
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 inuseMemowith[Component]as a dependency. -
Stale
isAnimatingguard. Becausescrambleis redefined each render, its closure overisAnimatingis stale—the guard on line 38 is ineffective, allowing rapidtriggertoggles to spawn multiple concurrent intervals. Move the interval logic directly into the effect and remove theisAnimatingstate.
🐛 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.
| 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.
| -- 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); |
There was a problem hiding this comment.
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:
- If the edge function continues to use
service_role, drop the anon insert policy entirely — it's unused and only adds attack surface. - If you want the edge function to use the anon role instead (so RLS is actually enforcing), keep the policy but add a
WITH CHECKthat 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.
| -- 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).
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>
…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>
…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>
There was a problem hiding this comment.
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 | 🔴 CriticalSame 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(andapps/web/components/ui/text-scramble.tsx). The same three issues apply here and should be fixed in lockstep:
useEffect(Lines 74-79) has no cleanup, so thesetIntervalstarted inscramble(Line 44) outlives unmount and triggerssetDisplayText/setIsAnimatingon an unmounted component.motion.create(Component)(Lines 30-32) is invoked on every render, producing a new component identity and remounting the motion wrapper unnecessarily.- The
isAnimatingre-entry guard (Lines 38-39) reads a stale closure value becausesetIsAnimatingis async; use auseRefinstead.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 | 🟡 MinorPlaceholder 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 (seeapps/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 | 🟡 MinorInvalid hex color
#ffff.
fill="#ffff"is not a valid CSS hex color (valid forms are 3, 4, 6, or 8 hex digits —#ffffhas 4 digits but would be interpreted as#ffff= RGBA#ff ff ff ff? No — 4-digit hex is#RGBA, so#ffff= white with alphaf= opaque white, which by chance renders correctly in modern browsers, but is confusing and non-idiomatic). Use#fffor#ffffffexplicitly to avoid reader confusion and potential older-renderer fallback. Also note: since{...props}is spread beforefill, callers cannot override the fill onQwenDark, 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 | 🟡 MinorRespect 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 forprefers-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 | 🟡 MinorCall the formatter for valid falsy series names.
item.namecan be0or""; 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 | 🟡 MinorFallback to the Recharts legend value when config labels are omitted.
ChartConfig.labelis 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 | 🟡 MinorUse 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 | 🟡 MinorRewrite object-style
/docsfooter 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/docsinstead 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 | 🟡 MinorGive tooltip triggers descriptive accessible names.
The trigger’s accessible name is currently just
?, which does not tell assistive-tech users what it opens. Add anaria-labeltied 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 | 🟡 MinorRemove the nested
TooltipProviderfrom theTooltipcomponent.The inner
TooltipProvider(with defaultdelayDuration={0}) shadows any outer provider configuration, making the exportedTooltipProviderineffective for consumers who want to customize shared delay settings. LetTooltiprender only the Radix root and require consumers to wrap with the explicitTooltipProvider.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 | 🟡 MinorUse
item.namefor legend text fallback, notitem.value.
ChartConfig.labelis optional, but the legend renders onlyitemConfig?.labelwithout fallback. The tooltip already demonstrates the correct pattern:itemConfig?.label ?? item.name. The proposed fix usesitem.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 | 🟡 MinorDon't skip custom tooltip formatting for falsy names.
TooltipNameTypeallows numbers and strings, so0or""are valid names but currently bypassformatterdue 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 | 🟡 MinorReplace hardcoded SVG IDs with React's
useId()hook.Both
LogoandLogoIconcomponents in this template use hardcodedid="paint_logo". When scaffolded into generated apps, multiple component instances will create duplicate document IDs and ambiguousurl(#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 | 🟡 MinorMake the beam keyframes global and motion-safe.
<style jsx>scopes@keyframeswith a hash (e.g.,beam-move-jsx-123456789), while the Tailwindanimate-[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-2000is a transition-delay utility and won't delay CSS animations; usemotion-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 | 🟡 MinorUse unique SVG gradient ids for each component instance.
Both
LogoandLogoIcondefine and referenceid="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'suseIdhook 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.tsxandapps/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-2has no effect on a flex parent.The outer container at line 37 is
flex flex-wrap, so themd:col-span-2on 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 includingnoopeneralongsidenoreferrerfor consistency.External links in the top section (lines 55, 69) use
rel="noopener noreferrer", but the data-driven link loop sets onlyrel: 'noreferrer'. Modern browsers treatnoreferreras implyingnoopener, 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: Conflictingblockandflexutilities on the same element.
classNamecontains bothblockandflex; Tailwind emits bothdisplaydeclarations and the later one wins, which is fragile and confusing. Since the badge centers its number, dropblock.♻️ 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 modelviolates the TypeScript convention that types/interfaces use PascalCase (Model). This also shadows the loop variablemodelon 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:
key={index}is acceptable for static arrays but it's conventional to key on a stable identifier likemodel.name, which is unique here.- The up/bottom map bodies are nearly identical — only the
styletransform differs. Consider extracting a smallModelRowhelper 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 hardcodedstyle.Spreading
{...props}beforestylemeans anystylepassed 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 inqwen.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
useMeasurereports a size,width/heightare0, socontentSize = gapand the first animation runs forgap / speedseconds (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 whensize === 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.tsxand toregistry/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 intopackages/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
TooltipProviderineffective for callers that wrap a subtree with custom provider props, because eachTooltipadds 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (53)
apps/web/app/(main)/docs/page.tsxapps/web/app/(marketing)/page.tsxapps/web/components/bento-2.tsxapps/web/components/comparator-7.tsxapps/web/components/faqs-3.tsxapps/web/components/features-1.tsxapps/web/components/footer.tsxapps/web/components/header.tsxapps/web/components/how-it-works-3.tsxapps/web/components/illustrations/agent-tasks.tsxapps/web/components/illustrations/document-analysis.tsxapps/web/components/illustrations/flow.tsxapps/web/components/illustrations/mdx-source-preview.tsxapps/web/components/illustrations/models.tsxapps/web/components/illustrations/preview-url-card.tsxapps/web/components/illustrations/uptime.tsxapps/web/components/illustrations/workflow.tsxapps/web/components/marketing/demo-cross-link.tsxapps/web/components/marketing/hero.tsxapps/web/components/pricing.tsxapps/web/components/ui/illustrations/product-illustration.tsxapps/web/components/ui/svgs/cohere.tsxapps/web/components/ui/svgs/deepseek.tsxapps/web/components/ui/svgs/firebase.tsxapps/web/components/ui/svgs/mistral-ai.tsxapps/web/components/ui/svgs/openai.tsxapps/web/components/ui/svgs/perplexity-ai.tsxapps/web/components/ui/svgs/qwen.tsxapps/web/lib/analytics.tsapps/web/test/analytics.test.tsapps/web/test/middleware-subdomain.test.tspackages/create-helpbase/templates/app/(main)/page.tsxpackages/create-helpbase/templates/components/footer.tsxpackages/create-helpbase/templates/components/logo.tsxpackages/create-helpbase/templates/components/ui/card.tsxpackages/create-helpbase/templates/components/ui/chart.tsxpackages/create-helpbase/templates/components/ui/infinite-slider.tsxpackages/create-helpbase/templates/components/ui/input.tsxpackages/create-helpbase/templates/components/ui/label.tsxpackages/create-helpbase/templates/components/ui/text-scramble.tsxpackages/create-helpbase/templates/components/ui/tooltip.tsxregistry/helpbase/components/footer.tsxregistry/helpbase/components/logo.tsxregistry/helpbase/components/ui/card.tsxregistry/helpbase/components/ui/chart.tsxregistry/helpbase/components/ui/infinite-slider.tsxregistry/helpbase/components/ui/input.tsxregistry/helpbase/components/ui/label.tsxregistry/helpbase/components/ui/text-scramble.tsxregistry/helpbase/components/ui/tooltip.tsxscripts/smoke-deploy.shscripts/sync-templates.mjssupabase/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
| 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> | ||
| ) |
There was a problem hiding this comment.
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).
| 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 />, |
There was a problem hiding this comment.
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.
| <div | ||
| aria-hidden | ||
| className="relative flex min-h-[420px] w-fit min-w-[420px] flex-col items-center"> |
There was a problem hiding this comment.
🧩 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.tsxRepository: 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.tsxRepository: 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 lettingw-fitsize 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.
| {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> | ||
| ))} |
There was a problem hiding this comment.
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.
| Object.defineProperty(window, "location", { | ||
| value: { pathname: "/", search: "" }, | ||
| writable: true, | ||
| }) |
There was a problem hiding this comment.
🧩 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 -80Repository: Codehagen/Helpbase
Length of output: 3276
🏁 Script executed:
cat -n apps/web/src/lib/analytics.tsRepository: Codehagen/Helpbase
Length of output: 123
🏁 Script executed:
fd -type f -name "analytics*" apps/web/srcRepository: Codehagen/Helpbase
Length of output: 232
🏁 Script executed:
fd -type f -name "analytics*" apps/web/srcRepository: Codehagen/Helpbase
Length of output: 232
🏁 Script executed:
fd -type f "analytics" apps/web/srcRepository: 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 fRepository: Codehagen/Helpbase
Length of output: 119
🏁 Script executed:
cat -n apps/web/lib/analytics.tsRepository: Codehagen/Helpbase
Length of output: 1800
🏁 Script executed:
web_search
JSDOM 29.0.2 window.location Object.defineProperty redefine behaviorRepository: 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:
- 1: Manipulation with window location is no longer possible jsdom/jsdom#3492
- 2: Cannot redefine property: location jsdom/jsdom#3739
- 3: https://www.npmjs.com/package/jsdom
- 4: https://github.com/jsdom/jsdom
- 5: https://github.com/tmpvar/jsdom
- 6: https://github.com/jsdom/jsdom/releases/tag/21.0.0
🏁 Script executed:
cat -n apps/web/test/analytics.test.ts | tail -30Repository: 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' |
There was a problem hiding this comment.
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.
| const uniqueId = React.useId() | ||
| const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` |
There was a problem hiding this comment.
🧩 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 fRepository: 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.
| 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]); |
There was a problem hiding this comment.
🧩 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:
- 1: https://stackoverflow.com/questions/76959144/how-to-avoid-re-render-in-react-when-using-framer-motion
- 2: https://app.studyraid.com/en/read/7850/206069/reducing-re-renders-during-animations
- 3: [BUG] React.memo breaks layout projection motiondivision/motion#1187
- 4: https://www.framer.com/motion/component/
- 5: https://motion.dev/docs/react
- 6: https://motion.dev/docs/react-motion-component
- 7: [Performance issue] How to optimize large list of motion elements. (how to avoid unnecessary rerender?) motiondivision/motion#1715
- 8: https://www.inwine.lv
- 9: https://motion.dev/docs/react-animation
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:
-
Unmount-unsafe interval (Lines 44, 74-79): The
intervalis local toscramble(), souseEffectcannot clean it up. If the component unmounts mid-animation ortriggerflips, the interval keeps firingsetDisplayTextafter unmount—a memory leak that can persist ~800ms in production. -
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
Componentprop. -
Racy re-entry guard (Lines 38-39):
setIsAnimating(true)is asynchronous; rapidtriggertoggles allow both calls to readisAnimating === falsefrom stale closure and start two competing intervals. Use auseReffor 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.
| const uniqueId = React.useId() | ||
| const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` |
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
🧩 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.
| 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.
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>
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>
* 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>
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)@magicui/terminaltyping animation. Primary CTA is copy-to-clipboardpnpm dlx create-helpbase, secondary CTA isdemo.helpbase.dev.helpbase sync/ hosted tier).curl /llms.txtteaser.Header rewrite — killed placeholder
NavigationMenuwith ten fake links (#automation,Marketplace,Guides,Partnerships, etc.) andContinuebutton. Real nav: Docs, Pricing (anchor), GitHub. Right side: Sign in + Deploy now.Supabase-native analytics
public.marketing_eventstable with RLS (anon insert-only, no read).trackedge function (deployed, v3): event allowlist, 2 KB metadata cap, session hash derived fromcf-connecting-ip(not x-forwarded-for) + UA + day.apps/web/lib/analytics.tsuses keepalive fetch and swallows all failures.@workspace/uiCopyButton primitive —navigator.clipboard.writeTexthappy path withexecCommand("copy")fallback for older Safari / non-secure contexts.onCopycallback fires only on success,data-copy-stateattribute exposed for consumers.Security fixes (pre-landing review)
cf-connecting-ipprecedence intrackhandler — prevents session-hash forgery via spoofedX-Forwarded-For.res.cloudinary.comimage allowlist to/dohqjvu9k/**— prevents Next.js image optimizer from proxying arbitrary Cloudinary content.Test Coverage
apps/web/lib/analytics.tspackages/ui/copy-button.tsxsupabase/functions/track/handler.tsTests: 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:
[P1] supabase/functions/track/handler.ts:88—x-forwarded-foris client-spoofable;cf-connecting-ipnow takes precedence forsession_hashderivation. Anti-spoof test added.[P1] apps/web/next.config.mjs:19—res.cloudinary.comallowlist was unscoped; addedpathname: "/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)@magicui/terminal+ 8 Tailark Pro blocks installedmarketing_events+ RLStrackwith SHA-256 session hashnext.config.mjsimage allowlistCommits
2e45d89feat(web): rewrite header with real helpbase nav12bd686chore(web): install @magicui/terminal + 8 Tailark Pro blockscba97a8feat(web): marketing page v2 — hero terminal + narrative scroll + pricing115d18bchore(deps): lock hugeicons + radix-tooltip installed for tailark blocksc0fb493test(web): coverage for the three critical new surfaces22906e0fix(sec): prefer cf-connecting-ip + scope cloudinary image allowlist23ba5e8docs(changelog): unreleased — marketing page v2 + analytics + security fixes91bc639test(web): tighten non-null assertions on mock.calls accessTest plan
pnpm testinapps/web: 155/155 passingpnpm tsc --noEmitinapps/web: cleanpnpm lint: 0 errors on touched filespnpm dev) smoke test:/returns 200, hero + all sections render, no console errorstrackdeployed (v3, verify_jwt:false)~/.gstack/projects/help-center/vegard-walkthrough-{date}.mddemo.helpbase.devbefore driving marketing traffic🤖 Generated with Claude Code
Summary by CodeRabbit