The assignee panel is the card's people, and a search for anybody else #225

Merged
Grandiras merged 4 commits from assignee-menu into main 2026-09-23 11:15:45 +00:00
Owner

The card dialog's assignee panel used to show every member as a chip, with the card's people ticked. It grew with the deployment rather than with the card. On a 40-member test deployment it was more than half of the dialog's elements (117 of 228), and finding one name meant reading all of them. This ships the redesign the canvas has carried since the 2026-08-29 UX pass (Card Modal · assignee redesign (not built)).

What changes

  • The panel shows only the people on the card. Each is a chip with a × to take them off, followed by a dashed + Assign… button that opens a menu.
  • The menu is searched, not scrolled. It has a search field, at most 8 rows (face, name, handle underneath, and a tick for anyone already on the card), then "N more". Matching ignores case and accents and spells out ß, so "muller" finds Müller and "weiss" finds Weiß. Enter puts the first match on and clears the field for the next name, but only once something has been typed. The reader is always listed first. Rows toggle without closing the menu.
  • It offers only people who can see the board. That comes from the new BoardService.SharedWithAsync, which returns null for a board open to everybody (no query in that case). Banned members are not offered either. Only the menu narrows: a name already on a card keeps its chip, the same way a banned member's does.
  • Somebody else's card has no menu. The reader sees the card's people as labels and one + Assign me button, which turns into their own chip with a × once they're on.
  • Escape closes the menu, not the dialog, and focus goes back to the button. Clicking anywhere else closes it too, using the same invisible click-catcher as the column filter.
  • The dialog body scrolls to show the menu (js/assignee-menu.js). On a desktop it scrolls just far enough to show the menu's foot. On a phone it brings the menu to the top, where the keyboard can't cover it. The search field only gets focus automatically with a fine pointer, so a phone keyboard doesn't pop up uninvited.
  • BoardPage now asks for everything before the dialog exists. An await in a Blazor event handler is a render, so the dialog used to appear without its move panel and grow one a query later, during its entrance. This connects to #224.

Canvas

The two not built frames were compared against the app and replaced with traced Card Modal · Assign menu frames (light and dark), showing the menu searched down to two people. Card Modal (light and dark) and Phone — Card modal are retraced. capture.mjs and frames.sh know the new state. The saved design/Ponente.free is in the second commit. The archive drops two images, and they were only the deleted frames' comment avatars.

Tests

permissions.spec.js now covers:

  • Somebody else's card: there is no menu, only Assign me, and the chip's × takes the reader back off.
  • The admin's full round trip: open the menu, search, Enter, Escape (closing the menu only), save, reopen.
  • A board of the admin's own: the other member is not offered, and focus returns to the button after Escape.

Runs:

  • Via tools/verify.sh e2e: permissions, board, calendar, calendar-all, mobile, changelog and access specs, 62 passed.
  • permissions.spec.js again with --repeat-each=2: passed.
  • dotnet test: 560 passed.

Docs: docs/ui.md (the picker's reasoning, and the read-only dialog's Assign me), docs/boards-and-permissions.md (moves, the read-only dialog, what permissions.spec pins) and docs/frontend.md (the canvas status and the new JS module). Changelog entry added in both locales.

Merge note: this and #224 both touch app.css, docs/ui.md and the Unreleased changelog block, so whichever merges second needs its conflicts resolved. Each changelog block needs both bullets.

🤖 Generated with Claude Code

