Group the message overflow menu into scannable sections (LC-598) #556

Merged
longjacksonle merged 1 commit from feat/lc-message-menu-grouping into main 2026-07-19 02:29:45 +02:00

Closes LC-598.

The per-message ... overflow menu had grown to 15 flat rows and ran most of the viewport height, with no icons and no visual chunking. Every shipped feature had appended one more row.

Presentation and interaction only: no endpoint, hx-target, hx-swap, or permission gate changed.

Grouping

Five sections separated by dividers: message (Copy link, Copy text, Forward); save and remind (Save, Pin, Remind, Mark unread); tools (Translate, Read aloud, Find related, Suggest reply, Require acknowledgement); edit; and a destructive group holding Report and Delete. Dividers only, no section labels - five labels would have added back most of the height the grouping saves.

Note the menu had 15 items, not the 13 in the original brief: Suggest reply and Report were also there, both rendering only on other people's messages. Suggest reply joined the tools cluster (it is LLM-backed like Translate); Report went to the destructive group above Delete, where the two are near-exclusive in practice.

No submenu. A "More tools" submenu would buy about four rows at the cost of hover-intent timing, touch handling, and nested arrow-key logic. Grouping plus the placement fix below solves the height problem without that.

The separator is a border-top on .lc-menu-group rather than hand-placed .lc-menu-divider nodes, so a group that renders empty takes its rule with it instead of leaving a doubled or leading divider. Every member of the tools group is capability-gated, so the whole group collapses when the operator has no LLM and no embeddings, the browser has no speech synthesis, and the viewer is neither author nor room moderator.

Every row gains an inline SVG icon, Delete in the danger color. The copy buttons previously swapped textContent to show "Copied", which would have wiped the new icon, so the label now lives in its own span and only that is swapped.

Placement (the substantive fix)

The menu is absolutely positioned inside #messages, which is overflow-y: auto. The scroll container - not the viewport - is therefore the clipping ancestor, and it capped the menu at the container height (~557px at 1400x900). A grouped menu is ~480px tall, so anchoring inside the container forced an internal scroll on almost every open, which is the opposite of "internal scroll as a last resort". Measuring against window.innerHeight also overcounted available space by ~138px (everything below the timeline), which let a downward menu get cut off mid-row.

It is now promoted to position: fixed on open and placed against the viewport, flipping upward when the row sits near the bottom and clamping into the horizontal edge - the same escape reactions.js already uses for the reaction picker. The CSS keeps a static downward fallback if the script never runs, and the menu closes on scroll or resize rather than being left stranded mid-air.

Keyboard

The menu declared role="menu" but its items carried no tabindex="-1", so they all sat in the natural tab order and there was no arrow-key navigation - a pre-existing a11y bug. Items are now roving-tabindex with ArrowUp/ArrowDown/Home/End, a Tab focus trap, and Escape closing and restoring focus to the trigger, matching the account menu in partials/sidebar_self.html. Capability-hidden rows drop out of the keyboard order via an offsetParent check, so a server without an LLM does not leave dead stops in the sequence.

Verified live

Driven against the mock-SSO dev stack with real Chrome over CDP, two identities (devuser and alice):

  • Owner view: 13 items, Edit + Delete + Require acknowledgement, no Report.
  • Non-owner view: 12 items, Report + Suggest reply, no Edit / Delete / Require acknowledgement.
  • Message at the viewport bottom: flips up, fully visible, no clipping, no internal scroll.
  • Keyboard: Space opens focusing the first item; ArrowDown/Up traverse group boundaries; End/Home jump to Delete/Copy link; Escape closes and returns focus to the ... trigger.
  • Copy link: label swaps to "Copied" and reverts after 1.5s with the icon intact; clipboard receives the permalink.
  • Light and dark, comfortable and compact densities.

just check passes. just test passes except tests/last_visited.rs::home_renders_welcome_when_no_cookie and ::home_welcome_renders_quick_actions, which fail identically on clean main and are unrelated to this change.

