From e0641bb421d6398a39804fb55c64142507817384 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 5 Apr 2026 21:01:58 -0400 Subject: [PATCH 01/13] feat(ui): adds accessible tooltips and user documentation --- CONTRIBUTING.md | 2 + README.md | 4 + docs/USER_GUIDE.md | 409 ++++++++++++++++++ .../components/dashboard/DashboardPage.tsx | 14 +- src/app/components/dashboard/IssuesTab.tsx | 28 +- .../dashboard/PersonalSummaryStrip.tsx | 2 + .../components/dashboard/PullRequestsTab.tsx | 5 +- src/app/components/layout/FilterBar.tsx | 57 +-- src/app/components/layout/Header.tsx | 125 +++--- .../components/onboarding/RepoSelector.tsx | 49 ++- src/app/components/settings/SettingsPage.tsx | 72 ++- .../settings/TrackedUsersSection.tsx | 39 +- .../shared/ExpandCollapseButtons.tsx | 84 ++-- .../components/shared/RepoLockControls.tsx | 91 ++-- src/app/components/shared/SizeBadge.tsx | 17 +- src/app/components/shared/StatusDot.tsx | 28 +- src/app/components/shared/Tooltip.tsx | 81 ++++ src/app/components/shared/UserAvatarBadge.tsx | 12 +- tests/components/shared/StatusDot.test.tsx | 12 +- tests/components/shared/Tooltip.test.tsx | 75 ++++ .../shared/UserAvatarBadge.test.tsx | 11 - 21 files changed, 937 insertions(+), 280 deletions(-) create mode 100644 docs/USER_GUIDE.md create mode 100644 src/app/components/shared/Tooltip.tsx create mode 100644 tests/components/shared/Tooltip.test.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 747e95f5..9a526df5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,3 +81,5 @@ Scope is optional. Use imperative mood: "add feature", not "adds feature" or "ad All PRs target `main` on `gordon-code/github-tracker`. Keep PRs focused — one feature or fix per PR makes review faster and reverts cleaner. In the PR body, describe what changed and why. CI runs typecheck, unit tests, and E2E tests automatically. PRs need a passing CI run before merge. + +When adding or changing user-facing features, update [docs/USER_GUIDE.md](docs/USER_GUIDE.md) to reflect the changes. diff --git a/README.md b/README.md index d3d08014..04472235 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ A dashboard for tracking GitHub issues, PRs, and Actions workflow runs across ma ![Dashboard](docs/dashboard-screenshot.png) +## Documentation + +For detailed feature documentation, see the [User Guide](docs/USER_GUIDE.md). + ## Features ### Issues diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 00000000..91fc17c5 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,409 @@ +# GitHub Tracker User Guide + +GitHub Tracker is a dashboard that aggregates open issues, pull requests, and GitHub Actions workflow runs across your repositories into a single view at [gh.gordoncode.dev](https://gh.gordoncode.dev). + +## Table of Contents + +- [Getting Started](#getting-started) + - [OAuth Sign-In](#oauth-sign-in) + - [Personal Access Token Sign-In](#personal-access-token-sign-in) + - [Repository Selection](#repository-selection) + - [Organization Access](#organization-access) +- [Dashboard Overview](#dashboard-overview) + - [Tab Structure](#tab-structure) + - [Personal Summary Strip](#personal-summary-strip) + - [Repo Grouping and Expand/Collapse](#repo-grouping-and-expandcollapse) + - [Scope Filter](#scope-filter) +- [Issues Tab](#issues-tab) + - [Filters](#issues-filters) + - [Dependency Dashboard Toggle](#dependency-dashboard-toggle) + - [Sorting](#issues-sorting) + - [Ignored Items](#ignored-items) +- [Pull Requests Tab](#pull-requests-tab) + - [Status Indicators](#status-indicators) + - [Filters](#pull-requests-filters) + - [Sorting](#pull-requests-sorting) +- [Actions Tab](#actions-tab) + - [Workflow Grouping](#workflow-grouping) + - [Show PR Runs](#show-pr-runs) + - [Filters](#actions-filters) +- [Multi-User Tracking](#multi-user-tracking) +- [Monitor-All Mode](#monitor-all-mode) +- [Upstream Repos](#upstream-repos) +- [Refresh and Polling](#refresh-and-polling) +- [Notifications](#notifications) +- [Repo Pinning](#repo-pinning) +- [Settings Reference](#settings-reference) +- [Troubleshooting](#troubleshooting) + +--- + +## Getting Started + +### OAuth Sign-In + +OAuth is the recommended sign-in method. Click **Sign in with GitHub** on the login page and authorize the application. GitHub will redirect you back with a token that grants access to `repo`, `read:org`, and `notifications` scopes. + +OAuth tokens work across all organizations you belong to and support the notifications optimization that reduces API usage in background tabs. + +### Personal Access Token Sign-In + +If you prefer not to use OAuth, you can sign in with a GitHub Personal Access Token (PAT). Click **Use a Personal Access Token** on the login page and paste your token. + +Two token formats are accepted: + +- **Classic tokens** (starts with `ghp_`) — recommended. Works across all organizations you belong to. Required scopes: `repo`, `read:org` (under admin:org), `notifications`. +- **Fine-grained tokens** (starts with `github_pat_`) — also work, but have limitations: they only access one organization at a time, do not support the `notifications` scope, and therefore cannot use the background-poll optimization. Required permissions: Actions (read), Contents (read), Issues (read), Pull requests (read). + +The token is validated against the GitHub API before being stored. It is saved permanently in your browser's `localStorage` — you will not need to re-enter it on revisit. + +### Repository Selection + +After signing in, the onboarding wizard asks you to select repositories to track. Search by name or browse by organization. You can change your selection at any time in **Settings > Repositories**. + +### Organization Access + +OAuth sign-in uses your existing GitHub org memberships. If a private organization does not appear in the repo selector, click **Manage org access** in Settings to open GitHub's OAuth application settings and grant access to that organization. When you return to the tracker, it automatically syncs your updated org list. + +--- + +## Dashboard Overview + +### Tab Structure + +The dashboard has three tabs: + +| Tab | Contents | +|-----|----------| +| **Issues** | Open issues across your selected repos where you are the author, assignee, or mentioned | +| **Pull Requests** | Open PRs where you are the author, reviewer, or assignee | +| **Actions** | Recent workflow runs for your selected repos | + +The active tab is remembered across page loads by default. You can set a fixed default tab in Settings. + +### Personal Summary Strip + +A summary strip appears directly below the tab bar whenever there is actionable activity. It shows counts for: + +- **Issues assigned** to you +- **PRs awaiting your review** (you are a requested reviewer and the PR needs review) +- **PRs ready to merge** (you are the author, checks pass, PR is approved or has no review requirement, not a draft) +- **PRs blocked** (you are the author, PR is not a draft, checks are failing or there is a merge conflict) +- **Actions running** (in-progress workflow runs, excluding ignored items) + +Clicking any count switches to the relevant tab and applies filters that match what was counted. This lets you jump directly to the most important items. + +Counts are computed across all repos regardless of any org or repo filter you have active on the tab. + +### Repo Grouping and Expand/Collapse + +Items are grouped by repository. Each repo group has a header row showing the repo name, item count, and a summary of statuses (check results, review decisions, role counts). Click a repo header to expand or collapse that group. + +Use the **Expand all** / **Collapse all** buttons in the toolbar to expand or collapse all groups at once. + +When a group is collapsed, a brief preview of any status change detected by the hot poll appears under the header for a few seconds before fading. + +### Scope Filter + +The **Scope** filter chip appears on the Issues and Pull Requests tabs when you have tracked users configured or monitor-all repos enabled. It has two options: + +- **Involves me** (default) — shows only items where you (the signed-in user) are the author, assignee, reviewer, or mentioned. For monitored repos, all activity in that repo is always shown regardless of scope. +- **All activity** — shows every open item across your selected repos. Items that involve you are highlighted with a blue left border. + +The scope filter is hidden (and always set to "Involves me") when you have no tracked users and no monitor-all repos, because in that configuration all fetched data already involves you. + +--- + +## Issues Tab + +### Issues Filters + +| Filter | Options | Default | +|--------|---------|---------| +| Scope | Involves me / All activity | Involves me | +| Role | All / Author / Assignee | All | +| Comments | All / Has comments / No comments | All | +| User | All / (tracked user logins) | All (shown when tracked users are configured) | + +Filters can be combined. Click **Reset all** to clear all active filters at once. + +### Dependency Dashboard Toggle + +The **Show Dep Dashboard** button controls whether the Renovate "Dependency Dashboard" issue appears in the list. It is hidden by default. This setting applies across all repos. + +### Issues Sorting + +Sort by: Repo, Title, Author, Comments, Created, Updated (default: Updated, descending). + +### Ignored Items + +Each issue row has an ignore button (visible on hover). Ignored items are hidden from the list. To restore ignored items, click the **Ignored** badge in the toolbar, which lists all currently ignored items with an option to unignore each one. Ignored items are automatically pruned after 30 days. + +--- + +## Pull Requests Tab + +### Status Indicators + +Each PR row displays several indicators: + +#### Check Status Dot + +A small colored dot shows the aggregate CI status for the PR's latest commit: + +| Color | Meaning | +|-------|---------| +| Green (solid) | All checks passed | +| Yellow (pulsing) | Checks in progress | +| Red (solid) | Checks failing | +| Yellow/faded (solid) | Checks blocked by merge conflict | +| Gray (solid) | No checks configured | + +The dot links to the PR's checks page on GitHub. + +#### Review Badges + +| Badge | Meaning | +|-------|---------| +| Approved | At least one approving review, no blocking reviews | +| Changes requested | At least one reviewer requested changes | +| Needs review | Review has been requested but not yet submitted | +| Dismissed | Previously submitted review was dismissed | + +#### Size Badges + +PR size is computed from total lines changed (additions + deletions): + +| Badge | Total lines changed | +|-------|---------------------| +| XS | Less than 10 | +| S | 10 to 99 | +| M | 100 to 499 | +| L | 500 to 999 | +| XL | 1,000 or more | + +The size badge links to the PR's file diff on GitHub. + +#### Role Badges + +Shows your involvement in the PR: **Author**, **Reviewer**, **Assignee**, or **Involved** (for items in upstream repos where you have no direct role). + +### Pull Requests Filters + +| Filter | Options | Default | +|--------|---------|---------| +| Scope | Involves me / All activity | Involves me | +| Role | All / Author / Reviewer / Assignee | All | +| Review | All / Approved / Changes / Needs review / Mergeable | All | +| Status | All / Draft / Ready | All | +| Checks | All / Passing / Failing / Pending / Conflict / Blocked / No CI | All | +| Size | All / XS / S / M / L / XL | All | +| User | All / (tracked user logins) | All (shown when tracked users are configured) | + +**Mergeable** in the Review filter matches PRs that are approved or have no review requirement (equivalent to "safe to merge from review standpoint"). + +**Blocked** in the Checks filter matches PRs with failing checks or a merge conflict. + +### Pull Requests Sorting + +Sort by: Repo, Title, Author, Checks, Review, Size, Created, Updated (default: Updated, descending). + +--- + +## Actions Tab + +### Workflow Grouping + +Workflow runs are grouped first by repository, then by workflow name. Each workflow group shows its most recent runs up to the configured limit (default: 3 runs per workflow, up to 5 workflows per repo). + +### Show PR Runs + +By default, runs triggered by pull request events are hidden to reduce noise. Toggle **Show PR runs** to include them. This preference is saved across sessions. + +### Actions Filters + +| Filter | Options | Default | +|--------|---------|---------| +| Conclusion | All / Success / Failure / Cancelled / Running / Other | All | +| Event | All / Push / Pull request / Schedule / Workflow dispatch / Other | All | + +--- + +## Multi-User Tracking + +You can track another GitHub user's issues and PRs alongside your own. Go to **Settings > Tracked Users**, enter a GitHub username, and click **Add**. The app validates the username against the GitHub API before saving. + +- Tracked user items appear in the same Issues and Pull Requests tabs, mixed with your own items. +- Each item shows small avatar badges indicating which tracked users it was surfaced by. +- When multiple users are tracked, a **User** filter chip appears on the Issues and Pull Requests tabs, letting you view activity for one user at a time. +- Both regular GitHub users and bot accounts (e.g., `renovate[bot]`) can be tracked. Bot accounts are labeled with a "Bot" badge in the Tracked Users section. + +**API usage note:** Each tracked user adds one additional GraphQL search query per poll cycle. At 3 or more tracked users, a warning appears in Settings. The hard cap is 10 tracked users. + +--- + +## Monitor-All Mode + +Normally, the dashboard shows only issues and PRs that involve you (or a tracked user). Monitor-all mode shows every open issue and PR in a specific repo — regardless of whether anyone you track is involved. + +**How to enable:** In **Settings > Repositories**, expand the repo panel. Each repo has an eye icon toggle. Enabling it adds the repo to the monitored list (maximum 10 monitored repos). + +**Effect on display:** Repo groups for monitored repos show a **Monitoring all** badge in their header. Items from monitored repos are always visible even when the Scope filter is set to "Involves me", and they bypass the User filter. + +Upstream repos cannot be monitored (only selected repos are eligible). + +--- + +## Upstream Repos + +Upstream repos are repositories that you contribute to but do not own — for example, open-source projects you have pull requests in. + +**Auto-discovery:** The app discovers upstream repos automatically by searching for issues and PRs involving you across GitHub (not limited to your selected repos). Discovered repos appear in **Settings > Repositories** under "Upstream repos" and are added to issue and PR fetches. + +**Manual management:** You can add or remove upstream repos manually in the Settings repo panel. + +Upstream repos are included in Issues and Pull Requests fetches but excluded from the Actions tab, since workflow run access requires explicit repo permissions. + +--- + +## Refresh and Polling + +### Full Refresh + +The dashboard polls GitHub at a configurable interval (default: **5 minutes**). Each full refresh fetches all issues, PRs, and workflow runs. You can trigger a manual refresh by clicking the refresh button in the header. + +Setting the interval to **Off** disables automatic polling; manual refresh still works. + +A ±30 second jitter is applied to the refresh interval to avoid synchronized API spikes from multiple browser tabs. + +### Hot Poll + +A second, faster poll loop runs alongside the full refresh specifically for in-flight items. It targets: + +- **PRs with pending CI checks** — re-checks status until checks resolve +- **In-progress workflow runs** — re-checks until the run completes + +Default interval: **30 seconds** (configurable from 10 to 120 seconds in Settings). + +While the hot poll is active, a subtle shimmer animation appears on affected PR rows. When a status changes, the row flashes briefly to draw attention. + +The hot poll pauses automatically when the browser tab is hidden (since visual feedback has no value in a background tab). + +### Tab Visibility Behavior + +When the tab is hidden: + +- The **hot poll always pauses** (it provides only visual feedback). +- The **full poll continues in background** when the notifications gate is available (OAuth or classic PAT with `notifications` scope). The gate uses `If-Modified-Since` headers for near-zero-cost 304 checks that do not count against your rate limit. +- When the notifications gate is **unavailable** (fine-grained PAT or classic PAT missing the `notifications` scope), the full poll also pauses in background tabs to conserve API budget. + +When you return to a tab that has been hidden for more than 2 minutes, a catch-up fetch fires immediately regardless of where the timer is in its cycle. + +--- + +## Notifications + +### Notification Drawer + +The bell icon in the header opens the notification drawer, which shows API errors, rate limit warnings, and other system messages. Notifications are dismissed automatically when the underlying condition clears on the next poll cycle. + +### Browser Push Notifications + +Browser push notifications are disabled by default. To enable them: + +1. Go to **Settings > Notifications**. +2. Click **Grant permission** and allow notifications in the browser prompt. +3. Toggle **Enable notifications** on. + +Per-type toggles (all default to on when notifications are enabled): + +| Toggle | What triggers a notification | +|--------|------------------------------| +| Issues | New issues are opened in your tracked repos | +| Pull Requests | New PRs are opened or updated | +| Workflow Runs | Workflow runs complete | + +--- + +## Repo Pinning + +Each repo group header has a pin (lock) control, visible on hover on desktop and always visible on mobile. Pinning a repo keeps it at the top of the list within its tab regardless of sort order or how recently it was updated. + +- Click the pin icon to pin a repo to the top. +- Click it again to unpin. +- Use the up/down arrows (visible when pinned) to reorder pinned repos relative to each other. + +Pin state is per-tab — a repo can be pinned on the Issues tab but not the Pull Requests tab. + +--- + +## Settings Reference + +Settings are saved automatically to `localStorage` and persist across sessions. All settings can be exported as a JSON file via **Settings > Data > Export**. + +### Config Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| Refresh interval | 5 minutes | How often to poll GitHub for new data. Options: 1, 2, 5, 10, 15, 30 minutes, or Off. | +| CI status refresh (hot poll interval) | 30 seconds | How often to re-check in-flight CI checks and workflow runs. Range: 10–120 seconds. | +| Max workflows per repo | 5 | Number of active workflows to track per repository. Range: 1–20. | +| Max runs per workflow | 3 | Number of recent runs to show per workflow. Range: 1–10. | +| Notifications enabled | Off | Master toggle for browser push notifications. | +| Notify: Issues | On | Notify when new issues open (requires notifications enabled). | +| Notify: Pull Requests | On | Notify when PRs are opened or updated (requires notifications enabled). | +| Notify: Workflow Runs | On | Notify when workflow runs complete (requires notifications enabled). | +| Theme | Auto | UI color theme. Auto follows system dark/light preference (Corporate for light, Dim for dark). | +| View density | Comfortable | Spacing between list items. Options: Comfortable, Compact. | +| Items per page | 25 | Number of items per page in each tab. Options: 10, 25, 50, 100. | +| Default tab | Issues | Tab shown when opening the dashboard fresh (without remembered last tab). | +| Remember last tab | On | Return to the last active tab on revisit. | + +### View State Settings + +These are UI preferences that persist across sessions but are not included in the exported config file. + +| Setting | Default | Description | +|---------|---------|-------------| +| Scope filter (Issues) | Involves me | Whether to show only items involving you or all activity. | +| Scope filter (Pull Requests) | Involves me | Whether to show only items involving you or all activity. | +| Show PR runs (Actions) | Off | Whether to show workflow runs triggered by pull request events. | +| Hide Dependency Dashboard | On | Whether to hide the Renovate Dependency Dashboard issue. | +| Sort preferences | Updated (desc) | Sort field and direction per tab, remembered across sessions. | +| Pinned repos | (none) | Repos pinned to the top of each tab's list. | + +--- + +## Troubleshooting + +**Items I expect to see are not showing up.** + +- Check that the Scope filter is set correctly. "Involves me" hides items where you have no direct involvement. Switch to "All activity" to see everything. +- Verify the repo is in your selected repo list (Settings > Repositories). +- Check if the item was accidentally ignored (toolbar Ignored badge). +- If you recently added the repo, wait for the next full refresh or click the manual refresh button. + +**I see a rate limit warning.** + +The tracker uses GitHub's GraphQL and REST APIs. Each poll cycle consumes some of your 5,000 request hourly budget. Tracking many repos, tracked users, or having a short refresh interval increases consumption. Increasing the refresh interval or reducing the number of tracked repos will reduce API usage. + +OAuth tokens and classic PATs use the notifications gate (304 shortcut), which significantly reduces per-cycle cost when nothing has changed. Fine-grained PATs do not support this optimization. + +**PAT vs OAuth: what is the difference?** + +OAuth tokens (from "Sign in with GitHub") work across all your organizations and support all features including the notifications background-poll optimization. Classic PATs with the correct scopes (`repo`, `read:org`, `notifications`) behave identically to OAuth. + +Fine-grained PATs are limited to one organization at a time, do not support the `notifications` scope, and therefore cannot use the background-poll optimization — the full poll pauses in hidden tabs, and a warning appears in the notification drawer. + +**Data looks stale after switching back to the tab.** + +When a tab has been hidden for more than 2 minutes, a catch-up fetch fires automatically on return. If the notifications gate is unavailable (fine-grained PAT), polling was paused while the tab was hidden — the catch-up fetch provides a single refresh on return. To ensure continuous background updates, use OAuth or a classic PAT with the `notifications` scope. + +**I want to stop tracking a repository.** + +Go to **Settings > Repositories > Manage Repositories**, find the repo, and deselect it. If it was in the monitored list, it will be removed from monitoring automatically. + +**How do I sign out or reset everything?** + +- **Sign out**: Settings > Data > Sign out. This clears your auth token and returns you to the login page. Your config is preserved. +- **Reset all**: Settings > Data > Reset all. This clears all settings, cache, auth tokens, and reloads the page. All configuration is lost. diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 6d5b9196..bb3db1fe 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -25,6 +25,7 @@ import { pushNotification } from "../../lib/errors"; import { getClient, getGraphqlRateLimit } from "../../services/github"; import { formatCount } from "../../lib/format"; import { setsEqual } from "../../lib/collections"; +import { Tooltip } from "../shared/Tooltip"; // ── Shared dashboard store (module-level to survive navigation) ───────────── @@ -450,6 +451,15 @@ export default function DashboardPage() { Source + + Guide + + {(rl) => ( -
+ API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr -
+ )}
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 4154d7d7..8b4f7215 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -18,6 +18,7 @@ import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUse import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import { Tooltip } from "../shared/Tooltip"; export interface IssuesTabProps { issues: Issue[]; @@ -271,17 +272,18 @@ export default function IssuesTab(props: IssuesTabProps) { setPage(0); }} /> - + + +
{repoGroup.repoFullName} - Monitoring all + + Monitoring all + 0}> diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 77576476..acacd8f1 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -2,6 +2,7 @@ import { createMemo, For, Show } from "solid-js"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import type { TabId } from "../layout/TabBar"; import { viewState, updateViewState, resetAllTabFilters, setTabFilter } from "../../stores/view"; +import { InfoTooltip } from "../shared/Tooltip"; interface SummaryCount { label: string; @@ -176,6 +177,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { )} +
); diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 51d3ca83..1470a1c4 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -23,6 +23,7 @@ import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import { Tooltip } from "../shared/Tooltip"; export interface PullRequestsTabProps { pullRequests: PullRequest[]; @@ -470,7 +471,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { {repoGroup.repoFullName} - Monitoring all + + Monitoring all + 0}> diff --git a/src/app/components/layout/FilterBar.tsx b/src/app/components/layout/FilterBar.tsx index 5b596608..a0e630b4 100644 --- a/src/app/components/layout/FilterBar.tsx +++ b/src/app/components/layout/FilterBar.tsx @@ -2,6 +2,7 @@ import { createMemo, createSignal, createEffect, onCleanup, Show } from "solid-j import { Select } from "@kobalte/core/select"; import { config } from "../../stores/config"; import { viewState, setGlobalFilter } from "../../stores/view"; +import { Tooltip } from "../shared/Tooltip"; interface FilterBarProps { isRefreshing?: boolean; @@ -118,34 +119,36 @@ export default function FilterBar(props: FilterBarProps) { - + + Refresh + + ); } diff --git a/src/app/components/layout/Header.tsx b/src/app/components/layout/Header.tsx index 734b032a..87ce5888 100644 --- a/src/app/components/layout/Header.tsx +++ b/src/app/components/layout/Header.tsx @@ -4,6 +4,7 @@ import { user, clearAuth } from "../../stores/auth"; import { getUnreadCount, markAllAsRead } from "../../lib/errors"; import NotificationDrawer from "../shared/NotificationDrawer"; import ToastContainer from "../shared/ToastContainer"; +import { Tooltip } from "../shared/Tooltip"; export default function Header() { const navigate = useNavigate(); @@ -55,71 +56,77 @@ export default function Header() { )} -
- - + + + {/* Bell icon with unread badge */} - + + 0}> + + {unreadCount() > 9 ? "9+" : unreadCount()} + + + + - + + + setDrawerOpen(false)} /> diff --git a/src/app/components/onboarding/RepoSelector.tsx b/src/app/components/onboarding/RepoSelector.tsx index 451439ae..87f8c437 100644 --- a/src/app/components/onboarding/RepoSelector.tsx +++ b/src/app/components/onboarding/RepoSelector.tsx @@ -14,6 +14,7 @@ import type { TrackedUser } from "../../stores/config"; import { relativeTime } from "../../lib/format"; import LoadingSpinner from "../shared/LoadingSpinner"; import FilterInput from "../shared/FilterInput"; +import { Tooltip, InfoTooltip } from "../shared/Tooltip"; // Validates owner/repo format (both segments must be non-empty, no spaces) const VALID_REPO_NAME = /^[a-zA-Z0-9._-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/; @@ -556,27 +557,28 @@ export default function RepoSelector(props: RepoSelectorProps) { - + + + @@ -597,7 +599,10 @@ export default function RepoSelector(props: RepoSelectorProps) {
{/* Section heading */}
-