The card dialog's assignee panel used to show every member as a chip, with the card's people ticked. It grew with the deployment rather than with the card. On a 40-member test deployment it was more than half of the dialog's elements (117 of 228), and finding one name meant reading all of them. This ships the redesign the canvas has carried since the 2026-08-29 UX pass (*Card Modal · assignee redesign (not built)*). ## What changes - **The panel shows only the people on the card.** Each is a chip with a × to take them off, followed by a dashed **+ Assign…** button that opens a menu. - **The menu is searched, not scrolled.** It has a search field, at most 8 rows (face, name, handle underneath, and a tick for anyone already on the card), then "*N* more". Matching ignores case and accents and spells out ß, so "muller" finds Müller and "weiss" finds Weiß. **Enter** puts the first match on and clears the field for the next name, but only once something has been typed. The reader is always listed first. Rows toggle without closing the menu. - **It offers only people who can see the board.** That comes from the new `BoardService.SharedWithAsync`, which returns null for a board open to everybody (no query in that case). Banned members are not offered either. **Only the menu narrows:** a name already on a card keeps its chip, the same way a banned member's does. - **Somebody else's card has no menu.** The reader sees the card's people as labels and one **+ Assign me** button, which turns into their own chip with a × once they're on. - **Escape closes the menu, not the dialog,** and focus goes back to the button. Clicking anywhere else closes it too, using the same invisible click-catcher as the column filter. - **The dialog body scrolls to show the menu** (`js/assignee-menu.js`). On a desktop it scrolls just far enough to show the menu's foot. On a phone it brings the menu to the top, where the keyboard can't cover it. The search field only gets focus automatically with a fine pointer, so a phone keyboard doesn't pop up uninvited. - **`BoardPage` now asks for everything before the dialog exists.** An await in a Blazor event handler is a render, so the dialog used to appear without its move panel and grow one a query later, during its entrance. This connects to #224. ## Canvas The two *not built* frames were compared against the app and replaced with traced **Card Modal · Assign menu** frames (light and dark), showing the menu searched down to two people. *Card Modal* (light and dark) and *Phone — Card modal* are retraced. `capture.mjs` and `frames.sh` know the new state. The saved `design/Ponente.free` is in the second commit. The archive drops two images, and they were only the deleted frames' comment avatars. ## Tests `permissions.spec.js` now covers: - **Somebody else's card:** there is no menu, only *Assign me*, and the chip's × takes the reader back off. - **The admin's full round trip:** open the menu, search, Enter, Escape (closing the menu only), save, reopen. - **A board of the admin's own:** the other member is not offered, and focus returns to the button after Escape. Runs: - Via `tools/verify.sh e2e`: `permissions`, `board`, `calendar`, `calendar-all`, `mobile`, `changelog` and `access` specs, 62 passed. - `permissions.spec.js` again with `--repeat-each=2`: passed. - `dotnet test`: 560 passed. Docs: `docs/ui.md` (the picker's reasoning, and the read-only dialog's *Assign me*), `docs/boards-and-permissions.md` (moves, the read-only dialog, what `permissions.spec` pins) and `docs/frontend.md` (the canvas status and the new JS module). Changelog entry added in both locales. **Merge note:** this and #224 both touch `app.css`, `docs/ui.md` and the Unreleased changelog block, so whichever merges second needs its conflicts resolved. Each changelog block needs both bullets. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The assignee panel is the card's people, and a search for anybody else
All checks were successful
CI / build (pull_request) Successful in 42s
93ea3ac0a5
The panel used to be every member as a chip with the card's people ticked:
it grew with the deployment rather than with the card, and at forty members
it was more than half the dialog's elements and a wall to read through for
one name. It is the canvas's adopted redesign now:

- Only the people on the card, as chips with a × to take them off, and a
  "+ Assign…" button opening a menu: a search field, at most eight rows
  (face, name, handle under it, a tick for who is on), then "N more".
  Matching ignores case and accents, and spells out ß. Enter puts the first
  match on and clears the field; the reader is always offered first.
- The menu offers only who can see the board — BoardService.SharedWithAsync,
  null for a board open to everybody — and not the banned. Only the menu
  narrows: a name already on a card stays.
- Somebody else's card has no menu, only "+ Assign me".
- Escape shuts the menu and not the dialog, and focus goes back to the
  button. The dialog's body is scrolled so the menu is in view
  (js/assignee-menu.js): to its foot on a desk, to the top on a phone,
  where the keyboard would otherwise cover it.
- BoardPage asks for the move menu's boards and the shared-with set before
  the dialog exists, so it no longer grows its move panel mid-entrance.

The canvas: the two "not built" frames are replaced by traced "Card Modal ·
Assign menu" frames (light + dark), and Card Modal and Phone — Card modal
are retraced; capture.mjs and frames.sh know the new state.