Follow-ups found while auditing (not fixed here, recorded on LC-598)

  • dm.rs:239 computes can_delete from the global role only, ignoring per-room moderator overrides, so a room-level moderator sees no Delete in a DM the endpoint would allow. Divergent from room.rs.
  • just verify cannot pass locally: dev/server-up runs --user $(id -u) but the lets-chat-rewrite-data volume is root-owned, so SQLite panics opening the auth DB. The compose dev stack solves this with a chown entrypoint; verify never got it. Its 30s health poll is also shorter than a cold cargo build inside the container.
  • Pin gives no hint about the per-room pin cap the endpoint enforces, so the action can fail with a Conflict after the click.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P2Lh6DKB15inZTb5z8miu7

Closes LC-598. The per-message `...` overflow menu had grown to 15 flat rows and ran most of the viewport height, with no icons and no visual chunking. Every shipped feature had appended one more row. Presentation and interaction only: no endpoint, `hx-target`, `hx-swap`, or permission gate changed. ## Grouping Five sections separated by dividers: message (Copy link, Copy text, Forward); save and remind (Save, Pin, Remind, Mark unread); tools (Translate, Read aloud, Find related, Suggest reply, Require acknowledgement); edit; and a destructive group holding Report and Delete. Dividers only, no section labels - five labels would have added back most of the height the grouping saves. Note the menu had **15** items, not the 13 in the original brief: `Suggest reply` and `Report` were also there, both rendering only on other people's messages. Suggest reply joined the tools cluster (it is LLM-backed like Translate); Report went to the destructive group above Delete, where the two are near-exclusive in practice. No submenu. A "More tools" submenu would buy about four rows at the cost of hover-intent timing, touch handling, and nested arrow-key logic. Grouping plus the placement fix below solves the height problem without that. The separator is a `border-top` on `.lc-menu-group` rather than hand-placed `.lc-menu-divider` nodes, so a group that renders empty takes its rule with it instead of leaving a doubled or leading divider. Every member of the tools group is capability-gated, so the whole group collapses when the operator has no LLM and no embeddings, the browser has no speech synthesis, and the viewer is neither author nor room moderator. Every row gains an inline SVG icon, Delete in the danger color. The copy buttons previously swapped `textContent` to show "Copied", which would have wiped the new icon, so the label now lives in its own span and only that is swapped. ## Placement (the substantive fix) The menu is absolutely positioned inside `#messages`, which is `overflow-y: auto`. The scroll container - not the viewport - is therefore the clipping ancestor, and it capped the menu at the container height (~557px at 1400x900). A grouped menu is ~480px tall, so anchoring inside the container forced an internal scroll on almost every open, which is the opposite of "internal scroll as a last resort". Measuring against `window.innerHeight` also overcounted available space by ~138px (everything below the timeline), which let a downward menu get cut off mid-row. It is now promoted to `position: fixed` on open and placed against the viewport, flipping upward when the row sits near the bottom and clamping into the horizontal edge - the same escape `reactions.js` already uses for the reaction picker. The CSS keeps a static downward fallback if the script never runs, and the menu closes on scroll or resize rather than being left stranded mid-air. ## Keyboard The menu declared `role="menu"` but its items carried no `tabindex="-1"`, so they all sat in the natural tab order and there was no arrow-key navigation - a pre-existing a11y bug. Items are now roving-tabindex with ArrowUp/ArrowDown/Home/End, a Tab focus trap, and Escape closing and restoring focus to the trigger, matching the account menu in `partials/sidebar_self.html`. Capability-hidden rows drop out of the keyboard order via an `offsetParent` check, so a server without an LLM does not leave dead stops in the sequence. ## Verified live Driven against the mock-SSO dev stack with real Chrome over CDP, two identities (`devuser` and `alice`): - Owner view: 13 items, Edit + Delete + Require acknowledgement, no Report. - Non-owner view: 12 items, Report + Suggest reply, no Edit / Delete / Require acknowledgement. - Message at the viewport bottom: flips up, fully visible, no clipping, no internal scroll. - Keyboard: Space opens focusing the first item; ArrowDown/Up traverse group boundaries; End/Home jump to Delete/Copy link; Escape closes and returns focus to the `...` trigger. - Copy link: label swaps to "Copied" and reverts after 1.5s with the icon intact; clipboard receives the permalink. - Light and dark, comfortable and compact densities. `just check` passes. `just test` passes except `tests/last_visited.rs::home_renders_welcome_when_no_cookie` and `::home_welcome_renders_quick_actions`, which **fail identically on clean `main`** and are unrelated to this change. ## Follow-ups found while auditing (not fixed here, recorded on LC-598) - `dm.rs:239` computes `can_delete` from the global role only, ignoring per-room moderator overrides, so a room-level moderator sees no Delete in a DM the endpoint would allow. Divergent from `room.rs`. - `just verify` cannot pass locally: `dev/server-up` runs `--user $(id -u)` but the `lets-chat-rewrite-data` volume is root-owned, so SQLite panics opening the auth DB. The compose dev stack solves this with a chown entrypoint; `verify` never got it. Its 30s health poll is also shorter than a cold `cargo` build inside the container. - `Pin` gives no hint about the per-room pin cap the endpoint enforces, so the action can fail with a Conflict after the click. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01P2Lh6DKB15inZTb5z8miu7
feat(room): group the message overflow menu into scannable sections
All checks were successful
check-secrets / Nosey parker (push) Successful in 8s
check-secrets / TruffleHog (push) Successful in 9s
check-secrets / Kingfisher (push) Successful in 10s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 7s
check-secrets / Kingfisher (pull_request) Successful in 7s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 5m8s
744bfc0820
The per-message `...` menu had grown to 15 flat rows and ran most of the viewport height, with no icons and no way to chunk it visually. Every shipped feature had appended one more row.

