Skip to content

[HDX-4405] fix(app): prevent stranded tooltip in virtualised table rows#2380

Open
alex-fedotyev wants to merge 6 commits into
mainfrom
alex/HDX-4405-fix-stranded-tooltip-in-virtual-table
Open

[HDX-4405] fix(app): prevent stranded tooltip in virtualised table rows#2380
alex-fedotyev wants to merge 6 commits into
mainfrom
alex/HDX-4405-fix-stranded-tooltip-in-virtual-table

Conversation

@alex-fedotyev
Copy link
Copy Markdown
Contributor

@alex-fedotyev alex-fedotyev commented May 29, 2026

Summary

Pivot of the original HDX-4405 fix per review feedback.

The first round of this PR fixed the stranded-tooltip bug by hoisting Tooltip.Floating to the <tbody>. That dismissed the bug but the resulting surface still felt off: the tooltip followed the cursor too closely. I've replaced it with a hover-revealed trailing chevron icon anchored to the row's last cell, wrapped in an anchored Mantine Tooltip. Mirrors the .rowButtons pattern from Search and Patterns.

This eliminates the stranded-tooltip bug class by construction: the anchored Tooltip's open state is tied to the icon's lifecycle, so a virtual row unmounting mid-hover can no longer leave a popup behind.

What changed (vs the previous commit on this branch)

  • Dropped the <tbody>-level Tooltip.Floating, the hoveredVirtualIndex / hoveredRowDescription state, the <tbody> onMouseLeave safety net, and per-row onMouseEnter / onMouseLeave handlers.
  • Added a per-row trailing IconChevronRight in the last <td> of clickable rows, wrapped in a Next.js <Link href={rowAction.url} prefetch={false} tabIndex={-1} aria-hidden="true">. The Link reuses the row's existing URL so cmd-click / middle-click / right-click "Open in New Tab" / status-bar URL preview all work on the icon. tabIndex={-1} and aria-hidden="true" keep sequential focus order and screen-reader traversal anchored to the per-cell Links so the icon doesn't double-count.
  • New HDXMultiSeriesTableChart.module.scss owns .tableRow, .lastCell, .rowActionHint. The icon is opacity: 0 by default and fades in on .tableRow:hover via a 0.15s transition. .lastCell adds position: relative to the last <td> only (the <tr> does not reliably form a positioning context across browsers).
  • Tooltip configured position="left", withArrow, openDelay={300}, closeDelay={100}, fz="xs" to match the established DBRowTableIconButton convention.
  • Suppressed on failure rows (rowAction.url === null) so the icon never promises a destination the click cannot deliver.

Tests

  • Unit tests in HDXMultiSeriesTableChart.test.tsx rewritten: trailing-chevron rendering on success rows, dashboard-variant tooltip text, no chevron on failure rows, no chevron on the legacy getRowSearchLink path, no chevron when no action is configured. Dropped the inline-style closest('[style*="display"]') walk flagged in the previous deep review.
  • E2E spec Trailing chevron hint appears on row hover... now asserts opacity-gated visibility, tooltip text on row hover, and click-on-chevron navigation. Replaces the prior stranded-tooltip dismiss test.
  • DashboardPage.ts hint helper hovers the row to reveal the icon, then hovers the icon to open the anchored Tooltip. Docstring rewritten.

How to test on Vercel preview

Preview route: /dashboards

  1. Open a dashboard with a Table tile configured with an onClick action (Search or Dashboard).
  2. Hover a row. The chevron icon fades in at the right edge of the row.
  3. Hover the chevron. After 300 ms the tooltip appears to the left with the resolved action description (for example Search HyperDX Logs or Open dashboard "API Latency").
  4. Click the chevron. Navigation behaves identically to clicking a cell.
  5. Cmd-click / middle-click the chevron. New tab opens.
  6. Right-click the chevron. Native context menu with "Open Link in New Tab" / "Copy Link Address".
  7. Move the cursor rapidly across multiple rows. No stranded tooltip from earlier rows.
  8. Configure an onClick whose template references an unknown column. Hover the resulting row. The chevron is not rendered.

