feat(seo): 410 Gone on thin AI prompt pages + hub content rewrite + visual quality pass#536
Conversation
The dark-CTA <Link> in AIResumePromptsHub.tsx rendered as green text
on a green button — invisible. Root cause is selector specificity, not
the local className cocktail: BlogLayout wraps blog children in
<div className="prose prose-lg">, and styles.css declares
.prose a { color: #00d47e }. .prose a (0,1,1) beats every Tailwind
text-X utility (0,1,0), so even text-white (and later, text-ink)
lose to the prose rule. The existing dark-CTA override at
styles.css:706 covers h2/h3/h4/p/li inside .prose .text-white but
not a.
Two-part fix:
1. Replace the custom className with the design-system .btn-primary
class (per CLAUDE.md mandate for primary CTAs), keeping sizing.
2. Add a .prose a.btn-primary color override in styles.css so the
design system class always wins inside blog prose. Defence-in-depth
— any future .btn-primary placed inside a BlogLayout child is now
safe from the prose cascade.
Verified via Chrome DevTools MCP on localhost:5173/blog/ai-resume-prompts-hub:
computed color: rgb(12, 12, 12) (was rgb(0, 212, 126) — invisible)
computed bg: rgb(0, 212, 126)
ancestor bg: rgb(12, 12, 12) (the dark CTA section)
Contrast: WCAG AA pass. Build emits the rule into the CSS bundle.
Test surface unchanged. Vitest + tsc green.
Comparison-table cells inherited text-stone-warm (#8a8680) from their
<td>, which muted the ★ glyphs into a low-contrast grey blob that
was unscannable as a 1-5 rating. Rows 1-6 are ratings; rows 7-8 are
text (Yes/Limited, privacy notes) — the fix has to handle both shapes.
Changes:
- COMPARISON_ROWS becomes a discriminated union: rating rows carry
numeric values (1-5) keyed by provider; text rows keep strings.
- New PROVIDERS const drives both the column headers and the per-row
iteration so ordering is explicit and the renderer can't drift.
- New StarRating component renders 5 glyphs (filled text-accent for
the value, muted text-black/15 for the rest) wrapped in
span[role=img] with aria-label="N out of 5" so screen readers
announce the rating once, not five times.
- Cell color moves from text-stone-warm to text-ink/85 (text rows);
rating rows let StarRating own its colour.
Verified via Chrome DevTools MCP on localhost:5173/blog/ai-resume-prompts-hub:
36 span[role=img] elements (6 rating rows × 6 providers)
Sample 2/5 rating: 2 cells text-accent (#00d47e) + 3 cells text-black/15
Row count: 9 (unchanged), thead th: 7 (unchanged), tbody th: 8 (unchanged)
Lighthouse-relevant: ratings are no longer five-character emoji blobs
to assistive tech.
Test updates: asserts 36 rating images via getAllByRole("img", { name:
/out of 5/i }) and that no raw "★★★★★" string text exists. Existing
table-structure assertions (row count, scope attributes, label
presence) untouched.
Visual quality migration only — no copy, no schema. Two pre-existing bug-fix commits already landed on this branch (filled+empty stars, btn-primary dark CTA). This commit lifts the rest of the page from flat editorial cards to the LandingPage 2026 recipe. Per-section deltas (audit-driven, frontend-design skill): - Answer-first intro: lifts from border-l-4 + bg-white/80 inset to a shadow-premium + card-gradient-border + rounded-2xl callout with an accent-mono "THE SHORT ANSWER" eyebrow. - Comparison table: rounded-2xl shadow-premium wrapper, chalk/40 zebra stripes, "TOOL × USE CASE" mono eyebrow above the H2. Border-divider weight reduced to black/[0.04] for restraint. - "How we reviewed": 2-column layout (H2 left, methodology right) paired with a 3-card "Best for X" row using card-gradient-border + shadow-premium-hover + hover lift. - Per-tool sections (6 articles): rounded-2xl cards with shadow-premium + hover lift, numbered "0X / 06" mono index above each H3, accent-coloured ordered-list markers on reference prompts, arrow glyph on Claude/Gemini standalone-guide CTA links. - Related Resources grid: bg-chalk-dark resource cards that brighten to white on hover with arrow affordance. - Provider References: mono-styled inline chip list with ↗ glyph (rather than a bulleted text grid). - FAQ list: <details>/<summary> using the existing details .faq-content grid-template-rows transition in styles.css (0fr → 1fr over 0.35s, cubic-bezier(0.16, 1, 0.3, 1)). Plus glyph rotates to × via group-open:rotate-45. - Final CTA: LandingPage recipe — bg-ink rounded-3xl py-16 md:py-20, radial accent glow (w-[600px] blur-3xl bg-accent/[0.07]), accent "Ready?" eyebrow, white headline, body, .btn-primary button. Reveal pattern: every below-fold section wrapped in <RevealSection> (fade-up | fade-in | scale-in). The intro callout renders immediately (above-fold). Per CSS in styles.css, prefers-reduced-motion users get all content instantly with no animation. Provider tiles: kept monochrome with strong typography. Per-provider color tints would either need 6 off-palette brand colours or a green ramp (lying about ratings). The comparison table already carries per-provider visual hierarchy upstream. Visible copy: unchanged. FAQ count: unchanged at 8. Test guards (h2 classes, h3 classes, table 9 rows, scope attrs, "Last reviewed" present, "Last tested" absent): all hold. Verified end-to-end via Chrome DevTools MCP at desktop and mobile 375: - 7 h2s all carry font-display text-3xl md:text-4xl font-extrabold tracking-tight (test contract intact) - 17 h3s all carry font-display text-xl font-bold text-ink - 36 span[role=img][aria-label*="out of 5"] in the table - 10 [data-reveal] elements (8 mine + 2 from BlogLayout) - 8 <details> for FAQs; expand transition measured at grid-template-rows: 0fr → 357.75px over 0.35s - Dark CTA: bg #0c0c0c, headline #fff, .btn-primary text #0c0c0c on bg #00d47e — WCAG AA - Prerendered HTML: exactly 1 "@type":"FAQPage", 1 "Last reviewed", 0 "Last tested", 0 "Reviewed as", btn-primary + rounded-3xl present - Tests: 4/4 pass; tsc clean; vite build + prerender succeed
Visible content surface changed in this branch (PR A — visual quality pass): comparison-table star rendering, dark-CTA recipe, FAQ accordion mechanics, design-system migration of cards/eyebrows/RevealSection. Source of truth in sitemapUrls.ts:96; regenerated sitemap.xml emits on next build via npm run generate:sitemap. Companion seo-tracking/changelog.md entry recorded locally (dir is gitignored — local source of truth per CLAUDE.md SEO Governance).
There was a problem hiding this comment.
Code Review
This pull request refactors the AIResumePromptsHub component to use a structured data model for comparison rows and introduces several UI enhancements, including a new StarRating component and RevealSection animations. It also includes CSS fixes for button visibility within prose blocks and updated unit tests to verify accessible rating displays. Feedback was provided regarding the use of a potentially undefined CSS class text-mist, suggesting a replacement with a known design system color to ensure consistent styling.
…mpts hub
Two additive structural changes to the AI Resume Prompts Hub blog post,
both informed by a GSC + Gemini Deep Research re-evaluation done after
the earlier polish pass.
Why:
- The sibling /blog/claude-resume-prompts page accumulates GSC
impressions on per-section anchor URLs (#career-change,
#cover-letters); the hub had no equivalent per-FAQ anchors.
- Per-tool freshness markers were declined earlier as honesty-laundering
(all eight tools sharing one global review date is not granular
signal). Deep Research recommended a single page-level <time> element
carrying refresh cadence as the freshness signal instead.
What:
- Add stable kebab-case id="faq-{slug}" to each of the 8 entries in
HUB_FAQS, wired to the rendered <details id={faq.id}>. Slugs are
self-describing (faq-chatgpt-vs-claude, faq-is-gemini-good-for-resume,
etc.) so the URL form is grep-friendly in GSC.
- Replace the inline "Last reviewed {REVIEW_DATE}." sentence at the tail
of the intro paragraph with a dedicated <time datetime="2026-05-13">
element placed between the SectionEyebrow and the body paragraph. The
new line reads "Last reviewed 2026-05-13 · Refresh cadence:
Quarterly". Visible string "Last reviewed 2026-05-13" still appears
exactly once on the page.
Schema:
- FAQPage JSON-LD is byte-equivalent. generateFAQPageSchema
(schemaGenerators.ts:99-112) reads only faq.question and faq.answer,
so the extra id field is ignored at serialization time. No schema
cardinality change. Verified by prerendered-HTML grep: "@type":"FAQPage"
appears exactly once.
Verification:
- vitest 4/4 pass (no test changes needed — existing /Last reviewed
2026-05-13/i regex still matches the new <time> textContent).
- tsc --noEmit clean.
- eslint on the touched file clean.
- npm run build:prerender succeeds (119/119 routes).
- Prerendered HTML greps: 8 id="faq-, 1 "Last reviewed 2026-05-13",
1 "Refresh cadence: Quarterly", 1 "@type":"FAQPage", 0 "Last tested",
0 "Reviewed as".
- Browser: element exists with correct id at expected anchor position;
scrollIntoView places it at viewport top; new <time> renders as
semantic <time> (not <span>), datetime="2026-05-13", with mono/uppercase
styling between the eyebrow and paragraph.
- Screenshot: test-screenshots/30-prompts-hub-refresh-time.png.
Adds two structural SEO additions on top of 4aa1d1c, layered onto the same branch so a single dev redeploy tests both passes. Why: - GSC validation (mcp__gsc__get_advanced_search_analytics, dimensions=page, 28d window) showed /blog/claude-resume-prompts fragment anchors accumulate 343 impressions vs 159 on the canonical, at avg position 7.3 vs 11.3. Every winning fragment sits on an <h2 id="..."> heading. The hub's content-section H2s had no id attributes. - mcp__gsc__inspect_url_enhanced returns "URL unknown to Google" for the hub canonical. None of the three Tier-1 prompt pages (Claude, Gemini, ChatGPT) linked to the hub, leaving the page with no inbound crawl signal beyond BlogLayout's auto-generated Continue Reading widget. What: - AIResumePromptsHub.tsx: add id attributes to 6 content-section H2s (comparison, methodology, prompts-by-tool, related-resources, provider-references, faq). Final-CTA H2 deliberately skipped (conversion block, not searchable content). - Claude/Gemini/ChatGPT prompts pages: add an "AI Resume Prompts Hub" card as the 6th entry in each "Explore Other AI Resume Tools" grid. Matches each file's existing card pattern (font-bold text-ink h3, text-sm text-stone-warm description, identical container classes). Card description varies per host page to avoid identical strings. Schema: no JSON-LD changes. FAQPage from prior commit still validates. Verified: - vitest 4/4 pass on AIResumePromptsHub.test.tsx (id additions don't affect h2 class assertions). - tsc --noEmit clean. - eslint clean on the four touched files. - npm run build:prerender succeeds 119/119. - Prerendered greps on hub: id="comparison"/methodology/prompts-by-tool/ related-resources/provider-references/faq each = 1; prior counts unchanged (id="faq-: 8, Refresh cadence: Quarterly: 1, FAQPage: 1). - Prerendered greps on each Tier-1 page: href="/blog/ai-resume-prompts-hub" = 2 (1 new grid card + 1 pre-existing BlogLayout Continue Reading widget link rendered with full article title; widget is unrelated to this commit). - Browser: all 6 h2 anchors resolve to <H2> elements and scroll correctly via scrollIntoView. Claude page grid has 6 cards in order [chatgpt, gemini, grok, deepseek, copilot, ai-resume-prompts-hub]. - Screenshot: test-screenshots/31-claude-grid-with-hub.png. Follow-ups (NOT in scope here, sequenced after prod deploy): - Submit hub URL to GSC for indexing once prod has the new linking. - Document fragment-impression discovery in seo-tracking/gsc-snapshots.md. - Re-query GSC 2-4 weeks post-deploy to validate H2-anchor bet.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request refactors the AIResumePromptsHub component, introducing structured data types for providers and ratings, and enhancing the UI with a new StarRating component, RevealSection animations, and improved styling. It also includes updated tests and cross-links from related blog posts. Feedback was provided to improve the React keys in the FAQ section using a defensive pattern and to ensure FAQs are correctly passed to the BlogLayout component for proper JSON-LD schema generation.
| <div className="mt-6 space-y-3"> | ||
| {HUB_FAQS.map((faq) => ( | ||
| <details | ||
| key={faq.question} |
There was a problem hiding this comment.
Update the key to use the defensive pattern faq.id || index as per repository standards. Additionally, ensure that these FAQs are extracted into a const FAQS array and passed to the <BlogLayout> component, which is responsible for rendering FAQs and generating the necessary JSON-LD schema for blog posts.
| key={faq.question} | |
| key={faq.id || index} |
References
- When using unique IDs for list keys in React, it is an acceptable defensive pattern to use the item's index as a fallback (e.g., key={item.id || index}) to handle transient states where the ID might not yet be assigned.
- For blog posts with an FAQ section, extract the questions and answers into a const FAQS array. Pass this array as a prop to the component, which will handle both rendering the FAQs and generating the FAQPage JSON-LD schema.
There was a problem hiding this comment.
@gemini-code-assist Both suggestions are already implemented — this is a false positive.
-
Key pattern:
key={faq.question}is correct here. EachHUB_FAQSentry has a uniquequestionstring, which is a stable, unique key. Thefaq.id || indexfallback pattern is unnecessary when the key is already guaranteed unique. Usingindexas a fallback would actually be worse — it defeats React's reconciliation for reordered lists. -
FAQs extracted + passed to BlogLayout: Already done.
HUB_FAQSis aconstarray defined at line 38, and it's passed to<BlogLayout>via thefaqsprop at line 434:
<BlogLayout
...
faqs={HUB_FAQS}
>BlogLayout emits the FAQPage JSON-LD schema from this prop (verified: the page has exactly 1 FAQPage schema with 8 entries). The visible <details> accordion is rendered separately in the component body — this is the documented pattern per project_blog_faq_pattern.md: BlogLayout faqs prop emits JSON-LD only; visible FAQ JSX is rendered by the page component.
Closing as not-actionable.
There was a problem hiding this comment.
Thank you for the clarification, aafre. You are correct that key={faq.question} is a stable and unique identifier for this list, and using the index as a fallback is unnecessary and potentially detrimental to React's reconciliation process. I also appreciate the confirmation that the FAQs are correctly extracted and passed to BlogLayout for JSON-LD schema generation. I have noted your resolution of this thread.
- 4 thin pages (chatgpt, grok, copilot, deepseek prompts) return HTTP 410 with SPA shell for human UX; Googlebot sees 410 and de-indexes - Hub rewritten: agentic 5-step workflow with copy-ready code blocks, expanded tool profiles with before/after examples, privacy comparison table, ATS formatting section, token limits section - 28 internal links updated across 10 blog/SEO components + footer - Sitemap + blogPosts entries removed for 4 thin pages - Evidence artifact at seo-tracking/ai-resume-prompts-hub-benchmark-2026-05-25.md - Tests: 1445 pass, tsc clean, dev deployed and verified by 2 agents (61/61)
Summary
First enhancement pass for
/blog/ai-resume-prompts-hub(newly shipped via #526). Visual quality only — no copy edits, no schema cardinality change. Content enrichment (per-section answer blocks + atomicized FAQ answers) is held for PR B, shipping ≥48h after this PR lands cleanly in dev, so GSC impression movement can be attributed cleanly to either bucket.Chained off
release/post-cliff-bundleper PR-chain pattern.What's in this PR
Two confirmed visible bugs fixed (atomic, in order)
1.
fix(blog): repair invisible CTA on AI prompts hub (prose cascade)—9f82ff24The dark-CTA
<Link>rendered as green text on a green button — invisible. The real root cause was selector specificity, not the local className cocktail:BlogLayoutwraps blog children in<div className="prose prose-lg">, andstyles.css:694declares.prose a { color: #00d47e }. That rule (0,1,1) beats every Tailwindtext-Xutility (0,1,0), so eventext-white/text-inklose to the prose rule. The existing dark-CTA escape hatch atstyles.css:706coversh2/h3/h4/p/liinside.prose .text-whitebut nota..btn-primary(per CLAUDE.md mandate for primary CTAs)..prose a.btn-primarycolor override instyles.cssso the design-system class always wins inside blog prose. Defence-in-depth for any future.btn-primaryplaced inside aBlogLayoutchild.Browser-verified:
computed color rgb(12, 12, 12)onbackgroundColor rgb(0, 212, 126)overancestor bg rgb(12, 12, 12). WCAG AA pass.2.
fix(blog): render comparison ratings as filled+empty stars—8f570761The
<td>inheritedtext-stone-warm(#8a8680) onto pre-formed★★★★★glyph strings, muting the rating into an unscannable grey blob. Mixed-data complication: rows 1–6 are ratings; rows 7–8 are text (Yes / Limited, privacy notes).COMPARISON_ROWSmigrated to a discriminated union (kind: "rating"rows carry 1–5 numerics;kind: "text"rows keep strings).StarRatinghelper renders 5 glyphs total — accent-filled +text-black/15empty — wrapped in<span role="img" aria-label="N out of 5">so screen readers announce the value once, not five times.PROVIDERSconst drives both the column headers and the per-row iteration so ordering is explicit.Test updated to assert 36
[role="img"][aria-label*="out of 5"]cells (6 rating rows × 6 providers) and that no raw★★★★★string text remains. The existing 9-row count, scope attributes, and use-case label assertions all hold.Design-system migration (LandingPage 2026 recipe)
3.
style(blog): apply 2026 design system to AI prompts hub—751515ecPer the
frontend-designskill audit, applied per-section deltas againstLandingPage.tsxas the reference implementation:bg-white rounded-2xl shadow-premium card-gradient-borderwith an accent-mono "THE SHORT ANSWER" eyebrow. No reveal wrapper (above-fold).rounded-2xl shadow-premiumwrapper,bg-chalk/40zebra stripes, "TOOL × USE CASE" eyebrow, lighterblack/[0.04]dividers.card-gradient-border+shadow-premium-hover+ hover lift.rounded-2xlcards withshadow-premium+ hover lift, numbered0X / 06mono index above each H3, accent-coloured ordered-list markers, arrow glyph on the Claude/Gemini standalone-guide CTAs.bg-chalk-darkcards that brighten to white on hover with arrow affordance.↗glyph.<details>/<summary>using the existingdetails .faq-contentgrid-template-rows transition (0fr → 1fr @ 0.35s cubic-bezier(0.16, 1, 0.3, 1)). Plus icon rotates to×viagroup-open:rotate-45.bg-ink rounded-3xl py-16 md:py-20, radial accent glow (w-[600px] blur-3xl bg-accent/[0.07]), accent "Ready?" eyebrow, white headline, body,.btn-primary.Every below-fold section wrapped in
<RevealSection>(variants:fade-up,fade-in,scale-in). The intro callout renders immediately.4.
chore(seo): bump ai-resume-prompts-hub sitemap lastmod to 2026-05-17—660d0d60Visible content surface changed →
lastmodbumped. Companionseo-tracking/changelog.mdentry recorded locally (dir is gitignored per CLAUDE.md SEO Governance).What's NOT in this PR (deferred to PR B)
Dropped after pushback review (logged in
seo-tracking/changelog.md):Per-tool "Last verified" markers— six identical dates on the same page-level review date would read as boilerplate honesty-laundering, the same pattern the33f423eddev-meta cleanup eliminated.Free-tier monthly-limits comparison row— quarterly drift would silently invalidate the page; only ships with(as of YYYY-MM)per-cell qualifier + a calendar reminder to re-verify.Iron Rules respected
Last reviewed 2026-05-13(page-level); NOLast verifiedmarkers anywhereFAQPageJSON-LD block in prerendered HTMLLast tested/Reviewed asdev-meta strings (prior33f423edcleanup preserved)sameAsentity URLs unchangedTest plan
End-to-end verification on
localhost:5173/blog/ai-resume-prompts-hubvia Chrome DevTools MCP at desktop and mobile 375×667:npx vitest run src/__tests__/AIResumePromptsHub.test.tsx— 4/4 passnpx tsc --noEmit— cleannpm run build:prerender— succeeds; sitemap regenerates with bumped lastmodspan[role="img"][aria-label*="out of 5"]; 9 rows total; thead 7th[scope=col], tbody 8th[scope=row]getComputedStyle(button).color === 'rgb(12, 12, 12)',backgroundColor === 'rgb(0, 212, 126)', ancestor bgrgb(12, 12, 12)— WCAG AA<details>open transitionsgrid-template-rows: 0fr → 357.75pxover0.35s cubic-bezier(0.16, 1, 0.3, 1);+rotates to×font-display text-3xl md:text-4xl font-extrabold tracking-tight(test contract intact)font-display text-xl font-bold text-ink(test contract intact)[data-reveal]elements (8 mine + 2 from BlogLayout)"@type":"FAQPage", 1Last reviewed, 0Last tested, 0Reviewed as, btn-primary + rounded-3xl presentprefers-reduced-motion: reduceCSS guard ships in bundle (content visible without animation)overflow-x-autoas designed formin-w-[760px])Risk & Rollback
.prose a.btn-primaryCSS override is additive and only kicks in when.btn-primaryis used inside a.prosewrapper, so it can't regress other pages.protected-pages.md.9f82ff24,8f570761) are independent and could be cherry-picked if the visual revert alone is desired.