Give buttons a shared primary/secondary/destructive style #118

Merged
rob merged 3 commits from feat/shared-button-style into main 2026-08-11 13:52:30 +00:00
Owner

Task 185. Every button in the ticket's own list — Accept/Decline, Remove/Leave/Cancel, the delete and move confirmations — carried no class at all. PlaceFormPanel's own discard confirmation (PR #117) shipped the same defect while this ticket was open; folded in too.

  • buttons.css (new): four weights — .btn-primary, .btn-secondary, .btn-destructive, and .btn-ghost/.btn-ghost-destructive for the quiet link-shaped triggers. .btn-primary is forms.css's existing .auth-form button rule with .btn-primary added to its selector list, not duplicated.
  • Destructive weight reuses --colour-danger (Remove, Leave, Delete, Decline). Promote is secondary: consequential, not destructive.
  • The ghost triggers are a weight, not an exception. place-detail-edit/-delete/-move and group-form-manage-members/-delete were initially left as exemptions; review found that place-detail-delete's trigger sat unweighted right beside its own .btn-destructive confirmation — the same flow, one styled and one not, which is precisely the "two systems standing" the ticket forbade. Giving them a named weight resolves it with no visual change: only margin stays component-scoped, verified by diffing both stylesheets.
  • PlaceAccordion's disclosure and list-navigation buttons remain genuine exemptions — they fill their row and must not read as standalone buttons.
  • ButtonWeightCoverage (new architecture test): every <button> in PlaceMark.WebUI must name a weight or be exempt. Exemptions are (file, class) pairs, so a class name cannot be borrowed elsewhere to bypass weighting.
  • GroupMemberRow no longer computes its weight. It previously set class="@ConfirmActionCssClass", which the guard cannot see — review proved this live by silently downgrading Remove/Leave from destructive to secondary with nothing catching it. Now @if/else branches with literal classes, asserted by three bUnit tests.

ADR-0103.

Review found four defects in the first two commits, each fixed and confirmed by mutation rather than inspection. The guard's account of its own limits was wrong twice before settling; it now hedges rather than enumerating, and a search of every <button>'s class= value confirms exactly one computed case remains (PlaceAccordion), matching what it claims.

Suite verified from a clean dotnet build PlaceMark.slnx: 1690 passed, 4 skipped, 1694 total, architecture tests 115. Earlier revisions of this description quoted inconsistent figures; this one is measured.

Task 185. Every button in the ticket's own list — Accept/Decline, Remove/Leave/Cancel, the delete and move confirmations — carried no `class` at all. `PlaceFormPanel`'s own discard confirmation (PR #117) shipped the same defect while this ticket was open; folded in too. - `buttons.css` (new): four weights — `.btn-primary`, `.btn-secondary`, `.btn-destructive`, and `.btn-ghost`/`.btn-ghost-destructive` for the quiet link-shaped triggers. `.btn-primary` is `forms.css`'s existing `.auth-form button` rule with `.btn-primary` added to its selector list, not duplicated. - Destructive weight reuses `--colour-danger` (Remove, Leave, Delete, Decline). Promote is secondary: consequential, not destructive. - **The ghost triggers are a weight, not an exception.** `place-detail-edit`/`-delete`/`-move` and `group-form-manage-members`/`-delete` were initially left as exemptions; review found that `place-detail-delete`'s trigger sat unweighted right beside its own `.btn-destructive` confirmation — the same flow, one styled and one not, which is precisely the "two systems standing" the ticket forbade. Giving them a named weight resolves it with no visual change: only `margin` stays component-scoped, verified by diffing both stylesheets. - `PlaceAccordion`'s disclosure and list-navigation buttons remain genuine exemptions — they fill their row and must not read as standalone buttons. - `ButtonWeightCoverage` (new architecture test): every `<button>` in `PlaceMark.WebUI` must name a weight or be exempt. Exemptions are `(file, class)` pairs, so a class name cannot be borrowed elsewhere to bypass weighting. - **`GroupMemberRow` no longer computes its weight.** It previously set `class="@ConfirmActionCssClass"`, which the guard cannot see — review proved this live by silently downgrading Remove/Leave from destructive to secondary with nothing catching it. Now `@if`/`else` branches with literal classes, asserted by three bUnit tests. ADR-0103. Review found four defects in the first two commits, each fixed and confirmed by mutation rather than inspection. The guard's account of its own limits was wrong twice before settling; it now hedges rather than enumerating, and a search of every `<button>`'s `class=` value confirms exactly one computed case remains (`PlaceAccordion`), matching what it claims. Suite verified from a clean `dotnet build PlaceMark.slnx`: **1690 passed, 4 skipped, 1694 total**, architecture tests 115. Earlier revisions of this description quoted inconsistent figures; this one is measured.
Give buttons a shared primary/secondary/destructive style
All checks were successful
CI / build (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 1m48s
7c9531d099
rob left a comment

Verdict: changes needed

One finding, verified by running it rather than reading it.

ButtonWeightCoverage flags a correctly weighted button as unweighted when its class attribute mixes a literal weight with a Razor expression. Confirmed by injecting

<button type="button" class="btn-primary @(_closing ? "x" : "y")">…</button>

into PlaceFormPanel.razor — the test fails on it. Neither regex handles the mixed case: _computedClassAttribute only matches when @ is the first character after the quote, and _literalClassAttribute's [^"@]* refuses any value containing @, so the button falls through with an empty class list.

It fails closed, so it cannot let an unstyled button through — but class="btn-primary @(…)" is an ordinary Blazor pattern, and the next person to write one gets an architecture-test failure telling them their weighted button is unweighted. Strip an @-expression out of the value before splitting, rather than rejecting values that contain one.

Related: the class remarks name PlaceAccordion's fully-computed class="@PlaceButtonClass(place.Id)" as "the one case in this codebase" and characterise the gap as skip-outright. That is incomplete — partially-computed values behave differently, and are flagged rather than skipped. Worth correcting, given ADR-0066's own history of a limitation comment that was accurate against one version of the check and wrong by the next.

Separately confirmed non-vacuous: a bare <button type="button"> injected into PlaceFormPanel.razor reddens the test on the exact tag.

Note on process: this is the authoring agent's own PR reviewed by the orchestrator, because the session's subagent limit is exhausted. An independent mergeable verdict is still owed before this merges — treat the above as findings, not as a completed review.

Verdict: changes needed One finding, verified by running it rather than reading it. `ButtonWeightCoverage` **flags a correctly weighted button as unweighted** when its class attribute mixes a literal weight with a Razor expression. Confirmed by injecting ```razor <button type="button" class="btn-primary @(_closing ? "x" : "y")">…</button> ``` into `PlaceFormPanel.razor` — the test fails on it. Neither regex handles the mixed case: `_computedClassAttribute` only matches when `@` is the *first* character after the quote, and `_literalClassAttribute`'s `[^"@]*` refuses any value containing `@`, so the button falls through with an empty class list. It fails closed, so it cannot let an unstyled button through — but `class="btn-primary @(…)"` is an ordinary Blazor pattern, and the next person to write one gets an architecture-test failure telling them their weighted button is unweighted. Strip an `@`-expression out of the value before splitting, rather than rejecting values that contain one. Related: the class remarks name `PlaceAccordion`'s fully-computed `class="@PlaceButtonClass(place.Id)"` as "the one case in this codebase" and characterise the gap as skip-outright. That is incomplete — partially-computed values behave differently, and are flagged rather than skipped. Worth correcting, given ADR-0066's own history of a limitation comment that was accurate against one version of the check and wrong by the next. Separately confirmed non-vacuous: a bare `<button type="button">` injected into `PlaceFormPanel.razor` reddens the test on the exact tag. Note on process: this is the authoring agent's own PR reviewed by the orchestrator, because the session's subagent limit is exhausted. An independent `mergeable` verdict is still owed before this merges — treat the above as findings, not as a completed review.
rob force-pushed feat/shared-button-style from 7c9531d099
All checks were successful
CI / build (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 1m48s
to 990ce51818
All checks were successful
CI / build (pull_request) Successful in 3m17s
CI / e2e (pull_request) Successful in 1m45s
2026-08-11 13:11:26 +00:00
Compare
rob left a comment

Verdict: changes needed

Reviewed at 990ce51 (mergeable, CI run #528 green). Independently verified the ClassTokens rewrite: both of the author's own claimed cases reproduce (class="btn-primary @(…)" passes, class="foo @(…)" still fails), and I could not find a false pass in the new balanced-paren/string scanner itself despite deliberately attacking it — nested parens, parens inside a string, two expressions, an entirely-computed value, a verbatim doubled-quote string all resolve correctly. One case (@(x ? "\"" : "y"), a backslash-escaped quote) does break the scan, but fails closed (returns [], flags a real weighted button as a gap) — annoying, not a hole, and a distinct bug from the one already reported.

1. The exempt list is a real, verified false pass — this is the sharpest finding. Any new button can bypass weighting entirely by reusing any of the 15 exempt class strings verbatim, regardless of what it actually does:

<button type="button" class="drawer-toggle">Delete my account, disguised as chrome</button>

ButtonWeightCoverage.UnweightedButtons() reports zero gaps for this. The exempt list is matched by string equality against a bare class token, with no association to the component or purpose it was written for — it's not "this specific drawer toggle is exempt", it's "the literal text drawer-toggle is exempt, wherever it appears." This defeats the ticket's whole point and should be closed (e.g. matching class and file/component) before merge.

2. The ghost-link exemptions don't hold up against "do not leave both systems standing." place-detail-delete (the trigger) and its own btn-destructive confirmation ("Yes, delete") sit in the same component, same flow, same action — one is on the new weight system, the other deliberately isn't. Same for place-detail-edit/-move and group-form-manage-members/-delete. This isn't the same category as the accordion disclosure/nav buttons (which have a real layout reason — filling and blending into a row); it's the identical "act on an object" control the ticket is about, in old and new clothes within one flow. The task-156 (no browser) blocker is real for actually redesigning their shape, but that argues for scoping this PR narrower or filing a explicit follow-up, not for calling the split intentional and closing the ticket on it.

3. ButtonWeightCoverage's doc comment and ADR-0103 both claim PlaceAccordion is "the one case" of an unjudgeable computed class — false as of this PR. GroupMemberRow.razor's own confirm button (class="@ConfirmActionCssClass") is a second one, added by this very PR, named nowhere. Mutated ConfirmActionCssClass to always return "btn-secondary" (silently downgrading Remove/Leave from destructive) — the guard doesn't catch it (documented limit), and neither do any of the 571 PlaceMark.WebUI.Tests (no bUnit assertion on this class anywhere). A genuinely destructive action's weight is unguarded, full stop.

4. Two more fails-closed traps, for completeness (not blocking, but real): a single-quoted class='btn-primary' is treated as no class at all (same family as the already-fixed bug, different trigger); a <button> inside a @* … *@ comment is still matched and flagged since comments aren't stripped before scanning.

5. Pre-existing, unrelated to this PR's own diff, worth knowing about: the outer _buttonOpeningTag regex (<button\b[^>]*>, unchanged) stops at the first literal > anywhere in the tag — including the one in @onclick="() => …". Today's buttons all put class= before any arrow-bearing attribute, so it happens to work, but it's order-dependent by accident, not by design. A future button with class= after an @onclick="() => …" will be silently truncated and flagged as a gap regardless of its real weight. Confirmed by injection.

Everything else checked out: every button the ticket named carries the right weight in markup (Accept/Promote/Cancel/Move-confirm secondary, Decline/Remove/Leave/Delete-confirm destructive), and appearance itself is correctly left unverified.

Verdict: changes needed Reviewed at `990ce51` (mergeable, CI run #528 green). Independently verified the `ClassTokens` rewrite: both of the author's own claimed cases reproduce (`class="btn-primary @(…)"` passes, `class="foo @(…)"` still fails), and I could not find a false pass in the new balanced-paren/string scanner itself despite deliberately attacking it — nested parens, parens inside a string, two expressions, an entirely-computed value, a verbatim doubled-quote string all resolve correctly. One case (`@(x ? "\"" : "y")`, a backslash-escaped quote) does break the scan, but fails **closed** (returns `[]`, flags a real weighted button as a gap) — annoying, not a hole, and a distinct bug from the one already reported. **1. The exempt list is a real, verified false pass — this is the sharpest finding.** Any new button can bypass weighting entirely by reusing any of the 15 exempt class strings verbatim, regardless of what it actually does: ```html <button type="button" class="drawer-toggle">Delete my account, disguised as chrome</button> ``` `ButtonWeightCoverage.UnweightedButtons()` reports zero gaps for this. The exempt list is matched by string equality against a bare class token, with no association to the component or purpose it was written for — it's not "this specific drawer toggle is exempt", it's "the literal text `drawer-toggle` is exempt, wherever it appears." This defeats the ticket's whole point and should be closed (e.g. matching class *and* file/component) before merge. **2. The ghost-link exemptions don't hold up against "do not leave both systems standing."** `place-detail-delete` (the trigger) and its own `btn-destructive` confirmation (`"Yes, delete"`) sit in the same component, same flow, same action — one is on the new weight system, the other deliberately isn't. Same for `place-detail-edit`/`-move` and `group-form-manage-members`/`-delete`. This isn't the same category as the accordion disclosure/nav buttons (which have a real layout reason — filling and blending into a row); it's the identical "act on an object" control the ticket is about, in old and new clothes within one flow. The task-156 (no browser) blocker is real for actually *redesigning* their shape, but that argues for scoping this PR narrower or filing a explicit follow-up, not for calling the split intentional and closing the ticket on it. **3. `ButtonWeightCoverage`'s doc comment and ADR-0103 both claim `PlaceAccordion` is "the one case" of an unjudgeable computed class — false as of this PR.** `GroupMemberRow.razor`'s own confirm button (`class="@ConfirmActionCssClass"`) is a second one, added by this very PR, named nowhere. Mutated `ConfirmActionCssClass` to always return `"btn-secondary"` (silently downgrading Remove/Leave from destructive) — the guard doesn't catch it (documented limit), and neither do any of the 571 `PlaceMark.WebUI.Tests` (no bUnit assertion on this class anywhere). A genuinely destructive action's weight is unguarded, full stop. **4. Two more fails-closed traps, for completeness (not blocking, but real):** a single-quoted `class='btn-primary'` is treated as no class at all (same family as the already-fixed bug, different trigger); a `<button>` inside a `@* … *@` comment is still matched and flagged since comments aren't stripped before scanning. **5. Pre-existing, unrelated to this PR's own diff, worth knowing about:** the outer `_buttonOpeningTag` regex (`<button\b[^>]*>`, unchanged) stops at the *first* literal `>` anywhere in the tag — including the one in `@onclick="() => …"`. Today's buttons all put `class=` before any arrow-bearing attribute, so it happens to work, but it's order-dependent by accident, not by design. A future button with `class=` after an `@onclick="() => …"` will be silently truncated and flagged as a gap regardless of its real weight. Confirmed by injection. Everything else checked out: every button the ticket named carries the right weight in markup (Accept/Promote/Cancel/Move-confirm secondary, Decline/Remove/Leave/Delete-confirm destructive), and appearance itself is correctly left unverified.
Key button exemptions to their own file, and give ghost triggers a real weight
All checks were successful
CI / build (pull_request) Successful in 3m9s
CI / e2e (pull_request) Successful in 1m57s
15b4e45d12
rob left a comment

Verdict: mergeable

Reviewed 15b4e45 specifically (independent of the two commits I authored). All four findings from the prior review are genuinely fixed, verified by mutation rather than by reading:

  • GroupMemberRow downgrade: reverted the confirm button's destructive-branch literal to btn-secondary — both new bUnit tests (...RemoveConfirmationShown..., ...LeaveConfirmationShown...) reddened on the exact class-attribute diff. The computed ConfirmActionCssClass property is gone outright, not just untested.
  • drawer-toggle bypass: added class="drawer-toggle" to an unrelated button in GroupMemberRow.razorUnweightedButtons() now correctly flags it (IsExemptInFile requires both class and file to match).
  • Regex truncation: direct probes against ClassTokens/MatchButtonOpeningTags confirm @onclick="() => …" before class= no longer truncates the tag, and — the case that matters most — a bare button with the same => hazard is still correctly flagged as ungapped rather than silently absorbing a sibling's class= into its own match.
  • Ghost weight move: diffed PlaceDetailPanel.razor.css/GroupFormPanel.razor.css against 990ce51 line by line. Every property except margin moved into buttons.css's .btn-ghost/.btn-ghost-destructive verbatim (same values, same hover shape); appearance is unchanged by construction, not merely by claim.

Is there a third computed-class case? Searched every <button> in src/PlaceMark.WebUI for any @ inside its own class attribute value (not a proximity grep — parsed each tag's own class= value directly). Exactly one: PlaceAccordion.razor's class="@PlaceButtonClass(place.Id)", the same one the guard's own (now appropriately hedged) remarks describe. No third case exists today, and the doc comment's refusal to name a fixed list this time is the right call given its own two-strikes history.

Single-quoted class and comment-embedded buttons (items 4 in the prior review) are both exercised directly and pass.

Two things worth a line, not a blocker:

  • The PR body currently reads "1675 passed" for the full suite. That number has moved and been wrong across pushes — please drop it or replace it with a number checked against a from-scratch dotnet build immediately before quoting it, rather than repeating a stale figure.
  • CI run #531 on 15b4e45 had not completed as of this review (Status: running) — confirm it's green before merging; this verdict is on the code, not on an unconfirmed run.

dotnet format --verify-no-changes and git merge-tree against origin/main are both clean.

Verdict: mergeable Reviewed `15b4e45` specifically (independent of the two commits I authored). All four findings from the prior review are genuinely fixed, verified by mutation rather than by reading: - **`GroupMemberRow` downgrade**: reverted the confirm button's destructive-branch literal to `btn-secondary` — both new bUnit tests (`...RemoveConfirmationShown...`, `...LeaveConfirmationShown...`) reddened on the exact class-attribute diff. The computed `ConfirmActionCssClass` property is gone outright, not just untested. - **`drawer-toggle` bypass**: added `class="drawer-toggle"` to an unrelated button in `GroupMemberRow.razor` — `UnweightedButtons()` now correctly flags it (`IsExemptInFile` requires both class and file to match). - **Regex truncation**: direct probes against `ClassTokens`/`MatchButtonOpeningTags` confirm `@onclick="() => …"` before `class=` no longer truncates the tag, and — the case that matters most — a bare button with the same `=>` hazard is still correctly flagged as ungapped rather than silently absorbing a sibling's `class=` into its own match. - **Ghost weight move**: diffed `PlaceDetailPanel.razor.css`/`GroupFormPanel.razor.css` against `990ce51` line by line. Every property except `margin` moved into `buttons.css`'s `.btn-ghost`/`.btn-ghost-destructive` verbatim (same values, same hover shape); appearance is unchanged by construction, not merely by claim. **Is there a third computed-class case?** Searched every `<button>` in `src/PlaceMark.WebUI` for any `@` inside its own `class` attribute value (not a proximity grep — parsed each tag's own `class=` value directly). Exactly one: `PlaceAccordion.razor`'s `class="@PlaceButtonClass(place.Id)"`, the same one the guard's own (now appropriately hedged) remarks describe. No third case exists today, and the doc comment's refusal to name a fixed list this time is the right call given its own two-strikes history. Single-quoted class and comment-embedded buttons (items 4 in the prior review) are both exercised directly and pass. **Two things worth a line, not a blocker:** - The PR body currently reads "1675 passed" for the full suite. That number has moved and been wrong across pushes — please drop it or replace it with a number checked against a from-scratch `dotnet build` immediately before quoting it, rather than repeating a stale figure. - **CI run #531 on `15b4e45` had not completed as of this review** (`Status: running`) — confirm it's green before merging; this verdict is on the code, not on an unconfirmed run. `dotnet format --verify-no-changes` and `git merge-tree` against `origin/main` are both clean.
rob merged commit 64dc5e318a into main 2026-08-11 13:52:30 +00:00
rob deleted branch feat/shared-button-style 2026-08-11 13:52:30 +00: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
rob/PlaceMark!118
No description provided.