References

Per-row Tooltip.Floating instances got stranded in their Portal when a
virtual row unmounted before onMouseLeave fired — a race that occurs
when the mouse moves rapidly across a TanStack Virtual list.

Fix: replace one Tooltip.Floating per virtual row with a single shared
Tooltip.Floating wrapping the whole <tbody>. The floating tooltip now
lives on <tbody>, which never unmounts, so its Portal-rendered content
can never be left open after the triggering element disappears.

Row-level onMouseEnter/onMouseLeave handlers update a shared
hoveredRowDescription state; the tooltip's disabled prop gates
visibility so rows without a resolved URL (error-toast branch) never
show a hint. A tbody-level onMouseLeave acts as a safety net to clear
the description if a rapid mouse move causes a row to unmount before its
own leave handler fires.

Test: adds a regression test that verifies the tooltip disappears on
mouseLeave (the stranded-tooltip scenario).
@alex-fedotyev alex-fedotyev added the ai-generated AI-generated content; review carefully before merging. label May 29, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 29, 2026

🦋 Changeset detected

Latest commit: d5d06c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 29, 2026

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

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Jun 3, 2026 5:05pm
hyperdx-storybook Error Error Jun 3, 2026 5:05pm

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

E2E Test Results

All tests passed • 197 passed • 3 skipped • 1216s

Status Count
✅ Passed 197
❌ Failed 0
⚠️ Flaky 3
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

Verifies that the Tooltip.Floating hint appears on row hover and
disappears when the mouse moves away, covering the stranded-tooltip
regression introduced by the per-row Tooltip.Floating in PR #2321.

Changes:
- dashboard-table-linking.spec.ts: new 'Tooltip hint appears on hover
  and disappears on mouse-leave' test. Creates a table tile with a
  Search row-click action, hovers the first row to confirm the hint
  appears, then moves the mouse away to confirm it hides cleanly.
- DashboardPage.ts: adds getFirstTableRow() and
  hoverFirstTableRowAndGetTooltip() page-object helpers.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

🔵 Tier 2 — Low Risk

Small, isolated change with no API route or data model modifications.

Why this tier:

  • Standard feature/fix — introduces new logic or modifies core functionality

Review process: AI review + quick human skim (target: 5–15 min). Reviewer validates AI assessment and checks for domain-specific concerns.
SLA: Resolve within 4 business hours.

Stats
  • Production files changed: 2
  • Production lines changed: 162 (+ 261 in test files, excluded from tier calculation)
  • Branch: alex/HDX-4405-fix-stranded-tooltip-in-virtual-table
  • Author: alex-fedotyev

To override this classification, remove the review/tier-2 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Deep Review

✅ No critical issues found.

This is a self-contained UI fix: the cursor-following Tooltip.Floating (which could strand a portal when a virtual row unmounted mid-hover) is replaced with an anchored Mantine Tooltip wrapping a hover-revealed trailing Link whose lifecycle is tied to the row. Reviewers confirmed the strand-by-construction claim holds for Mantine v9's portal teardown, the href sink is pre-existing and not exploitable, and the empty-cell path is safe.

🟡 P2 -- recommended

  • packages/app/src/HDXMultiSeriesTableChart.tsx:446 -- href={rowAction.url as string} casts away the string | null type rather than narrowing, so if isActionable's definition ever drifts from the render guard the checker stays silent and null reaches Link's href.
    • Fix: Derive a single narrowed local (e.g. const actionUrl = rowAction?.url) and guard on it inline so the compiler proves non-null and the cast is removed.
    • kieran-typescript, maintainability
  • packages/app/tests/e2e/features/dashboard-table-linking.spec.ts:755 -- the test is named a "pivot of the original HDX-4405 regression test" but only hovers a static first row and moves the cursor to a neutral corner; it never scrolls the virtualized list or recycles the hovered row, so the unmount-mid-hover failure mode the PR claims to close is unverified.
    • Fix: Add a step that opens the tooltip on a row, scrolls the tile's overflow container (or refetches) so that row unmounts, and asserts getByRole('tooltip') becomes hidden.
    • testing, julik-frontend-races
