Skip to content

Comments

feat: add contributors popup card#1596

Open
userquin wants to merge 20 commits intomainfrom
userquin/feat-add-contributors-popup-card
Open

feat: add contributors popup card#1596
userquin wants to merge 20 commits intomainfrom
userquin/feat-add-contributors-popup-card

Conversation

@userquin
Copy link
Member

@userquin userquin commented Feb 23, 2026

🔗 Linked issue

resolves #1564

🧭 Context

This PR includes a single popup for all contributors avatar including sponsor link.

NOTE: this PR dont't use dir="auto" nor bdi tags for RTL locales in the popup content, the browser interprets that as LTR and, by default, the text will align to the physical left of the popup. In a purely RTL (Real-Time) UI, this creates a "zigzag" effect:

  • The popup appears on the right (good).
  • The name is stuck to the left of the popup (because it's ASCII/Latin).
  • The bio, if it starts with an emoji or Arabic text, is stuck to the right.
  • unocss icons (like the company logo) remain in their original "start" position (on the right).

I cannot test this on Safari, so any help on this is welcome 🙏 .

NOTE: we need NUXT_GITHUB_ORG_TOKEN at Vercel (for PR preview environment), without the token only popup shown for steward contributors (default ones)

some screenshots danielroe's popup card
antfu's popup card

📚 Description

This PR uses GH graphql to get the following info to be displayed in the popup (sponsor was already there):

  • name
  • bio
  • company and companyName (companyHTML when present is used, it is sanitized from the graphql endpoint)
  • location
  • website url
  • twitter link

The card uses purple colors for the sponsor anchor colors and includes also an anchor for the GH account. The card won't work without JavaScript, too complex to handle focus state, the user can just press enter on the avatar and will be sent to the GH account page.

This PR also includes a change to Link/Base.vue since there is no way to remove the external link icon (added back noExternalIcon boolean prop)

To test this PR in your local you MUST be a maintainer here, and create a PAT with read:org at admin:org:

  • go to your tokens
  • click on Generate new token and select Generate new token (classic)
  • add a note to identify the token and check read:org at admin:org
  • click on Generate token button: don't close the page, copy the token before leaving the page
  • you can also use any token you have with the read:org
PAT with `read:org` at `admin:org`

Once created, you MUST copy and paste it at .env file at root (included at .gitignore and generated by one module) using:
NUXT_GITHUB_ORG_TOKEN=<your_token>:

.env file containing the NUXT_GITHUB_ORG_TOKEN

Stop dev server, delete .nuxt/cache folder and start dev server.

Used Gemini PRO to change the popup position and the arrow at the bottom/top, I did some manual changes.

@vercel
Copy link

vercel bot commented Feb 23, 2026

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

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 23, 2026 8:49am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 23, 2026 8:49am
npmx-lunaria Ignored Ignored Feb 23, 2026 8:49am

Request Review

@github-actions
Copy link

github-actions bot commented Feb 23, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/ar-EG.json Localization changed, will be marked as complete.
lunaria/files/cs-CZ.json Localization changed, will be marked as complete.
lunaria/files/de-DE.json Localization changed, will be marked as complete.
lunaria/files/en-GB.json Localization changed, will be marked as complete.
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
lunaria/files/es-419.json Localization changed, will be marked as complete.
lunaria/files/es-ES.json Localization changed, will be marked as complete.
lunaria/files/fr-FR.json Localization changed, will be marked as complete.
lunaria/files/ja-JP.json Localization changed, will be marked as complete.
lunaria/files/pl-PL.json Localization changed, will be marked as complete.
lunaria/files/uk-UA.json Localization changed, will be marked as complete.
lunaria/files/zh-CN.json Localization changed, will be marked as complete.
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 23, 2026

📝 Walkthrough

Walkthrough

Adds a noExternalIcon prop to the Link component and conditions external-icon rendering on it; extends server/api/contributors.get.ts with a GitHubUserData type, HTML/string sanitisation, batch-fetching of additional GitHub user fields and merging those into contributor objects; rewrites app/pages/about.vue to use a ClientOnly, fixed-viewport contributor popover with hover timers, flip positioning, expanded contributor card fields and interaction handlers; updates i18n schema and many locale/lunaria files by removing team.governance, simplifying contributor title strings, and adding contributor-related keys (avatar, view_profile_detailed, separator, role, works_at, location).

Possibly related PRs

Suggested reviewers

  • danielroe
  • ghostdevv
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description comprehensively describes the implementation of a contributor popup card with GitHub data fetching, styling, and local testing instructions.
Linked Issues check ✅ Passed The PR implements identity claims (name, bio, company, location, website, Twitter) on contributor cards and includes external account links (GitHub, sponsors) as required by issue #1564.
Out of Scope Changes check ✅ Passed The PR includes restoration of the noExternalIcon prop in Link/Base.vue, which is a necessary supporting change to remove external link icons from the popup content, directly enabling the feature scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch userquin/feat-add-contributors-popup-card

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/api/contributors.get.ts (1)

244-254: ⚠️ Potential issue | 🟡 Minor

Type contract broken on the no-token path.

When githubToken is absent, userData is empty and userData.get(c.login) || {} yields {}. After Object.assign the GitHubUserData fields remain undefined on the resulting object, yet the cast on Line 251 tells TypeScript they are string | null. The template's v-if guards survive this at runtime, but it silently violates the interface contract and can confuse future callers.

🛠️ Suggested fix — provide explicit null fallback
-        const userInfo = userData.get(c.login) || {}
+        const userInfo: GitHubUserData = userData.get(c.login) ?? {
+          name: null,
+          bio: null,
+          company: null,
+          companyHTML: null,
+          location: null,
+          websiteUrl: null,
+          twitterUsername: null,
+        }
🧹 Nitpick comments (1)
app/pages/about.vue (1)

8-11: isMounted is never consumed; timer refs typed as any.

isMounted is set in onMounted but is not referenced anywhere in the template or script — it is dead code. Additionally, shallowRef<any> for timer handles violates the project's strict type-safety guideline; these should be typed as ReturnType<typeof setTimeout> | null.

✏️ Suggested fix
-// SSR & Validation Fix
-const isMounted = shallowRef(false)
 const activeContributor = shallowRef<GitHubContributor | null>(null)
-const openTimer = shallowRef<any>(null)
-const closeTimer = shallowRef<any>(null)
+const openTimer = shallowRef<ReturnType<typeof setTimeout> | null>(null)
+const closeTimer = shallowRef<ReturnType<typeof setTimeout> | null>(null)
 const isFlipped = shallowRef(false)
-
-onMounted(() => {
-  isMounted.value = true
-})

@codecov
Copy link

codecov bot commented Feb 23, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (7)
app/pages/about.vue (5)

10-11: Prefer a narrower type for timer refs instead of any.

Using shallowRef<any> loses type safety. ReturnType<typeof setTimeout> is the idiomatic way to type timer handles in TypeScript.

♻️ Suggested fix
-const openTimer = shallowRef<any>(null)
-const closeTimer = shallowRef<any>(null)
+const openTimer = shallowRef<ReturnType<typeof setTimeout> | null>(null)
+const closeTimer = shallowRef<ReturnType<typeof setTimeout> | null>(null)

As per coding guidelines: "Ensure you write strictly type-safe code" (**/*.{ts,tsx,vue}).


84-88: Silent empty catch blocks hide potential issues.

Lines 86–87 and similarly lines 153–155 swallow all exceptions without logging. While this is likely intentional for browsers lacking Popover API support, a brief comment explaining the rationale would aid future maintainers.

♻️ Suggested improvement
     try {
       ;(popover as any).showPopover()
-    } catch (e) {}
+    } catch {
+      // Popover API not supported — silently degrade
+    }

170-174: Inline focus-visible utility on a <button> — should rely on the global rule.

The button at Line 173 applies focus-visible:outline-accent/70 as an inline utility class. The project's global main.css already provides button:focus-visible styling, so this inline class either conflicts with or duplicates it.

♻️ Suggested fix
-            class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
+            class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"

Based on learnings: "In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css … Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates."


305-307: Loading indicator and list can render simultaneously.

The <ul> uses v-if="contributors.length" (Line 306) independently of the pending/error guards above (Lines 292, 299 which use v-if/v-else-if). When useLazyFetch refreshes cached data, both the "Loading…" text and the stale list will appear at the same time. If this is intentional (showing stale-while-revalidate), consider adding a visual indicator (e.g. reduced opacity) on the list during refresh. If not, change Line 306 to v-else-if.


7-16: Remove the unused isMounted ref and onMounted hook.

The isMounted ref is declared and set to true in onMounted, but is never referenced anywhere else in the component. This is dead code and should be removed.

Proposed removal
-// SSR & Validation Fix
-const isMounted = shallowRef(false)
 const activeContributor = shallowRef<GitHubContributor | null>(null)
 const openTimer = shallowRef<any>(null)
 const closeTimer = shallowRef<any>(null)
 const isFlipped = shallowRef(false)

-onMounted(() => {
-  isMounted.value = true
-})
server/api/contributors.get.ts (2)

121-124: GraphQL query interpolates login without escaping.

Contributor logins are embedded directly into the GraphQL query string. While GitHub usernames are restricted to alphanumeric characters and hyphens (making exploitation near-impossible), escaping the " character would add defence-in-depth against any unexpected input.

🛡️ Suggested hardening
   const fragments = logins.map(
     (login, i) =>
-      `user${i}: user(login: "${login}") { hasSponsorsListing login name bio company companyHTML location websiteUrl twitterUsername }`,
+      `user${i}: user(login: "${login.replace(/"/g, '')}") { hasSponsorsListing login name bio company companyHTML location websiteUrl twitterUsername }`,
   )

81-94: sanitizeGitHubHTML may duplicate target/rel on <a> tags that already have them.

Line 89 unconditionally injects target="_blank" rel="noopener noreferrer" into every <a opening tag. If GitHub's rendered HTML already includes these attributes, they'll appear twice. While browsers tolerate duplicates (using the first occurrence), it's slightly untidy.

This is low-priority since the HTML source is GitHub's API, which currently doesn't include these attributes on company links.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/pages/about.vue (1)

306-328: Expose the popover on keyboard focus as well.

Hover-only access hides the expanded card from keyboard users; mirroring hover with focus/blur keeps it accessible without changing the visual behaviour.

♿ Suggested tweak
                   `@mouseenter`="onMouseEnter(contributor)"
                   `@mouseleave`="onMouseLeave"
+                  `@focus`="onMouseEnter(contributor)"
+                  `@blur`="onMouseLeave"

Comment on lines +171 to 174
v-if="canGoBack"
type="button"
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
@click="router.back()"
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove the per-button focus-visible utility class.

Buttons already inherit a global focus-visible style, so this extra utility is redundant and diverges from the shared rule.

🎯 Suggested fix
-            class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
+            class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"

Based on learnings: In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components.

📝 Committable suggestion

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

Suggested change
v-if="canGoBack"
type="button"
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
@click="router.back()"
v-if="canGoBack"
type="button"
class="cursor-pointer inline-flex items-center gap-2 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded shrink-0"
`@click`="router.back()"

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
server/api/contributors.get.ts (1)

256-267: Object.assign mutates the original REST response object — prefer spreading into a new object.

Object.assign(c, ...) modifies c in-place, which is a surprising side-effect even though allContributors is not referenced afterwards. Spreading into a fresh object is cleaner and removes the need for the broad cast (noting that GitHubContributor already declares sponsors_url and role, so the intersection type is redundant for those fields):

♻️ Proposed refactor
-        Object.assign(c, { role, order, sponsors_url, ...userInfo })
-        return c as GitHubContributor & { order: number; sponsors_url: string | null; role: Role }
+        return { ...c, role, order, sponsors_url, ...userInfo } satisfies GitHubContributor & { order: number }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
app/pages/about.vue (4)

22-30: isMounted is dead code — remove it.

isMounted is set in onMounted but never read anywhere; <ClientOnly> already gates the popover to the client. The ref and its lifecycle hook can be dropped.

♻️ Proposed removal
-const isMounted = shallowRef(false)
 const activeContributor = shallowRef<GitHubContributor | null>(null)
 const openTimer = shallowRef<ReturnType<typeof setTimeout> | undefined>()
 const closeTimer = shallowRef<ReturnType<typeof setTimeout> | undefined>()
 const isFlipped = shallowRef(false)
-
-onMounted(() => {
-  isMounted.value = true
-})
-
 onBeforeUnmount(() => {

439-441: Mixed border styling: hardcoded colours alongside a design token.

border-t-gray-400/65 dark:border-t-gray-300 and border-border-subtle are applied to the same element. The hardcoded values are redundant; the design token alone is sufficient and keeps the component consistent with the rest of the codebase.

♻️ Proposed fix
-          class="mt-3 flex items-center justify-between border-t border-t-gray-400/65 dark:border-t-gray-300 border-border-subtle pt-3"
+          class="mt-3 flex items-center justify-between border-t border-border-subtle pt-3"

463-470: will-change-transform on the arrow is declared twice.

The UnoCSS class will-change-transform on the element (line 465) already generates will-change: transform. The identical rule in the scoped styles (.popover-arrow { will-change: transform }, lines 495–497) is therefore redundant — remove one of them.


481-487: Scoped [popover] rule duplicates template utility classes.

@apply top-0 inset-is-0 and the transition declaration in the scoped rule are already present as UnoCSS utilities in the template (top-0 inset-is-0, transition-[opacity,visibility] duration-150 ease-out). The scoped rule is purely redundant and can be removed, leaving only the will-change and [popover]:not(:popover-open) display-reset rules.

♻️ Proposed fix
 [popover]:not(:popover-open) {
   display: none;
 }
-[popover] {
-  `@apply` top-0 inset-is-0;
-  will-change: transform, opacity;
-  transition:
-    opacity 0.15s ease-out,
-    visibility 0.15s ease-out;
-}
+/* will-change hint for compositor promotion */
+[popover] {
+  will-change: transform, opacity;
+}

@RYGRIT
Copy link
Contributor

RYGRIT commented Feb 23, 2026

You seem to have cited the wrong issue link; it seems more relevant to #1562.

@userquin
Copy link
Member Author

userquin commented Feb 23, 2026

You seem to have cited the wrong issue link; it seems more relevant to #1562.

Upps, no, I pick the wrong issue, it is #1564, updated thx ❤️

@userquin userquin marked this pull request as draft February 23, 2026 05:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/pages/about.vue (1)

327-343: ⚠️ Potential issue | 🟡 Minor

v-if on the <ul> is not chained with the pending/error guards — list may render alongside the loading indicator.

The <ul> at line 342 uses a standalone v-if instead of v-else-if. Because useLazyFetch preserves the previous data across refetches, contributors.length can be truthy while contributorsStatus is 'pending', causing both the "loading" message and the stale contributor list to render simultaneously.

🐛 Proposed fix
             <ul
-              v-if="contributors.length"
+              v-else-if="contributors.length"
               class="grid grid-cols-[repeat(auto-fill,48px)] justify-center gap-2 list-none p-0 overflow-visible"
             >
♻️ Duplicate comments (2)
app/pages/about.vue (2)

88-97: as any casts on showPopover/hidePopover remain.

TypeScript ≥ 5.2 ships Popover API types in lib.dom.d.ts, and package.json declares TS 5.9.3. The (popover as any) casts on lines 90 and 165 should no longer be necessary — removing them would restore type-safety and let the compiler catch misuse.

Also applies to: 164-171


207-210: Per-element focus-visible:outline-accent/70 duplicates the global rule.

This was flagged previously. The global button:focus-visible style in main.css already covers this element; the inline utility is redundant and diverges from the shared rule. Based on learnings: focus-visible styling for button and select elements is implemented globally in app/assets/main.css.

🧹 Nitpick comments (4)
server/api/contributors.get.ts (1)

135-197: Consider extracting query building or user normalisation to keep this function concise.

It now exceeds the 50‑line guideline; splitting into small helpers would keep it focused and easier to test. As per coding guidelines: “Keep functions focused and manageable (generally under 50 lines)”.

app/pages/about.vue (3)

177-195: Use camelCase for local variables (works_atworksAt).

works_at (line 183) and the corresponding destructured usage break the standard TypeScript camelCase convention used elsewhere in the file. This also applies to the location variable that shadows the global location — consider renaming to e.g. locationStr to avoid the shadow.

♻️ Suggested rename
-  const works_at = c.company
-    ? $t('about.contributors.works_at', { separator, company: c.company })
+  const worksAt = c.company
+    ? $t('about.contributors.works_at', { separator, company: c.company })
     : ''
-  const location = c.location
-    ? $t('about.contributors.location', { separator, location: c.location })
+  const locationLabel = c.location
+    ? $t('about.contributors.location', { separator, location: c.location })
     : ''
   return $t('about.contributors.view_profile_detailed', {
     name: c.name || c.login,
     role,
-    works_at,
-    location,
+    works_at: worksAt,
+    location: locationLabel,
   })

As per coding guidelines: **/*.{ts,tsx,vue}: Follow standard TypeScript conventions and best practices.


415-442: Plain-text company branch is missing gap-1 and carries an unused [&_a] selector.

The companyHTML block (line 417) has gap-1 between the icon and text, but the plain-text company fallback (line 431) omits it, producing tighter spacing. Additionally, the [&_a]:(…) UnoCSS selector on the fallback <div> (line 438) targets <a> children, but the content is text interpolation — there will never be an <a> to style.

♻️ Suggested fix
             <div
               v-else-if="activeContributor.company"
-              class="flex items-center font-sans text-2xs text-fg-muted text-start min-w-0"
+              class="flex items-center gap-1 font-sans text-2xs text-fg-muted text-start min-w-0"
             >
               <div
                 class="i-lucide:building-2 size-3 shrink-0 mt-0.5 text-accent/80"
                 aria-hidden="true"
               />
               <div
-                class="company-content leading-relaxed break-words min-w-0 [&_a]:(text-accent no-underline hover:underline transition-all)"
+                class="leading-relaxed break-words min-w-0"
               >

74-131: positionPopover is slightly above the 50-line guideline but acceptable.

The function is ~54 lines and handles a cohesive task (measure, flip, position, arrow). Splitting it further would likely hurt readability. Just noting for awareness — no action needed.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d34b0e and 08145a0.

📒 Files selected for processing (9)
  • app/pages/about.vue
  • i18n/locales/en.json
  • i18n/locales/es.json
  • i18n/schema.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
  • lunaria/files/es-419.json
  • lunaria/files/es-ES.json
  • server/api/contributors.get.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • lunaria/files/es-ES.json
  • lunaria/files/en-GB.json
  • lunaria/files/es-419.json

}
}

function onMouseEnter(contributor: GitHubContributor) {
Copy link
Member

Choose a reason for hiding this comment

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

note - we should also do this when focusing these cards, otherwise the content is inaccessible to non-mouse-users.

same for tap devices - maybe if on a device with touch support then tapping should open the popup rather than go directly to the link

Copy link
Member Author

@userquin userquin Feb 23, 2026

Choose a reason for hiding this comment

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

please check my discussion with coderabbitai here #1596 (comment) it is a complex problem we handle as a progressive enhacement.

We will need another approach, like an activator but ouside the link/anchor. Check some code I did on first approach in the previous coderabbitai comment.

Copy link
Member Author

Choose a reason for hiding this comment

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

As alternative we can switch the BaseLink to a BaseButton or just a custom button and use ENTER to go to GH profile or SPACE to show the popup or just prevent this logic and use the lnk in the popup to go to the GH profile if the user needs more info or just want.

Copy link
Member Author

Choose a reason for hiding this comment

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

We can't use an anchor as both a link and a button; this is semantically incorrect and a screen reader will go crazy: tapping the link will open a pop-up window, so what? It's an anchor/link.

Copy link
Member Author

Choose a reason for hiding this comment

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

Another problem, without JavaScript we'll need to use an anchor via noscript inside a list: maybe I can explore css alternatives (CSS grid)

Copy link
Member

Choose a reason for hiding this comment

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

how about we just never have the cards work as a link? they always popup a dialog...

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, but patak wants same layout for any contributor but we cannot add such ammount of cards, we'll need to add an upper limit (the card size may vary, some empty some full, some with long bio or location)...

Copy link
Member

@knowler knowler Feb 23, 2026

Choose a reason for hiding this comment

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

Since the “hovercard” includes contents that would be available if a user were to following the link and its not essential for the contents to be displayed on our page, I think it could be worth thinking of this feature as an enhancement. So, I don’t think it’s worth making it work for mobile—they can just tap through and find the same information.

I think we could just start off by copying GitHub here:

  • Use a link as the base, but don’t alter its normal activation behaviour.
  • Show the card on hover (for mouse users).
  • We can set a shortcut to show the card for keyboard/screen reader users. GitHub uses Alt+ArrowUp which it sets in aria-keyshortcuts. We can include whatever we decide on in our keyboard shortcuts legend (I don’t think it’s worth showing the keys like we do for other shortcuts).
    • When the shortcut is used, the popover opens with the first focusable item focused.
  • GitHub uses the region landmark here, but I might prefer the article role for the hovercard instead.

I’ll take a look around for more and do some more testing (I only tested VoiceOver on macOS), but I think this is an acceptable start.

Copy link
Member

@knowler knowler Feb 23, 2026

Choose a reason for hiding this comment

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

Ok, I’m looking at the updated design and since it does include the name and role, that would be considered essential content, so it might be better for these to be buttons that open an anchored popover. I wouldn’t make the buttons themselves links. Links would need to be within the popover.

A rough example: https://knowler.dev/demos/r3QNDGk?codepen (Firefox and Safari don’t quite work as expected)

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
app/pages/about.vue (1)

207-209: focus-visible:outline-accent/70 still present on back button.

This inline utility duplicates the global button:focus-visible rule in main.css and was previously flagged as redundant.

🧹 Nitpick comments (4)
app/pages/about.vue (4)

22-30: Remove unused isMounted ref — dead code.

isMounted is set in onMounted but is never referenced anywhere in the current template. The keyboard-interactive prototype (where it guarded aria-expanded/aria-controls) was intentionally dropped; ClientOnly now handles SSR concerns. The ref can be removed alongside its onMounted callback.

🧹 Proposed cleanup
-const isMounted = shallowRef(false)
 const activeContributor = shallowRef<GitHubContributor | null>(null)
 const openTimer = shallowRef<ReturnType<typeof setTimeout> | undefined>()
 const closeTimer = shallowRef<ReturnType<typeof setTimeout> | undefined>()
 const isFlipped = shallowRef(false)

-onMounted(() => {
-  isMounted.value = true
-})
-
 onBeforeUnmount(() => {

147-147: Use () => void trigger() for consistency with line 145.

The prior review fix explicitly recommended this form; setTimeout(trigger, 80) is functionally equivalent but inconsistently signals intent compared to void trigger() on line 145.

-    openTimer.value = setTimeout(trigger, 80)
+    openTimer.value = setTimeout(() => void trigger(), 80)

158-175: closeTimer.value not cleared after its callback fires.

openTimer.value is always reset to undefined after use, but closeTimer.value retains the spent ID after the 120 ms callback runs. While harmless (subsequent cancelClose() calls clearTimeout on a stale ID safely), it is inconsistent with the established reset pattern.

🔧 Proposed fix
   closeTimer.value = setTimeout(() => {
     const popover = document.getElementById('shared-contributor-popover')
     if (popover && !popover.matches(':hover')) {
       try {
         ;(popover as any).hidePopover()
       } catch (e) {
         if (import.meta.dev) {
           // oxlint-disable-next-line no-console
           console.warn('[onMouseLeave] hidePopover failed:', e)
         }
       }
       activeContributor.value = null
     }
+    closeTimer.value = undefined
   }, 120)

177-195: works_at is snake_case; roleLabels.value[c.role] accessed twice without caching.

Two issues:

  1. works_at should be worksAt — TypeScript/JavaScript convention is camelCase for local variables.
  2. roleLabels.value[c.role] is accessed twice: once as a truthiness guard and again as the role parameter. TypeScript does not narrow the second access, so it remains string | undefined. Store it in an intermediate variable.
✏️ Proposed fix
 function getAriaLabel(c: GitHubContributor): string {
   const separator = $t('about.contributors.separator')
-  const role = roleLabels.value[c.role]
-    ? $t('about.contributors.role', { separator, role: roleLabels.value[c.role] })
-    : ''
-  const works_at = c.company
+  const roleLabel = roleLabels.value[c.role]
+  const role = roleLabel
+    ? $t('about.contributors.role', { separator, role: roleLabel })
+    : ''
+  const worksAt = c.company
     ? $t('about.contributors.works_at', { separator, company: c.company })
     : ''
   const location = c.location
     ? $t('about.contributors.location', { separator, location: c.location })
     : ''
   return $t('about.contributors.view_profile_detailed', {
     name: c.name || c.login,
     role,
-    works_at,
+    works_at: worksAt,
     location,
   })
 }

As per coding guidelines: "Follow standard TypeScript conventions and best practices" and "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index."

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 08145a0 and b55b56a.

📒 Files selected for processing (1)
  • app/pages/about.vue

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/pages/about.vue (1)

207-210: focus-visible:outline-accent/70 on the back button is still present.

This was flagged previously — the per-element override conflicts with the global button:focus-visible rule in main.css and should be removed.

🧹 Nitpick comments (3)
app/pages/about.vue (3)

78-131: positionPopover slightly exceeds the 50-line guideline — consider extracting sub-helpers.

At ~54 lines, the function mildly violates the project's "generally under 50 lines" rule. The position-maths and arrow-update logic are natural extraction points.

♻️ Suggested split
+function calculatePosition(
+  rect: DOMRect,
+  popoverWidth: number,
+  popoverHeight: number,
+  padding: number,
+) {
+  const showBelow = rect.top < popoverHeight + 20
+  const idealLeft = rect.left + rect.width / 2
+  const minLeft = popoverWidth / 2 + padding
+  const maxLeft = window.innerWidth - popoverWidth / 2 - padding
+  const finalLeft = Math.max(minLeft, Math.min(idealLeft, maxLeft))
+  const yBase = showBelow ? rect.bottom + 12 : rect.top - 12
+  const yPercent = showBelow ? '0' : '-100%'
+  return { showBelow, idealLeft, finalLeft, yBase, yPercent }
+}
+
+function updateArrow(popover: HTMLElement, idealLeft: number, finalLeft: number) {
+  const arrow = popover.querySelector('.popover-arrow') as HTMLElement | null
+  if (arrow) {
+    arrow.style.setProperty('--arrow-delta', `${idealLeft - finalLeft}px`)
+  }
+}

 async function positionPopover(anchorId: string) {
   const popover = document.getElementById('shared-contributor-popover')
   const anchor = document.getElementById(anchorId)
   if (!popover || !anchor) return

   await nextTick()
   if (!popover.matches(':popover-open')) {
     try {
       ;(popover as any).showPopover()
     } catch (e) {
       if (import.meta.dev) {
         // oxlint-disable-next-line no-console
         console.warn('[positionPopover] showPopover failed:', e)
       }
     }
   }
   await nextTick()

   const rect = anchor.getBoundingClientRect()
   const padding = 16
   const popoverWidth = popover.offsetWidth || 256
   const popoverHeight = popover.offsetHeight || 280

-  const showBelow = rect.top < popoverHeight + 20
-  isFlipped.value = showBelow
-
-  const idealLeft = rect.left + rect.width / 2
-  const minLeft = popoverWidth / 2 + padding
-  const maxLeft = window.innerWidth - popoverWidth / 2 - padding
-  const finalLeft = Math.max(minLeft, Math.min(idealLeft, maxLeft))
-
-  const yBase = showBelow ? rect.bottom + 12 : rect.top - 12
-  const yPercent = showBelow ? '0' : '-100%'
-
+  const { showBelow, idealLeft, finalLeft, yBase, yPercent } = calculatePosition(rect, popoverWidth, popoverHeight, padding)
+  isFlipped.value = showBelow
   popover.style.transform = `translate3d(${finalLeft}px, ${yBase}px, 0) translate(-50%, ${yPercent})`
-
-  const arrow = popover.querySelector('.popover-arrow') as HTMLElement
-  if (arrow) {
-    const delta = idealLeft - finalLeft
-    arrow.style.setProperty('--arrow-delta', `${delta}px`)
-  }
+  updateArrow(popover, idealLeft, finalLeft)
 }

As per coding guidelines: "Keep functions focused and manageable (generally under 50 lines)".


183-188: Use camelCase for local variables; avoid shadowing window.location.

Two naming issues in getAriaLabel:

  1. works_at (line 183) is snake_case — TypeScript convention requires camelCase worksAt.
  2. location (line 186) shadows the global window.location, which can cause confusion and may trigger lint warnings.
♻️ Suggested fix
-  const works_at = c.company
-    ? $t('about.contributors.works_at', { separator, company: c.company })
+  const worksAt = c.company
+    ? $t('about.contributors.works_at', { separator, company: c.company })
     : ''
-  const location = c.location
-    ? $t('about.contributors.location', { separator, location: c.location })
+  const locationPart = c.location
+    ? $t('about.contributors.location', { separator, location: c.location })
     : ''
   return $t('about.contributors.view_profile_detailed', {
     name: c.name || c.login,
     role,
-    works_at,
-    location,
+    works_at: worksAt,
+    location: locationPart,
   })

As per coding guidelines: "Follow standard TypeScript conventions and best practices".


408-413: Hardcoded quote characters around bio are not i18n-aware.

Wrapping the bio in literal "…" works for English but is incorrect for many locales (French uses «…», German uses „…"). Consider moving the surrounding quotes into the i18n string or removing them and relying on the italic styling alone to convey the quote context.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b55b56a and 55db682.

📒 Files selected for processing (1)
  • app/pages/about.vue

Comment on lines +429 to +442
<div
v-else-if="activeContributor.company"
class="flex items-center font-sans text-2xs text-fg-muted text-start min-w-0"
>
<div
class="i-lucide:building-2 size-3 shrink-0 mt-0.5 text-accent/80"
aria-hidden="true"
/>
<div
class="company-content leading-relaxed break-words min-w-0 [&_a]:(text-accent no-underline hover:underline transition-all)"
>
{{ activeContributor.company }}
</div>
</div>
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 23, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing gap-1 on the plain-text company row.

The companyHTML branch (line 417) includes gap-1 on the flex container, but the v-else-if plain-text branch (line 431) omits it, causing the building icon and the company name to render flush against each other.

🐛 Proposed fix
-              class="flex items-center font-sans text-2xs text-fg-muted text-start min-w-0"
+              class="flex items-center gap-1 font-sans text-2xs text-fg-muted text-start min-w-0"

Copy link
Member Author

Choose a reason for hiding this comment

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

remove mt-0.5 from icon adding gap-1 to the container

Copy link
Contributor

Choose a reason for hiding this comment

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

Task: Adjust company row styling

  • Add gap-1 to the plain-text company container (line 431)
  • Remove mt-0.5 from both company icons (lines 420 and 436)

@userquin, got it! I've updated the fix to add gap-1 to the plain-text container and remove mt-0.5 from both icons for consistent spacing.


🧠 Learnings used
Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 1596
File: app/pages/about.vue:318-341
Timestamp: 2026-02-23T05:31:51.043Z
Learning: In the npmx.dev project, the contributor popover on the about page is intentionally a mouse-only progressive enhancement. Keyboard support (focus/blur with full popover interaction) was tested but rejected due to Firefox performance issues (3-4 second lag with per-contributor popovers in the Top Layer) and implementation complexity (aria-controls, aria-expanded, focus trapping, Escape handling). All contributor information remains accessible via the clickable link to the GitHub profile.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!


💡 For code changes across multiple files, use @coderabbitai plan in an issue comment

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Description and identity claims to governance cards

4 participants