permissions.spec.js pins Assign me on somebody else's card, the admin's
search / Enter / Escape / save round trip, and that a board of the admin's
own does not offer the other member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
Canvas: the assignee menu traced, the "not built" frames retired
All checks were successful
CI / build (pull_request) Successful in 47s
05e05057f7
Card Modal · assignee redesign (not built), light and dark, compared against
the app and deleted; in their place Card Modal · Assign menu (light + dark),
traced with the menu open and searched to two people. Card Modal and Phone —
Card modal are retraced with the new panel. The two images the archive drops
were the deleted frames' comment avatars and nothing else's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into assignee-menu
All checks were successful
CI / build (pull_request) Successful in 44s
11d6565bf5
# Conflicts:
#	src/Ponente.Kanban/Services/Changelog.cs
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

A careful, well-tested redesign that matches its own documentation. Verified in depth:

  • SharedWithAsync is semantically right. For a restricted board it returns exactly the BoardMembers DIDs, which is the same set BoardAccess.VisibleTo lets through (no admin bypass — board admins are on grant rows by construction); null for Everyone and an empty set for a missing board are both the honest answers. Only the menu narrows; the server-side CanSetAssignees rule is untouched, so this is a UI narrowing, as documented, not a new gate — and no new data exposure, since AvailableMembers already reached the dialog.
  • The render-once fix is real. OpenCard/StartNewCard now compute canEdit, boards and the shared-with set before _modalCard is set, and CloseModal resets the new state. Calendar asks per-card-board (_modalBoardId), which is the right board on the general calendar. (Nit, no action needed: Calendar's call always pays the visibility query even for an Everyone board, where BoardPage's helper short-circuits — one cheap query on dialog open only.)
  • The read-only dialog logic holds. CanAssignSelf correctly disappears once the reader is on the card (the chip's × takes over) and degrades safely for the empty-OnlyDid case; static-vs-pressable chip rendering matches the old rule.
  • The JS and CSS assumptions check out. .modal-body is the scroll container and the absolutely positioned menu extends its scrollable overflow, so the scrollBy approach is sound; the 599px phone breakpoint matches the app's own media queries; the fixed catcher has no transformed ancestor by the time the menu opens (the modal's slideUp has finished), so inset: 0 covers the viewport; catcher z 20 under menu z 30 with the add button deliberately under the catcher reproduces the column filter's toggle shape. The ≤6px measurement error from the entrance animation is inside the 12px gap.
  • Lifecycle and interop are correct. DisposeAsync calls the idempotent base.Dispose(), so the locale subscription is dropped whether Blazor calls one or both; the module import follows the codebase's convention; modal-escape.js's <body>-focused guard doesn't collide with the search field's focus.
  • Tests and locale keys are complete. Both locales carry every new key including the assignees.more / .one pair; the e2e premises hold (new boards are created Restricted with only the creator, so the own-board "not offered" assertion is testing what it claims).

One non-blocking correctness bug in the menu's keyboard path is filed as a line comment (Enter on a focused row double-toggles); the type-then-Enter flow the PR describes is unaffected.

Known follow-ups the PR already discloses, for the record: the Ponente.free canvas commit trails this one, and the merge note about #224 touching app.css, docs/ui.md and the Unreleased changelog block stands — whichever merges second needs both changelog bullets.