🔵 P3 nitpicks (4)
  • packages/app/src/HDXMultiSeriesTableChart.module.scss:4 -- the comment hard-codes a cross-file line range (LogTable.module.scss (lines 156-172)) that will silently rot on the next edit to that file.
    • Fix: Reference the .rowButtons symbol and correct packages/app/styles/ path instead of literal line numbers.
  • packages/app/tests/e2e/features/dashboard-table-linking.spec.ts:805 -- the click-navigation step hovers the row then clicks the opacity: 0-by-default hint without asserting the reveal fired, so a broken hover-reveal CSS rule would not fail the test.
    • Fix: Assert await expect(hint).toHaveCSS('opacity', '1') after row.hover() and before the click.
    • testing, julik-frontend-races
  • packages/app/src/HDXMultiSeriesTableChart.tsx:436 -- the guard isLastCell && isActionable && rowAction restates the rowAction check already inside isActionable, present only to satisfy TS narrowing.
    • Fix: Narrowing on a single derived url value removes both this duplicated clause and the as string cast in one change.
    • maintainability, kieran-typescript
  • packages/app/src/HDXMultiSeriesTableChart.tsx:445 -- the hover-revealed trailing icon is a hand-rolled SCSS Link, where agent_docs/code_style.md documents the Mantine subtle ActionIcon variant as the convention for utility controls that reveal interactivity on hover.
    • Fix: Consider ActionIcon with component={Link} to inherit the documented variant while keeping native href semantics; the current approach is acceptable if the variant cannot carry the required Link behavior.
    • ce-learnings-researcher

Reviewers (9): correctness, testing, maintainability, project-standards, kieran-typescript, julik-frontend-races, adversarial, agent-native, learnings-researcher.

Testing gaps:

  • No test exercises a mixed table where some rows are actionable and others (url: null) are not -- all current mocks return a constant action for every row.
  • No test asserts the trailing arrow's href resolves to the same destination as the per-cell row-body Link for the same row (the PR's stated invariant); the e2e only matches a loose /\/search\?/ regex.
  • The lastCell positioning class and zero-visible-cells path (lastCellIndex === -1) are covered only indirectly via hint presence.

P2 — correctness:
- Store hoveredVirtualIndex (row index) instead of hoveredRowDescription
  (string) so the label re-derives via useMemo on every render. If the
  virtualiser drops or replaces the hovered row (scroll, auto-refetch,
  rapid cursor movement) the new row's action is shown immediately;
  stale text from the unmounted row can never persist.
- Rows with url:null or empty description now correctly disable the
  tooltip regardless of what the prior hover state was.

P2 — testing:
- Replaced the simple mouseLeave regression test with one that exercises
  the actual race: hover index 0 (URL row), then enter index 1 (no-URL
  row) without firing mouseLeave on index 0. Asserts tooltip hides by
  inspecting the Mantine inline display style on the Portal container.

P3 — maintainability:
- label={hoveredRowDescription} — drop the ?? '' fallback that obscured
  the disabled gating relationship (Mantine accepts null as ReactNode).
- disabled={!hoveredRowDescription} — guards against empty-string
  descriptions that would mount a zero-width floating tooltip.
- Unconditional onMouseEnter/onMouseLeave on each <tr> with a hoisted
  clearHovered useCallback, replacing the conditional handler pattern
  that forked JSX unnecessarily.
- Collapse dual comment blocks into one rationale at the Tooltip.Floating
  call site; the state declaration now has a single-line pointer.
- Add data-testid="row-action-hint" to the Tooltip.Floating label span
  so E2E tests locate the tooltip by stable testid rather than by
  hard-coupled copy strings.
MikeShi42
MikeShi42 previously approved these changes May 30, 2026
Copy link
Copy Markdown
Contributor

@MikeShi42 MikeShi42 left a comment

Choose a reason for hiding this comment

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