Items are now grouped into five sections separated by dividers: message (Copy link, Copy text, Forward), save and remind (Save, Pin, Remind, Mark unread), tools (Translate, Read aloud, Find related, Suggest reply, Require acknowledgement), edit, and a destructive group holding Report and Delete. Dividers only, no section labels: five labels would have added back most of the height the grouping saves.

The separator is a border-top on the group rather than hand-placed `.lc-menu-divider` nodes, so a group that renders empty takes its rule with it instead of leaving a doubled or leading divider. Every member of the tools group is capability-gated, so that whole group collapses when an operator has no LLM and no embeddings, the browser has no speech synthesis, and the viewer is not the author or a room moderator.

Every row gains an inline SVG icon so the list is scannable by shape, with Delete in the danger color. The copy buttons previously swapped `textContent` to show "Copied", which would have wiped the new icon, so the label now lives in its own span and only that is swapped.

Placement: the menu is absolutely positioned inside `#messages`, which is `overflow-y: auto`, so the scroll container clipped it and capped it at the container height (~557px at 1400x900) rather than the viewport. A grouped menu is ~480px tall, so anchoring inside the container forced an internal scroll on almost every open. It is now promoted to `position: fixed` on open and placed against the viewport, flipping upward when the row sits near the bottom and clamping into the horizontal edge, the same escape the reaction picker already uses. Internal scroll remains as a genuine last resort. The CSS keeps a static downward fallback if the script never runs, and the menu closes on scroll or resize rather than being left stranded.

Keyboard: the menu declared `role="menu"` but its items had no `tabindex="-1"`, so they all sat in the natural tab order and there was no arrow-key navigation. Items are now roving-tabindex with ArrowUp/ArrowDown/Home/End, a Tab focus trap, and Escape closing and restoring focus to the trigger, matching the account menu in sidebar_self.html. Capability-hidden rows drop out of the keyboard order via an offsetParent check.

Presentation only: no endpoint, hx-target, hx-swap, or permission gate changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Lh6DKB15inZTb5z8miu7
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-19 02:26:56 +02:00
longjacksonle deleted branch feat/lc-message-menu-grouping 2026-07-19 02:29:46 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!556
No description provided.