Upstream Repositories

+

+ Upstream Repositories + +

Repos you contribute to but don't own. Issues and PRs are tracked; workflow runs are not.

diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index f3db7e9c..4a73effb 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -14,6 +14,7 @@ import Section from "./Section"; import SettingRow from "./SettingRow"; import ThemePicker from "./ThemePicker"; import TrackedUsersSection from "./TrackedUsersSection"; +import { InfoTooltip } from "../shared/Tooltip"; import type { RepoRef } from "../../services/api"; export default function SettingsPage() { @@ -377,24 +378,30 @@ export default function SettingsPage() { ))} - - { - const val = parseInt(e.currentTarget.value, 10); - if (!isNaN(val) && val >= 10 && val <= 120) { - saveWithFeedback({ hotPollInterval: val }); - } - }} - class="input input-sm w-20" - /> - +
+
+
+ CI status refresh + +
+
How often to re-check in-flight CI checks and workflow runs (10-120s)
+
+
+ { + const val = parseInt(e.currentTarget.value, 10); + if (!isNaN(val) && val >= 10 && val <= 120) { + saveWithFeedback({ hotPollInterval: val }); + } + }} + class="input input-sm w-20" + /> +
+
{/* Section 4: GitHub Actions */} @@ -716,6 +723,35 @@ export default function SettingsPage() { + +
); diff --git a/src/app/components/settings/TrackedUsersSection.tsx b/src/app/components/settings/TrackedUsersSection.tsx index b785c89f..fc6ee7ff 100644 --- a/src/app/components/settings/TrackedUsersSection.tsx +++ b/src/app/components/settings/TrackedUsersSection.tsx @@ -3,6 +3,7 @@ import type { TrackedUser } from "../../stores/config"; import { user } from "../../stores/auth"; import { validateGitHubUser } from "../../services/api"; import { getClient } from "../../services/github"; +import { Tooltip } from "../shared/Tooltip"; interface TrackedUsersSectionProps { users: TrackedUser[]; @@ -129,25 +130,27 @@ export default function TrackedUsersSection(props: TrackedUsersSectionProps) { - + + + )} diff --git a/src/app/components/shared/ExpandCollapseButtons.tsx b/src/app/components/shared/ExpandCollapseButtons.tsx index 3706fd23..cabc614b 100644 --- a/src/app/components/shared/ExpandCollapseButtons.tsx +++ b/src/app/components/shared/ExpandCollapseButtons.tsx @@ -1,3 +1,5 @@ +import { Tooltip } from "./Tooltip"; + export interface ExpandCollapseButtonsProps { onExpandAll: () => void; onCollapseAll: () => void; @@ -6,48 +8,50 @@ export interface ExpandCollapseButtonsProps { export default function ExpandCollapseButtons(props: ExpandCollapseButtonsProps) { return (
- - + + + + + + + +
); } diff --git a/src/app/components/shared/RepoLockControls.tsx b/src/app/components/shared/RepoLockControls.tsx index ef8c2aec..2e62ca16 100644 --- a/src/app/components/shared/RepoLockControls.tsx +++ b/src/app/components/shared/RepoLockControls.tsx @@ -1,5 +1,6 @@ import { Show, createMemo } from "solid-js"; import { viewState, lockRepo, unlockRepo, moveLockedRepo, type LockedReposTab } from "../../stores/view"; +import { Tooltip } from "./Tooltip"; interface RepoLockControlsProps { tab: LockedReposTab; @@ -22,52 +23,58 @@ export default function RepoLockControls(props: RepoLockControlsProps) { + + + } + > + - } - > - - - + + + + + + + ); diff --git a/src/app/components/shared/SizeBadge.tsx b/src/app/components/shared/SizeBadge.tsx index 9948faf7..2911f115 100644 --- a/src/app/components/shared/SizeBadge.tsx +++ b/src/app/components/shared/SizeBadge.tsx @@ -1,5 +1,6 @@ import { Show } from "solid-js"; import { prSizeCategory } from "../../lib/format"; +import { Tooltip } from "./Tooltip"; interface SizeBadgeProps { additions: number; @@ -17,15 +18,25 @@ const SIZE_CONFIG = { XL: "badge badge-error badge-sm", } as const; +const SIZE_TOOLTIP: Record = { + XS: "XS: under 10 lines changed", + S: "S: under 100 lines changed", + M: "M: under 500 lines changed", + L: "L: under 1000 lines changed", + XL: "XL: 1000+ lines changed", +}; + export default function SizeBadge(props: SizeBadgeProps) { const size = () => props.category ?? prSizeCategory(props.additions, props.deletions); return ( 0 || props.changedFiles > 0}> - - {size()} - + + + {size()} + + +{props.additions} diff --git a/src/app/components/shared/StatusDot.tsx b/src/app/components/shared/StatusDot.tsx index 733c90f5..696c2b66 100644 --- a/src/app/components/shared/StatusDot.tsx +++ b/src/app/components/shared/StatusDot.tsx @@ -1,4 +1,5 @@ import { Show } from "solid-js"; +import { Tooltip } from "./Tooltip"; export interface StatusDotProps { status: "success" | "pending" | "failure" | "error" | "conflict" | null; @@ -42,7 +43,6 @@ export default function StatusDot(props: StatusDotProps) { const dot = () => ( @@ -57,17 +57,19 @@ export default function StatusDot(props: StatusDotProps) { ); return ( - - {(url) => ( - e.stopPropagation()} - > - {dot()} - - )} - + + + {(url) => ( + e.stopPropagation()} + > + {dot()} + + )} + + ); } diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx new file mode 100644 index 00000000..cff606d1 --- /dev/null +++ b/src/app/components/shared/Tooltip.tsx @@ -0,0 +1,81 @@ +import { createMemo, createSignal } from "solid-js"; +import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"; +import type { JSX } from "solid-js"; + +// SECURITY: tooltip content must be JSX children, never raw HTML + +interface TooltipProps { + content: string; + placement?: "top" | "bottom" | "left" | "right"; + focusable?: boolean; + children: JSX.Element; +} + +export function Tooltip(props: TooltipProps) { + const [isHovered, setIsHovered] = createSignal(false); + const [isFocused, setIsFocused] = createSignal(false); + const open = createMemo(() => isHovered() || isFocused()); + + return ( + { + if (!isOpen) { + setIsHovered(false); + setIsFocused(false); + } + }} + placement={props.placement ?? "top"} + gutter={4} + openDelay={300} + > + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + onFocusIn={() => setIsFocused(true)} + onFocusOut={() => setIsFocused(false)} + > + {props.children} + + + + + {props.content} + + + + ); +} + +interface InfoTooltipProps { + content: string; + placement?: "top" | "bottom" | "left" | "right"; +} + +export function InfoTooltip(props: InfoTooltipProps) { + return ( + + + i + + + + + {props.content} + + + + ); +} diff --git a/src/app/components/shared/UserAvatarBadge.tsx b/src/app/components/shared/UserAvatarBadge.tsx index 280619e1..8cff8219 100644 --- a/src/app/components/shared/UserAvatarBadge.tsx +++ b/src/app/components/shared/UserAvatarBadge.tsx @@ -1,4 +1,5 @@ import { createMemo, For, Show } from "solid-js"; +import { Tooltip } from "./Tooltip"; export function buildSurfacedByUsers( surfacedBy: string[] | undefined, @@ -34,11 +35,12 @@ export default function UserAvatarBadge(props: UserAvatarBadgeProps) { class={`avatar${i() > 0 ? " -ml-1.5" : ""}`} >
- {u.login} + + {u.login} +
)} diff --git a/tests/components/shared/StatusDot.test.tsx b/tests/components/shared/StatusDot.test.tsx index 432c17ed..aaa03018 100644 --- a/tests/components/shared/StatusDot.test.tsx +++ b/tests/components/shared/StatusDot.test.tsx @@ -5,33 +5,31 @@ import StatusDot from "../../../src/app/components/shared/StatusDot"; describe("StatusDot", () => { it('shows "All checks passed" label for status="success"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("All checks passed"); - expect(wrapper?.getAttribute("title")).toBe("All checks passed"); }); it('shows "Checks in progress" label for status="pending"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("Checks in progress"); - expect(wrapper?.getAttribute("title")).toBe("Checks in progress"); }); it('shows "Checks failing" label for status="failure"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("Checks failing"); }); it('shows "Checks failing" label for status="error"', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("Checks failing"); }); it('shows "No checks" label for status=null', () => { const { container } = render(() => ); - const wrapper = container.querySelector("span"); + const wrapper = container.querySelector("[aria-label]"); expect(wrapper?.getAttribute("aria-label")).toBe("No checks"); }); diff --git a/tests/components/shared/Tooltip.test.tsx b/tests/components/shared/Tooltip.test.tsx new file mode 100644 index 00000000..673b7ec5 --- /dev/null +++ b/tests/components/shared/Tooltip.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import { Tooltip, InfoTooltip } from "../../../src/app/components/shared/Tooltip"; + +describe("Tooltip", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders children correctly", () => { + render(() => ( + + + + )); + expect(screen.getByRole("button", { name: "Click me" })).toBeTruthy(); + }); + + it("trigger has inline-flex class", () => { + const { container } = render(() => ( + + Child + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger).not.toBeNull(); + }); + + it("focusable prop adds tabindex='0' to trigger span", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger?.getAttribute("tabindex")).toBe("0"); + }); + + it("without focusable, trigger span has no tabindex", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex"); + expect(trigger?.hasAttribute("tabindex")).toBe(false); + }); +}); + +describe("InfoTooltip", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders (i) button with aria-label 'More information'", () => { + render(() => ); + const btn = screen.getByRole("button", { name: "More information" }); + expect(btn).toBeTruthy(); + expect(btn.textContent?.trim()).toBe("i"); + }); + + it("button has cursor-help class", () => { + render(() => ); + const btn = screen.getByRole("button", { name: "More information" }); + expect(btn.classList.contains("cursor-help")).toBe(true); + }); +}); diff --git a/tests/components/shared/UserAvatarBadge.test.tsx b/tests/components/shared/UserAvatarBadge.test.tsx index 4e7239fb..f105cc3d 100644 --- a/tests/components/shared/UserAvatarBadge.test.tsx +++ b/tests/components/shared/UserAvatarBadge.test.tsx @@ -59,17 +59,6 @@ describe("UserAvatarBadge", () => { expect(imgs[0].getAttribute("alt")).toBe("tracked1"); }); - it("shows tooltip with login via title attribute", () => { - render(() => ( - - )); - const img = screen.getByRole("img"); - expect(img.getAttribute("title")).toBe("octocat"); - }); - it("uses avatarUrl as img src", () => { const avatarUrl = "https://avatars.githubusercontent.com/u/583231"; render(() => ( From f5448654f40cbc506fb08eb4d223508c310c4102 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 5 Apr 2026 21:15:21 -0400 Subject: [PATCH 02/13] fix(ui): addresses review findings for tooltip implementation --- README.md | 6 +-- .../settings/TrackedUsersSection.tsx | 4 +- src/app/components/shared/SizeBadge.tsx | 10 ++-- src/app/components/shared/Tooltip.tsx | 22 ++++++--- src/app/components/shared/UserAvatarBadge.tsx | 14 +++--- tests/components/shared/Tooltip.test.tsx | 48 ++++++++++++++++++- 6 files changed, 81 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 04472235..a896cdeb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Open issues where you're the creator, assignee, or mentioned. A scope filter let ### Pull Requests -Open PRs with CI status dots (green/yellow/red), review decision badges, size badges (XS–XXL by lines changed), and draft indicators. A "blocked" filter catches PRs where checks are failing or a review requested changes. The scope filter works here too. Reviewer avatars stack for multiple reviewers. +Open PRs with CI status dots (green/yellow/red), review decision badges, size badges (XS–XL by lines changed), and draft indicators. A "blocked" filter catches PRs where checks are failing or a review requested changes. The scope filter works here too. Reviewer avatars stack for multiple reviewers. ### Actions @@ -100,11 +100,11 @@ src/ layout/ # Header, TabBar, FilterBar onboarding/ # OnboardingWizard, OrgSelector, RepoSelector settings/ # SettingsPage, TrackedUsersSection, ThemePicker, Section, SettingRow - shared/ # 18 shared components: FilterInput, FilterChips, StatusDot, + shared/ # 19 shared components: FilterInput, FilterChips, StatusDot, # ReviewBadge, SizeBadge, RoleBadge, SortDropdown, PaginationControls, # LoadingSpinner, SkeletonRows, ToastContainer, NotificationDrawer, # RepoLockControls, UserAvatarBadge, ExpandCollapseButtons, - # RepoGitHubLink, ChevronIcon, ExternalLinkIcon + # RepoGitHubLink, ChevronIcon, ExternalLinkIcon, Tooltip/InfoTooltip lib/ # 14 modules: format, errors, notifications, oauth, pat, url, # flashDetection, grouping, reorderHighlight, collections, # emoji, label-colors, sentry, github-emoji-map.json diff --git a/src/app/components/settings/TrackedUsersSection.tsx b/src/app/components/settings/TrackedUsersSection.tsx index fc6ee7ff..ab4b9172 100644 --- a/src/app/components/settings/TrackedUsersSection.tsx +++ b/src/app/components/settings/TrackedUsersSection.tsx @@ -126,7 +126,9 @@ export default function TrackedUsersSection(props: TrackedUsersSectionProps) {
- bot + + bot + diff --git a/src/app/components/shared/SizeBadge.tsx b/src/app/components/shared/SizeBadge.tsx index 2911f115..fe196f55 100644 --- a/src/app/components/shared/SizeBadge.tsx +++ b/src/app/components/shared/SizeBadge.tsx @@ -18,11 +18,11 @@ const SIZE_CONFIG = { XL: "badge badge-error badge-sm", } as const; -const SIZE_TOOLTIP: Record = { - XS: "XS: under 10 lines changed", - S: "S: under 100 lines changed", - M: "M: under 500 lines changed", - L: "L: under 1000 lines changed", +const SIZE_TOOLTIP: Record<"XS" | "S" | "M" | "L" | "XL", string> = { + XS: "XS: <10 lines changed", + S: "S: 10–99 lines changed", + M: "M: 100–499 lines changed", + L: "L: 500–999 lines changed", XL: "XL: 1000+ lines changed", }; diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index cff606d1..a30eb3a9 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -1,9 +1,11 @@ -import { createMemo, createSignal } from "solid-js"; +import { createMemo, createSignal, onCleanup } from "solid-js"; import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"; import type { JSX } from "solid-js"; // SECURITY: tooltip content must be JSX children, never raw HTML +const TOOLTIP_CONTENT_CLASS = "z-50 max-w-xs rounded bg-neutral px-2 py-1 text-xs text-neutral-content shadow-lg"; + interface TooltipProps { content: string; placement?: "top" | "bottom" | "left" | "right"; @@ -16,6 +18,10 @@ export function Tooltip(props: TooltipProps) { const [isFocused, setIsFocused] = createSignal(false); const open = createMemo(() => isHovered() || isFocused()); + // openDelay is ignored in controlled mode; implement the delay manually + let hoverTimer: ReturnType | undefined; + onCleanup(() => clearTimeout(hoverTimer)); + return ( setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + onPointerEnter={() => { + hoverTimer = setTimeout(() => setIsHovered(true), 300); + }} + onPointerLeave={() => { + clearTimeout(hoverTimer); + setIsHovered(false); + }} onFocusIn={() => setIsFocused(true)} onFocusOut={() => setIsFocused(false)} > {props.children} - + {props.content} @@ -71,7 +81,7 @@ export function InfoTooltip(props: InfoTooltipProps) { i - + {props.content} diff --git a/src/app/components/shared/UserAvatarBadge.tsx b/src/app/components/shared/UserAvatarBadge.tsx index 8cff8219..1650ee23 100644 --- a/src/app/components/shared/UserAvatarBadge.tsx +++ b/src/app/components/shared/UserAvatarBadge.tsx @@ -31,18 +31,18 @@ export default function UserAvatarBadge(props: UserAvatarBadgeProps) { > {(u, i) => ( -
0 ? " -ml-1.5" : ""}`} - > -
- + +
0 ? " -ml-1.5" : ""}`} + > +
{u.login} - +
-
+ )}
diff --git a/tests/components/shared/Tooltip.test.tsx b/tests/components/shared/Tooltip.test.tsx index 673b7ec5..58523d97 100644 --- a/tests/components/shared/Tooltip.test.tsx +++ b/tests/components/shared/Tooltip.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { render, screen } from "@solidjs/testing-library"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; import { Tooltip, InfoTooltip } from "../../../src/app/components/shared/Tooltip"; describe("Tooltip", () => { @@ -49,6 +49,43 @@ describe("Tooltip", () => { const trigger = container.querySelector("span.inline-flex"); expect(trigger?.hasAttribute("tabindex")).toBe(false); }); + + it("tooltip content is not visible before hover", () => { + render(() => ( + + Trigger + + )); + expect(document.body.textContent).not.toContain("tooltip text"); + }); + + it("shows tooltip content after 300ms hover delay", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + // Content should not be visible before delay fires + expect(document.body.textContent).not.toContain("tooltip text"); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("tooltip text"); + }); + + it("cancels tooltip if pointer leaves before 300ms delay", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(150); + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(300); + expect(document.body.textContent).not.toContain("tooltip text"); + }); }); describe("InfoTooltip", () => { @@ -72,4 +109,13 @@ describe("InfoTooltip", () => { const btn = screen.getByRole("button", { name: "More information" }); expect(btn.classList.contains("cursor-help")).toBe(true); }); + + it("shows tooltip content after hover (openDelay=300ms)", () => { + render(() => ); + const btn = screen.getByRole("button", { name: "More information" }); + fireEvent.pointerEnter(btn); + expect(document.body.textContent).not.toContain("helpful info text"); + vi.advanceTimersByTime(300); + expect(document.body.textContent).toContain("helpful info text"); + }); }); From ed3e7ce73bca83e6ac2f508bb28cdf86ee0d6fcc Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 5 Apr 2026 21:18:58 -0400 Subject: [PATCH 03/13] fix(ui): adds items-center to tooltip trigger for vertical alignment --- src/app/components/shared/Tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index a30eb3a9..3ef44dd3 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -36,7 +36,7 @@ export function Tooltip(props: TooltipProps) { > { hoverTimer = setTimeout(() => setIsHovered(true), 300); From 7499f5da4abcdc99b93dc4b02f9afc5cc6b1d5a0 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 5 Apr 2026 21:30:55 -0400 Subject: [PATCH 04/13] fix(ui): addresses quality gate findings from Layer 2 review --- README.md | 4 +- .../components/dashboard/DashboardPage.tsx | 2 +- src/app/components/shared/Tooltip.tsx | 1 + tests/components/shared/Tooltip.test.tsx | 38 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a896cdeb..aaf875b5 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ src/ view.ts # View state (tabs, sorting, filters, ignored items, locked repos) worker/ index.ts # OAuth token exchange endpoint, CORS, security headers -tests/ # 1522 unit/component tests across 69 test files -e2e/ # 14 E2E tests across 2 spec files +tests/ # 1534 unit/component tests across 70 test files +e2e/ # 15 E2E tests across 3 spec files ``` ## Development diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index bb3db1fe..5d0a2c01 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -470,7 +470,7 @@ export default function DashboardPage() {
{(rl) => ( - + API RL: {rl().remaining.toLocaleString()}/{formatCount(rl().limit)}/hr diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index 3ef44dd3..2a033750 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -39,6 +39,7 @@ export function Tooltip(props: TooltipProps) { class="inline-flex items-center" tabindex={props.focusable ? "0" : undefined} onPointerEnter={() => { + clearTimeout(hoverTimer); hoverTimer = setTimeout(() => setIsHovered(true), 300); }} onPointerLeave={() => { diff --git a/tests/components/shared/Tooltip.test.tsx b/tests/components/shared/Tooltip.test.tsx index 58523d97..2237cb85 100644 --- a/tests/components/shared/Tooltip.test.tsx +++ b/tests/components/shared/Tooltip.test.tsx @@ -86,6 +86,44 @@ describe("Tooltip", () => { vi.advanceTimersByTime(300); expect(document.body.textContent).not.toContain("tooltip text"); }); + + it("closes tooltip state when pointer leaves after it is visible", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + expect(trigger.getAttribute("data-expanded")).toBe(""); + fireEvent.pointerLeave(trigger); + expect(trigger.hasAttribute("data-expanded")).toBe(false); + }); + + it("shows tooltip on focusIn (keyboard access)", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.focusIn(trigger); + expect(document.body.textContent).toContain("focus tooltip"); + }); + + it("closes tooltip state on focusOut", () => { + const { container } = render(() => ( + + Badge + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.focusIn(trigger); + expect(trigger.getAttribute("data-expanded")).toBe(""); + fireEvent.focusOut(trigger); + expect(trigger.hasAttribute("data-expanded")).toBe(false); + }); }); describe("InfoTooltip", () => { From 2eb42592a2dd828dc1ef728f660312546b09d97b Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 5 Apr 2026 22:12:32 -0400 Subject: [PATCH 05/13] fix(ui): addresses remaining tooltip review findings --- docs/USER_GUIDE.md | 2 +- src/app/components/dashboard/IssuesTab.tsx | 2 +- src/app/components/dashboard/ItemRow.tsx | 4 +- .../components/dashboard/WorkflowRunRow.tsx | 42 +++++++------- src/app/components/layout/FilterBar.tsx | 57 +++++++++---------- .../settings/TrackedUsersSection.tsx | 2 +- .../components/shared/RepoLockControls.tsx | 4 +- src/app/components/shared/SizeBadge.tsx | 2 +- src/app/components/shared/Tooltip.tsx | 9 ++- tests/components/shared/Tooltip.test.tsx | 3 + 10 files changed, 68 insertions(+), 59 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 91fc17c5..1dd801af 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -129,7 +129,7 @@ Filters can be combined. Click **Reset all** to clear all active filters at once ### Dependency Dashboard Toggle -The **Show Dep Dashboard** button controls whether the Renovate "Dependency Dashboard" issue appears in the list. It is hidden by default. This setting applies across all repos. +The **Show Dep Dashboard** button controls whether the Renovate "Dependency Dashboard" issue appears in the list. The Dependency Dashboard issue is hidden by default. This setting applies across all repos. ### Issues Sorting diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 8b4f7215..77de0030 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -272,7 +272,7 @@ export default function IssuesTab(props: IssuesTabProps) { setPage(0); }} /> - +
{/* Ignore button — visible on hover */} + + ); } diff --git a/src/app/components/dashboard/WorkflowRunRow.tsx b/src/app/components/dashboard/WorkflowRunRow.tsx index 1cf04d3c..9814ce4b 100644 --- a/src/app/components/dashboard/WorkflowRunRow.tsx +++ b/src/app/components/dashboard/WorkflowRunRow.tsx @@ -3,6 +3,7 @@ import type { WorkflowRun } from "../../services/api"; import type { Config } from "../../stores/config"; import { isSafeGitHubUrl } from "../../lib/url"; import { relativeTime, formatDuration } from "../../lib/format"; +import { Tooltip } from "../shared/Tooltip"; interface WorkflowRunRowProps { run: WorkflowRun; @@ -194,27 +195,28 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) {
- + + + ); } diff --git a/src/app/components/layout/FilterBar.tsx b/src/app/components/layout/FilterBar.tsx index a0e630b4..5b596608 100644 --- a/src/app/components/layout/FilterBar.tsx +++ b/src/app/components/layout/FilterBar.tsx @@ -2,7 +2,6 @@ import { createMemo, createSignal, createEffect, onCleanup, Show } from "solid-j import { Select } from "@kobalte/core/select"; import { config } from "../../stores/config"; import { viewState, setGlobalFilter } from "../../stores/view"; -import { Tooltip } from "../shared/Tooltip"; interface FilterBarProps { isRefreshing?: boolean; @@ -119,36 +118,34 @@ export default function FilterBar(props: FilterBarProps) { - - - + + + Refresh + ); } diff --git a/src/app/components/settings/TrackedUsersSection.tsx b/src/app/components/settings/TrackedUsersSection.tsx index ab4b9172..27922b27 100644 --- a/src/app/components/settings/TrackedUsersSection.tsx +++ b/src/app/components/settings/TrackedUsersSection.tsx @@ -132,7 +132,7 @@ export default function TrackedUsersSection(props: TrackedUsersSectionProps) { - + - + - + - ); } diff --git a/src/app/components/dashboard/WorkflowRunRow.tsx b/src/app/components/dashboard/WorkflowRunRow.tsx index 9814ce4b..38ec3e9b 100644 --- a/src/app/components/dashboard/WorkflowRunRow.tsx +++ b/src/app/components/dashboard/WorkflowRunRow.tsx @@ -3,7 +3,6 @@ import type { WorkflowRun } from "../../services/api"; import type { Config } from "../../stores/config"; import { isSafeGitHubUrl } from "../../lib/url"; import { relativeTime, formatDuration } from "../../lib/format"; -import { Tooltip } from "../shared/Tooltip"; interface WorkflowRunRowProps { run: WorkflowRun; @@ -195,10 +194,10 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) { - - - ); } diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index b54d241e..f0818889 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -31,6 +31,8 @@ export function Tooltip(props: TooltipProps) { open={open()} onOpenChange={(isOpen) => { if (!isOpen) { + clearTimeout(hoverTimer); + clearTimeout(closeTimer); setIsHovered(false); setIsFocused(false); } diff --git a/tests/components/shared/Tooltip.test.tsx b/tests/components/shared/Tooltip.test.tsx index 1639778e..5782b850 100644 --- a/tests/components/shared/Tooltip.test.tsx +++ b/tests/components/shared/Tooltip.test.tsx @@ -104,6 +104,27 @@ describe("Tooltip", () => { expect(trigger.hasAttribute("data-expanded")).toBe(false); }); + it("re-entering during closeDelay cancels the close timer", () => { + const { container } = render(() => ( + + Trigger + + )); + const trigger = container.querySelector("span.inline-flex")!; + fireEvent.pointerEnter(trigger); + vi.advanceTimersByTime(300); + expect(trigger.getAttribute("data-expanded")).toBe(""); + fireEvent.pointerLeave(trigger); + // Re-enter within 100ms closeDelay — should cancel close + vi.advanceTimersByTime(50); + fireEvent.pointerEnter(trigger); + // The tooltip should still be expanded (closeTimer was cancelled before it fired) + expect(trigger.getAttribute("data-expanded")).toBe(""); + // Clean up: close the tooltip so Kobalte's global state resets + fireEvent.pointerLeave(trigger); + vi.advanceTimersByTime(500); + }); + it("shows tooltip on focusIn (keyboard access)", () => { const { container } = render(() => ( From 7f7b756ac1c484ef0eed99a93dd49bf134afbe44 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 21:55:29 -0400 Subject: [PATCH 07/13] style: fixes indentation in WorkflowRunRow ignore button --- .../components/dashboard/WorkflowRunRow.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/app/components/dashboard/WorkflowRunRow.tsx b/src/app/components/dashboard/WorkflowRunRow.tsx index 38ec3e9b..1cf04d3c 100644 --- a/src/app/components/dashboard/WorkflowRunRow.tsx +++ b/src/app/components/dashboard/WorkflowRunRow.tsx @@ -195,26 +195,26 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) { + + + + ); } From c786a4917b1b1f9a7113f6b3df0a8f7971e95320 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:14:06 -0400 Subject: [PATCH 08/13] docs: updates README test count to 1535 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aaf875b5..7550e730 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ src/ view.ts # View state (tabs, sorting, filters, ignored items, locked repos) worker/ index.ts # OAuth token exchange endpoint, CORS, security headers -tests/ # 1534 unit/component tests across 70 test files +tests/ # 1535 unit/component tests across 70 test files e2e/ # 15 E2E tests across 3 spec files ``` From 1939daec43ca615ab88d7db817a1ac6cd05b87fd Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 7 Apr 2026 08:24:32 -0400 Subject: [PATCH 09/13] feat(ui): refines tooltip text and placement per feedback --- .../dashboard/PersonalSummaryStrip.tsx | 2 +- src/app/components/layout/FilterBar.tsx | 56 ++++++++++--------- .../shared/ExpandCollapseButtons.tsx | 8 +-- .../components/shared/RepoLockControls.tsx | 4 +- src/app/components/shared/StatusDot.tsx | 2 +- .../components/ExpandCollapseButtons.test.tsx | 8 +-- tests/components/IssuesTab.test.tsx | 8 +-- tests/components/PullRequestsTab.test.tsx | 6 +- 8 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index acacd8f1..04d05f8b 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -177,7 +177,7 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { )} - + ); diff --git a/src/app/components/layout/FilterBar.tsx b/src/app/components/layout/FilterBar.tsx index 5b596608..2abfc8e2 100644 --- a/src/app/components/layout/FilterBar.tsx +++ b/src/app/components/layout/FilterBar.tsx @@ -2,6 +2,7 @@ import { createMemo, createSignal, createEffect, onCleanup, Show } from "solid-j import { Select } from "@kobalte/core/select"; import { config } from "../../stores/config"; import { viewState, setGlobalFilter } from "../../stores/view"; +import { Tooltip } from "../shared/Tooltip"; interface FilterBarProps { isRefreshing?: boolean; @@ -118,34 +119,35 @@ export default function FilterBar(props: FilterBarProps) { - + + + ); } diff --git a/src/app/components/shared/ExpandCollapseButtons.tsx b/src/app/components/shared/ExpandCollapseButtons.tsx index cabc614b..5124f785 100644 --- a/src/app/components/shared/ExpandCollapseButtons.tsx +++ b/src/app/components/shared/ExpandCollapseButtons.tsx @@ -8,10 +8,10 @@ export interface ExpandCollapseButtonsProps { export default function ExpandCollapseButtons(props: ExpandCollapseButtonsProps) { return (
- + - + - + - +
{/* Ignore button — visible on hover */} - + {/* Eye-slash icon */} + + +
); } diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 1470a1c4..d7678ff7 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -608,11 +608,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
0}> - - Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} - {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} - {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} - + + + Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} + {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} + {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} + + diff --git a/src/app/components/dashboard/WorkflowRunRow.tsx b/src/app/components/dashboard/WorkflowRunRow.tsx index 1cf04d3c..28a3539f 100644 --- a/src/app/components/dashboard/WorkflowRunRow.tsx +++ b/src/app/components/dashboard/WorkflowRunRow.tsx @@ -3,6 +3,7 @@ import type { WorkflowRun } from "../../services/api"; import type { Config } from "../../stores/config"; import { isSafeGitHubUrl } from "../../lib/url"; import { relativeTime, formatDuration } from "../../lib/format"; +import { Tooltip } from "../shared/Tooltip"; interface WorkflowRunRowProps { run: WorkflowRun; @@ -182,39 +183,41 @@ export default function WorkflowRunRow(props: WorkflowRunRowProps) { {durationLabel(props.run)} - + + + - + + + ); } diff --git a/src/app/components/dashboard/WorkflowSummaryCard.tsx b/src/app/components/dashboard/WorkflowSummaryCard.tsx index bccbfa42..a8c36ee1 100644 --- a/src/app/components/dashboard/WorkflowSummaryCard.tsx +++ b/src/app/components/dashboard/WorkflowSummaryCard.tsx @@ -1,6 +1,7 @@ import { createMemo, For, Show } from "solid-js"; import type { WorkflowRun } from "../../services/api"; import WorkflowRunRow from "./WorkflowRunRow"; +import { Tooltip } from "../shared/Tooltip"; interface WorkflowSummaryCardProps { workflowName: string; @@ -81,19 +82,25 @@ export default function WorkflowSummaryCard(props: WorkflowSummaryCardProps) {
0}> - - {counts().success} - + + + {counts().success} + + 0}> - - {counts().failure} - + + + {counts().failure} + + 0}> - - {counts().running} - + + + {counts().running} + +
diff --git a/src/app/components/shared/RepoGitHubLink.tsx b/src/app/components/shared/RepoGitHubLink.tsx index 2fbc7ca2..0d5bb000 100644 --- a/src/app/components/shared/RepoGitHubLink.tsx +++ b/src/app/components/shared/RepoGitHubLink.tsx @@ -1,4 +1,5 @@ import ExternalLinkIcon from "./ExternalLinkIcon"; +import { Tooltip } from "./Tooltip"; const sectionLabels = { issues: "issues", @@ -13,15 +14,16 @@ export default function RepoGitHubLink(props: { const label = () => sectionLabels[props.section]; return ( -
- - + + + + + ); } diff --git a/src/app/components/shared/Tooltip.tsx b/src/app/components/shared/Tooltip.tsx index f0818889..cad4d993 100644 --- a/src/app/components/shared/Tooltip.tsx +++ b/src/app/components/shared/Tooltip.tsx @@ -10,6 +10,7 @@ interface TooltipProps { content: string; placement?: "top" | "bottom" | "left" | "right"; focusable?: boolean; + class?: string; children: JSX.Element; } @@ -42,7 +43,7 @@ export function Tooltip(props: TooltipProps) { > { clearTimeout(hoverTimer); diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 4930cd29..23df2daf 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -104,8 +104,10 @@ describe("ItemRow", () => { it("ignore button has relative z-10 to sit above overlay link", () => { render(() => ); const ignoreBtn = screen.getByLabelText(/Ignore #42/i); - expect(ignoreBtn.className).toContain("relative"); - expect(ignoreBtn.className).toContain("z-10"); + // The Tooltip wrapper span carries relative z-10; the button itself is inside it + const tooltipTrigger = ignoreBtn.closest("span.relative"); + expect(tooltipTrigger).not.toBeNull(); + expect(tooltipTrigger!.className).toContain("z-10"); }); it("applies compact padding in compact density", () => { @@ -180,9 +182,7 @@ describe("ItemRow", () => { const created = container.querySelector(`time[datetime="${defaultProps.createdAt}"]`); const updated = container.querySelector(`time[datetime="${defaultProps.updatedAt}"]`); expect(created!.textContent).toBe("2h"); - expect(created!.getAttribute("title")).toBe(`Created: ${new Date(defaultProps.createdAt).toLocaleString()}`); expect(updated!.textContent).toBe("30m"); - expect(updated!.getAttribute("title")).toBe(`Updated: ${new Date(defaultProps.updatedAt).toLocaleString()}`); // Middle dot separator is a with aria-hidden const dot = container.querySelector('span[aria-hidden="true"]'); expect(dot).not.toBeNull(); diff --git a/tests/components/WorkflowRunRow.test.tsx b/tests/components/WorkflowRunRow.test.tsx index fc8efa2a..e290a129 100644 --- a/tests/components/WorkflowRunRow.test.tsx +++ b/tests/components/WorkflowRunRow.test.tsx @@ -36,7 +36,6 @@ describe("WorkflowRunRow", () => { const timeEl = container.querySelector("time"); expect(timeEl).not.toBeNull(); expect(timeEl!.getAttribute("datetime")).toBe(createdAt); - expect(timeEl!.getAttribute("title")).toBe(`Created: ${new Date(createdAt).toLocaleString()}`); expect(timeEl!.textContent).toMatch(/2 hours? ago/); }); From 557410040d7649f69b17b774d7b38eb85e44eecf Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 7 Apr 2026 10:51:21 -0400 Subject: [PATCH 12/13] fix(ui): address PR review findings for tooltips - adds comment count tooltip with plural/singular support - fixes flex layout regressions (shrink-0, self-center on Tooltip wrappers) - removes nested interactive focusable tooltips in WorkflowSummaryCard - fixes IgnoreBadge truncation broken by inline-flex Tooltip wrapper - extends SettingRow with labelSuffix prop, removes hand-rolled layout - fixes FilterBar refresh tooltip awkward phrasing during refresh - corrects USER_GUIDE StatusDot label and README stale test count - rewrites misleading Tooltip security comment, documents openDelay asymmetry - adds 12 new tests covering tooltip content, layout, and accessibility --- README.md | 2 +- docs/USER_GUIDE.md | 2 +- src/app/components/dashboard/IgnoreBadge.tsx | 2 +- src/app/components/dashboard/ItemRow.tsx | 38 ++++----- .../components/dashboard/WorkflowRunRow.tsx | 8 +- .../dashboard/WorkflowSummaryCard.tsx | 6 +- src/app/components/layout/FilterBar.tsx | 2 +- src/app/components/settings/SettingRow.tsx | 6 +- src/app/components/settings/SettingsPage.tsx | 43 +++++----- src/app/components/shared/Tooltip.tsx | 3 +- tests/components/ItemRow.test.tsx | 79 ++++++++++++++++++- tests/components/WorkflowRunRow.test.tsx | 22 +++++- .../dashboard/PersonalSummaryStrip.test.tsx | 6 ++ tests/components/shared-badges.test.tsx | 19 ++++- tests/components/shared/StatusDot.test.tsx | 17 ++++ tests/components/shared/Tooltip.test.tsx | 29 +++++++ .../shared/UserAvatarBadge.test.tsx | 22 +++++- 17 files changed, 245 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 7550e730..7e4be0a4 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ src/ view.ts # View state (tabs, sorting, filters, ignored items, locked repos) worker/ index.ts # OAuth token exchange endpoint, CORS, security headers -tests/ # 1535 unit/component tests across 70 test files +tests/ # unit/component tests across 70 test files e2e/ # 15 E2E tests across 3 spec files ``` diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1dd801af..c8222927 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -157,7 +157,7 @@ A small colored dot shows the aggregate CI status for the PR's latest commit: | Yellow (pulsing) | Checks in progress | | Red (solid) | Checks failing | | Yellow/faded (solid) | Checks blocked by merge conflict | -| Gray (solid) | No checks configured | +| Gray (solid) | No checks | The dot links to the PR's checks page on GitHub. diff --git a/src/app/components/dashboard/IgnoreBadge.tsx b/src/app/components/dashboard/IgnoreBadge.tsx index 316f009e..0b5bc3db 100644 --- a/src/app/components/dashboard/IgnoreBadge.tsx +++ b/src/app/components/dashboard/IgnoreBadge.tsx @@ -85,7 +85,7 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) {

{item.repo}

- +

{item.title}

diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index ae11dd2e..28054b0e 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -79,7 +79,7 @@ export default function ItemRow(props: ItemRowProps) { {/* Repo badge */} - + 0}> - - - {formatCount(props.commentCount!)} - + + + + {formatCount(props.commentCount!)} + + {/* Ignore button — visible on hover */} - +