fix makes sense - though i think it's a bit "aggressive" that the tooltip follows the cursor so closely, would it make more sense to have the action on the right side of the table?

Replace the tbody-level Tooltip.Floating that tracks the cursor
with a hover-revealed trailing chevron icon anchored to the row's
last cell, wrapped in an anchored Mantine Tooltip. Mirrors the
.rowButtons pattern used by Search and Patterns.

Eliminates the stranded-tooltip bug class by construction: the
anchored Tooltip's open state is tied to the icon's lifecycle, so
a virtual row unmounting mid-hover can no longer leave a popup
behind. Addresses Mike's design feedback that the cursor-following
tooltip felt "a bit aggressive".

Drop hoveredVirtualIndex / hoveredRowDescription state, the
tbody-level Tooltip.Floating, the tbody onMouseLeave safety net,
and per-row onMouseEnter / onMouseLeave handlers. Add a per-row
trailing IconChevronRight in the last <td>, wrapped in a Next.js
Link with prefetch=false, tabIndex=-1, and aria-hidden so cmd /
middle / right-click semantics carry over but sequential focus
and screen-reader traversal aren't double-counted.

New HDXMultiSeriesTableChart.module.scss owns .tableRow,
.lastCell, .rowActionHint. The icon is opacity 0 by default and
fades in on .tableRow:hover; the last <td> gets position: relative
so the icon's absolute positioning anchors to the cell, not the
row (which is not a reliable positioning context across browsers).
Tooltip is anchored position=left with the established
DBRowTableIconButton conventions (openDelay 300, closeDelay 100,
withArrow, fz xs).

Suppressed on failure rows (rowAction.url null): the description
("Open in search", "Open dashboard X") would promise a destination
the click cannot deliver.

Tests:
- Unit tests rewritten: trailing chevron rendering, dashboard
  variant tooltip, failure-row chevron absent, legacy
  getRowSearchLink path chevron absent. Dropped the inline-style
  closest() walk flagged in the deep review.
- E2E spec replaces the stranded-tooltip dismiss test with
  opacity-gated visibility, anchored tooltip text on hover, and
  click-on-chevron navigation.
- DashboardPage hint helper hovers the row to reveal the icon,
  then hovers the icon to open the anchored Tooltip.
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

Pivoted the PR per Mike's review feedback. The cursor-following Tooltip.Floating is gone; the hint is now a hover-revealed trailing chevron in the last cell, with an anchored Mantine Tooltip describing the destination on hover. Pushed as 7d720a9b.

Walking through Mike's concern: instead of patching the original surface I replaced it with one that doesn't have the same failure mode. The anchored Tooltip's open state is tied to the icon's lifecycle, so a virtual row unmounting mid-hover can't leave a popup behind by construction.

Notes on the design choices, in case they help review:

  • The chevron is opacity-gated on row hover (.tableRow:hover .rowActionHint { opacity: 1 }), matching the .rowButtons pattern used by Search and Patterns.
  • The icon's <Link> reuses rowAction.url so cmd-click, middle-click, right-click "Open in New Tab", and the status-bar URL preview all work on the icon as well as the row body.
  • tabIndex={-1} + aria-hidden="true" on the icon <Link> keep sequential focus order and screen-reader traversal anchored to the per-cell Links. The icon is a redundant pointer to the same destination; making it focusable would double-count.
  • The icon is suppressed on failure rows (rowAction.url === null) so it never promises a destination the click cannot deliver.
  • position="left" on the Tooltip so the popup appears toward the row body rather than off the right edge of the table.

PR-tier prediction: Tier 2 (132 production lines changed, single layer, no critical-path files). The previous commit on this branch (24d8552c) had bumped to Tier 3 because of the cross-file state machinery; this revision removes that.

Always-visible chevron column (Option A from the chat) is a richer surface and is tracked as a followup. It would add a permanent 32 px column on the right edge of the table, plus action-type-specific icons (IconLayoutDashboard for dashboard onClick vs IconChevronRight for search onClick). Happy to scope that out as a separate PR if you want to land this first.