⏱ 11m 11s · 20 steps · $0.1010 · tokens: 376.7k in / 35.6k out (+804.5k cache-read) · Go: 5h 10% · wk 78% · mo 72%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. A careful, well-tested redesign that matches its own documentation. Verified in depth: - **`SharedWithAsync` is semantically right.** For a restricted board it returns exactly the `BoardMembers` DIDs, which is the same set `BoardAccess.VisibleTo` lets through (no admin bypass — board admins are on grant rows by construction); `null` for `Everyone` and an empty set for a missing board are both the honest answers. Only the menu narrows; the server-side `CanSetAssignees` rule is untouched, so this is a UI narrowing, as documented, not a new gate — and no new data exposure, since `AvailableMembers` already reached the dialog. - **The render-once fix is real.** `OpenCard`/`StartNewCard` now compute `canEdit`, boards and the shared-with set *before* `_modalCard` is set, and `CloseModal` resets the new state. Calendar asks per-card-board (`_modalBoardId`), which is the right board on the general calendar. (Nit, no action needed: Calendar's call always pays the visibility query even for an `Everyone` board, where `BoardPage`'s helper short-circuits — one cheap query on dialog open only.) - **The read-only dialog logic holds.** `CanAssignSelf` correctly disappears once the reader is on the card (the chip's × takes over) and degrades safely for the empty-`OnlyDid` case; static-vs-pressable chip rendering matches the old rule. - **The JS and CSS assumptions check out.** `.modal-body` is the scroll container and the absolutely positioned menu extends its scrollable overflow, so the `scrollBy` approach is sound; the 599px phone breakpoint matches the app's own media queries; the fixed catcher has no transformed ancestor by the time the menu opens (the modal's `slideUp` has finished), so `inset: 0` covers the viewport; catcher z 20 under menu z 30 with the add button deliberately under the catcher reproduces the column filter's toggle shape. The ≤6px measurement error from the entrance animation is inside the 12px gap. - **Lifecycle and interop are correct.** `DisposeAsync` calls the idempotent `base.Dispose()`, so the locale subscription is dropped whether Blazor calls one or both; the module import follows the codebase's convention; `modal-escape.js`'s `<body>`-focused guard doesn't collide with the search field's focus. - **Tests and locale keys are complete.** Both locales carry every new key including the `assignees.more` / `.one` pair; the e2e premises hold (new boards are created `Restricted` with only the creator, so the own-board "not offered" assertion is testing what it claims). One non-blocking correctness bug in the menu's keyboard path is filed as a line comment (Enter on a focused row double-toggles); the type-then-Enter flow the PR describes is unaffected. Known follow-ups the PR already discloses, for the record: the `Ponente.free` canvas commit trails this one, and the merge note about #224 touching `app.css`, `docs/ui.md` and the Unreleased changelog block stands — whichever merges second needs both changelog bullets. --- --- ⏱ 11m 11s · 20 steps · $0.1010 · tokens: 376.7k in / 35.6k out (+804.5k cache-read) · Go: 5h 10% · wk 78% · mo 72% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-225/run-2026-09-23T10-58-05-570Z.md) <!-- opencode-reviewed-head:93ea3ac0a5d72229c6c7958acaf216b2cdbf35e9 --> <!-- opencode-verdict:approve -->
@ -41,0 +67,4 @@
role="dialog"
aria-label="@L.T("modal.assignees")"
@ref="_menu"
@onkeydown="OnMenuKeyDown"
Collaborator

The Enter handler sits on the .assignee-menu div, so it also fires for a keydown that bubbles up from a focused row button (reached by Tab from the search field). With text in the field, one Enter press then toggles twice: the handler toggles Matches().FirstOrDefault(), and the browser's native button activation fires a click that toggles the focused row. If the focused row is the first match the two cancel out — Enter visibly does nothing; if it isn't, two members change with one keypress. Escape is fine where it is (it must catch the whole menu), but Enter belongs on the search input itself (@onkeydown on the <input>), where there is no native click to double-fire. Narrow keyboard-only path — the type-then-Enter, click and Escape flows all behave and are pinned by the spec — so not blocking, but worth fixing while the component is fresh.

The Enter handler sits on the `.assignee-menu` div, so it also fires for a keydown that bubbles up from a **focused row button** (reached by Tab from the search field). With text in the field, one Enter press then toggles twice: the handler toggles `Matches().FirstOrDefault()`, and the browser's native button activation fires a click that toggles the focused row. If the focused row *is* the first match the two cancel out — Enter visibly does nothing; if it isn't, two members change with one keypress. Escape is fine where it is (it must catch the whole menu), but Enter belongs on the search input itself (`@onkeydown` on the `<input>`), where there is no native click to double-fire. Narrow keyboard-only path — the type-then-Enter, click and Escape flows all behave and are pinned by the spec — so not blocking, but worth fixing while the component is fresh.
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer left a comment

💬 Verdict: comment — the reviewer did not submit a structured review; the raw output follows.

(empty)



⏱ 1m 59s · 0 steps · $0.0000 · tokens: 0 in / 0 out (+0 cache-read) · Go: 5h 10% · wk 78% · mo 72%

💬 **Verdict: comment** — the reviewer did not submit a structured review; the raw output follows. (empty) --- --- ⏱ 1m 59s · 0 steps · $0.0000 · tokens: 0 in / 0 out (+0 cache-read) · Go: 5h 10% · wk 78% · mo 72%
Review: Enter is the search field's, so a row's Enter is only its click
All checks were successful
CI / build (pull_request) Successful in 43s
86f997b88b
The menu handled Enter for the whole menu, so Enter on a row reached by Tab
toggled the first match *and* clicked the row — nothing visible when they
were the same person, two people changed when they were not. Escape stays
on the menu; Enter moves onto the field. permissions.spec.js now tabs to a
row and presses Enter twice (off, then on), and fails on the old handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-review after new commits: the one issue from my previous pass is resolved. The Enter-in-menu double-toggle is fixed by moving the handler onto the search input and leaving only Escape on the menu container, with a new spec section pinning the Tab-to-row → Enter path (one toggle per keypress). The docs and inline remarks were updated to match. Everything else from the first pass stands as verified: SharedWithAsync semantics, the render-once dialog setup in BoardPage/Calendar, the read-only dialog's Assign me path, the JS/CSS scroll and focus behaviour, the disposal chain, locale keys in both languages, and the canvas/test coverage. Remaining notes are all non-blocking and already disclosed by the PR itself: the Ponente.free canvas commit trails the code commit, and the merge conflict with #224 over app.css, docs/ui.md and the Unreleased changelog block needs resolving in whichever PR lands second. Good to merge.



⏱ 1m 0s · 0 steps · $0.0000 · tokens: 0 in / 0 out (+0 cache-read) · Go: 5h 11% · wk 78% · mo 72%

✅ **Verdict: approve** — no blocking issues found. Re-review after new commits: the one issue from my previous pass is resolved. The Enter-in-menu double-toggle is fixed by moving the handler onto the search input and leaving only Escape on the menu container, with a new spec section pinning the Tab-to-row → Enter path (one toggle per keypress). The docs and inline remarks were updated to match. Everything else from the first pass stands as verified: `SharedWithAsync` semantics, the render-once dialog setup in `BoardPage`/`Calendar`, the read-only dialog's *Assign me* path, the JS/CSS scroll and focus behaviour, the disposal chain, locale keys in both languages, and the canvas/test coverage. Remaining notes are all non-blocking and already disclosed by the PR itself: the `Ponente.free` canvas commit trails the code commit, and the merge conflict with #224 over `app.css`, `docs/ui.md` and the Unreleased changelog block needs resolving in whichever PR lands second. Good to merge. --- --- ⏱ 1m 0s · 0 steps · $0.0000 · tokens: 0 in / 0 out (+0 cache-read) · Go: 5h 11% · wk 78% · mo 72% <!-- opencode-reviewed-head:86f997b88b71544920868848593c987952e737fe --> <!-- opencode-verdict:approve -->
@ -41,0 +77,4 @@
@ref="_search"
@bind="_query"
@bind:event="oninput"
@onkeydown="OnSearchKeyDown" />
Collaborator

Fixed since the last pass: Enter is handled on the field itself, and the menu-level keydown handler now only catches Escape, so a focused row's Enter is just its native click — one keypress toggles one person. The new spec assertions ("takes them off, once" / "and the next puts them back") pin exactly the path that used to double-fire. Thanks for the clean fix and the test.

Fixed since the last pass: Enter is handled on the field itself, and the menu-level keydown handler now only catches Escape, so a focused row's Enter is just its native click — one keypress toggles one person. The new spec assertions ("takes them off, once" / "and the next puts them back") pin exactly the path that used to double-fire. Thanks for the clean fix and the test.
Collaborator

🔧 Let opencode-bot fix this — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do.

  • @opencode-bot Address the findings of the latest review on this pull request.
🔧 **Let opencode-bot fix this** — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do. - [ ] @opencode-bot Address the findings of the latest review on this pull request. <!-- opencode-fix-button:225 -->
Grandiras deleted branch assignee-menu 2026-09-23 11:15:45 +00:00
Grandiras referenced this pull request from a commit 2026-09-23 11:47:01 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
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
Grandiras/Ponente!225
No description provided.