Validation locally:

  • yarn workspace @hyperdx/app ci:lint: pass
  • yarn workspace @hyperdx/app ci:unit --testPathPatterns HDXMultiSeriesTableChart: 8/8 pass
  • yarn workspace @hyperdx/app ci:unit --testPathPatterns useOnClickLinkBuilder|DBTableChart: 12/12 pass
  • yarn workspace @hyperdx/app knip: no new findings
  • predict-tier: Tier 2

@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

@MikeShi42 - what do you think of this? I changed it to use less aggressive UX, while using > indicator to highlight the fact that this is actionable.
image

@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

@elizabetdev - what do you think about the > on the row to indicate action and drill down to another dashboard or search?
image

@elizabetdev
Copy link
Copy Markdown
Contributor

@alex-fedotyev I think all the actionable rows should have a hover state var(--color-bg-highlighted)

And we already using the chevron right for the following (so I think we shouldn't be reuisng the same icon to indicate a different thing:

image

The solution

So the solution would be, on hover var(--color-bg-highlighted) to indicate that the row has an action and a arrow up right to indicate linking to other page of opening a flyout.
image

Address elizabetdev review feedback on PR #2380:

- Hover state on actionable rows: switched the trailing-icon row
  from the global `bg-muted-hover` utility (`--color-bg-muted`) to a
  module-scoped `.actionableRow` class that hovers to
  `--color-bg-highlighted`. Non-actionable rows (failure or no
  action configured) keep `bg-muted-hover` as-is, so the visual
  delta between the two reinforces interactivity before the user
  sees the icon fade in.
- Icon swap: `IconChevronRight` -> `IconArrowUpRight`. The
  chevron-right is already used elsewhere (sidebar group collapse
  affordances among others) for an unrelated interaction, so
  reusing it here was a collision. Arrow-up-right reads as
  "navigate elsewhere" without that collision.

Tests:

- Unit: two new positive / negative tests assert the row gains
  `actionableRow` on a resolved URL and falls back to
  `bg-muted-hover` otherwise. Existing trailing-icon tests rename
  to "arrow" wording; the `data-testid="row-action-hint"`
  selector is unchanged so test infra carries over.
- E2E: step labels in `dashboard-table-linking.spec.ts` renamed to
  reference the arrow icon; selector is unchanged.
- Changeset filename renamed `hdx-4405-trailing-chevron-hint.md` ->
  `hdx-4405-trailing-action-hint.md` and the patch note updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

Both fixed in d5d06c7.

Icon: swapped IconChevronRight for IconArrowUpRight. Added a code comment next to the JSX block calling out the chevron-right collision with the sidebar group collapse / expand affordance, so the rationale is right there next to the icon for the next reader.

Hover state: new .actionableRow SCSS class. The <tr> className is conditional: rows that resolve to a URL get .actionableRow (hovers to var(--color-bg-highlighted)); rows where the action returns url: null keep the default bg-muted-hover utility. So actionable vs non-actionable rows now read differently on hover, before the icon even fades in.

Tests:

  • Unit tests: 10 in HDXMultiSeriesTableChart.test.tsx (was 8). Two new ones cover the positive / negative class application. The data-testid="row-action-hint" selector is unchanged, so the existing trailing-icon tests stayed green with just a chevron -> arrow wording pass.
  • E2E spec and DashboardPage page-object docstrings had "chevron" wording updated to "arrow"; selector is unchanged, so the e2e harness needs no fixture rewrite.

Vercel preview should update with the new icon + hover behavior shortly.

@alex-fedotyev
Copy link
Copy Markdown
Contributor Author

@elizabetdev - is this how you imagined it?

kodiakhq Bot pushed a commit that referenced this pull request Jun 5, 2026
…2386)

## Summary

This PR adds conditional color rules to number tiles. Define an ordered list of conditions; the last matching rule's color wins, so list rules in ascending priority order with the highest-priority rule last. If no rule matches, the tile falls back to its static color (set previously in #2265), then to the default text color.

The schema is intentionally generic at the shared level so a future table-tile slice can attach per-column rules without a schema change.

> **Stacking note:** the branch was cut from `alex/HDX-4405-fix-stranded-tooltip-in-virtual-table` (PR #2380), so the file list includes 4 unrelated files from that PR: `HDXMultiSeriesTableChart.tsx`, `HDXMultiSeriesTableChart.test.tsx`, `dashboard-table-linking.spec.ts`, `DashboardPage.ts`. Those files belong to #2380 and will drop out of this diff once #2380 merges. The HDX-4406 review surface is the other 10 files.

### What changed

**common-utils (schema)**
- Added `ColorConditionSchema` (discriminated union of `gt`, `gte`, `lt`, `lte`, `between`, `eq`, `neq`, `contains`, `startsWith`, `endsWith`, `regex` operators)
- Added `colorRules` (optional, max 10) to `SharedChartSettingsSchema` alongside the existing `color` field

**app (resolver)**
- `evaluateColorCondition`: evaluates a single rule against a runtime value with proper type guards (numeric ops reject strings, string ops reject numbers, bad regex returns false)
- `resolveConditionalColor`: iterates rules in order, last match wins, falls back to `fallback` (the static color) when nothing matches
- String data values (ClickHouse returns `UInt64` counts as JSON strings) are coerced to numbers before rule evaluation

**app (UI)**
- New `ColorRulesEditor` component: sortable rule list via `@dnd-kit/sortable`, per-operator value inputs (single number, two-number range for `between`, text-or-number for `eq`/`neq`), `ColorSwatchInput` per rule, add/delete buttons; "Add rule" disables at 10
- `ChartDisplaySettingsDrawer`: added "Conditional colors" section gated on `DisplayType.Number`, placed below the existing static color picker
- `EditTimeChartForm`: `colorRules` wired through `useWatch`, `displaySettings` memo, and `handleUpdateDisplaySettings`
- `DBNumberChart`: resolves color via `resolveConditionalColor(rawValue, config.colorRules, config.color)` at render time

**Tests**
- Schema: positive + negative cases for all operators and array length constraints
- Resolver: `evaluateColorCondition` per-operator, `resolveConditionalColor` including last-match-wins, string coercion, and the canonical success/warning/error scenario
- UI: add/delete/operator shape/color swatch/render cases for `ColorRulesEditor`
- Integration: `DBNumberChart` with `color: 'chart-success'` + two rules confirms value 50 -> success, 200 -> warning, 1000 -> error; also covers string UInt64 input

No changes to `packages/api` schemas or external API (separate follow-up ticket, mirrors HDX-4378).

### Screenshots or video

| Before | After |
| :----- | :---- |
| Number tile has only a static color picker | Number tile shows a "Conditional colors" section with add/reorder/delete rule controls below the static color picker |

### How to test on Vercel preview

**Preview routes:** /dashboards

**Steps:**

1. Open or create a dashboard and add a number tile (or edit an existing one).
2. Click the "Display Settings" button to open the drawer.
3. Confirm the "Conditional colors" section appears below the "Color" picker.
4. Click "Add rule" and verify a rule row appears with operator >, a value input, a color swatch, and a delete button.
5. Change the operator to >= and set value to 100; pick the Warning color swatch.
6. Click "Add rule" again; set operator >=, value 500; pick the Error color swatch.
7. Click "Apply". Verify the tile persists the rules (re-open Display Settings and confirm the two rules are still there).
8. With a tile value below 100, confirm it renders in the static (or default) color.
9. With a value between 100 and 499, confirm warning color.
10. With a value >= 500, confirm error color.
11. Click "Add rule" 8 more times to reach 10 rules total. Verify the button becomes disabled.
12. Drag a rule handle to reorder and click Apply; confirm the new order is reflected on re-open.

### References

- Linear Issue: https://linear.app/clickhouse/issue/HDX-4406
- Related PRs: #2265 (static number-tile color picker, precedent); #2380 (tooltip fix; this branch is stacked on it)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-generated AI-generated content; review carefully before merging. review/tier-2 Low risk — AI review + quick human skim

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants