Use nldd-icon-button and nldd-activity-indicator for the card actions #236

Closed
robbertbos wants to merge 4 commits from style/nldd-icon-buttons into main
Owner

Replaces five hand-rolled icon buttons and one hand-rolled spinner with the NLDD components, then fixes the three regressions that only showed up in the running app.

The swaps

Where Was Now
CardRow quick actions + "Open in Mattermost", both variants (5 sites) 24x24 hand-rolled <button> / <a> nldd-icon-button size="xs" variant="neutral-transparent"
@-mention loading row border-top-color + @keyframes rotate nldd-activity-indicator size="16"

The geometry is a dead-on match at xs: 24x24 box, 16px icon, 4px radius, all three unchanged.

The three regressions, and what they taught

The hover plate came back. neutral-transparent hard-codes a transparent hover background, so the quick actions lost their grey chip while BubbleMenuButton beside them kept one. The value is a literal in the shadow stylesheet, so no variant and no --semantics-buttons-* token reaches it. But NLDD sets no background on the host, and a transparent inner control lets the host's own background through - so this is public CSS on our own element, not the private --_is-hovered-background-color. Measured back to oklch(0.923 0.009 257.4) light and oklch(0.348 0.02 257.4) dark, identical to what BubbleMenuButton paints.

display: flex on the host fixes three things at once. :host is inline-block, so it built a 27px line box around a 24px control. That overhang produced a strip of the card's pointer cursor under every button, a 1.5px misalignment against BubbleMenuButton, and would have made the hover plate 3px too tall. The hosts measure 24x24 again.

The cursor. Dragging along the row flipped arrow, hand, arrow, hand. Two causes: the card's own pointer leaking through the 2px gaps and the 3px overhang, and the href variant taking --semantics-controls-link-cursor, which base.css:99 sets to pointer. Waggle's convention (base.css:94) is that buttons keep the arrow and the pointer is for links; an icon button is a button whatever element it renders. Scoping the token on the group settles all four at default and leaves every real text link alone. Measured: all four default, all four 24x24.

A skipped tab stop. useCardKeyboardNav's FOCUSABLE_SELECTOR only knew light-DOM elements, so none of the three hosts counted and the jump to the next section fired one control early - "Open in Mattermost" was unreachable by Tab.

What has no test, and why

The tab-stop fix has no unit test. getFocusableWithin filters on offsetParent, which happy-dom does not populate, so a custom element that is not already the active element is filtered out before any assertion can see it. Writing a test that passes anyway would be worse than none. Verified in Chromium on the built bundle instead, together with the cursor and the plate.

Left out on purpose

The analysis found 19 hand-rolled icon buttons, not 10. These five are the ones where the box does not change, so only colour and hover move. The rest each have a reason to wait: ReactionBar's add button is a bordered pill matching its badge siblings; MmSourceBreadcrumb's overflow trigger is inline text with no box; ImageLightbox sits on a black backdrop where neutral-transparent inverts to near-black; the two preview modals have 36px neighbours that neither size step matches; BubbleMenuButton serves two variants from one element.

Verification

vue-tsc, eslint (0 errors), vitest 691 in 94 files, vite build, Playwright e2e 66 passed / 3 skipped, pre-commit run --all-files. Cursor, hover plate and box size measured in Chromium against the built bundle.

Replaces five hand-rolled icon buttons and one hand-rolled spinner with the NLDD components, then fixes the three regressions that only showed up in the running app. ## The swaps | Where | Was | Now | |---|---|---| | `CardRow` quick actions + "Open in Mattermost", both variants (5 sites) | 24x24 hand-rolled `<button>` / `<a>` | `nldd-icon-button size="xs" variant="neutral-transparent"` | | `@`-mention loading row | `border-top-color` + `@keyframes` rotate | `nldd-activity-indicator size="16"` | The geometry is a dead-on match at `xs`: 24x24 box, 16px icon, 4px radius, all three unchanged. ## The three regressions, and what they taught **The hover plate came back.** `neutral-transparent` hard-codes a transparent hover background, so the quick actions lost their grey chip while `BubbleMenuButton` beside them kept one. The value is a literal in the shadow stylesheet, so no variant and no `--semantics-buttons-*` token reaches it. But NLDD sets no background on the *host*, and a transparent inner control lets the host's own background through - so this is public CSS on our own element, not the private `--_is-hovered-background-color`. Measured back to `oklch(0.923 0.009 257.4)` light and `oklch(0.348 0.02 257.4)` dark, identical to what `BubbleMenuButton` paints. **`display: flex` on the host fixes three things at once.** `:host` is `inline-block`, so it built a 27px line box around a 24px control. That overhang produced a strip of the card's pointer cursor under every button, a 1.5px misalignment against `BubbleMenuButton`, and would have made the hover plate 3px too tall. The hosts measure 24x24 again. **The cursor.** Dragging along the row flipped arrow, hand, arrow, hand. Two causes: the card's own pointer leaking through the 2px gaps and the 3px overhang, and the `href` variant taking `--semantics-controls-link-cursor`, which `base.css:99` sets to `pointer`. Waggle's convention (`base.css:94`) is that buttons keep the arrow and the pointer is for links; an icon button is a button whatever element it renders. Scoping the token on the group settles all four at `default` and leaves every real text link alone. Measured: all four `default`, all four 24x24. **A skipped tab stop.** `useCardKeyboardNav`'s `FOCUSABLE_SELECTOR` only knew light-DOM elements, so none of the three hosts counted and the jump to the next section fired one control early - "Open in Mattermost" was unreachable by Tab. ## What has no test, and why The tab-stop fix has no unit test. `getFocusableWithin` filters on `offsetParent`, which happy-dom does not populate, so a custom element that is not already the active element is filtered out before any assertion can see it. Writing a test that passes anyway would be worse than none. Verified in Chromium on the built bundle instead, together with the cursor and the plate. ## Left out on purpose The analysis found 19 hand-rolled icon buttons, not 10. These five are the ones where the box does not change, so only colour and hover move. The rest each have a reason to wait: `ReactionBar`'s add button is a bordered pill matching its badge siblings; `MmSourceBreadcrumb`'s overflow trigger is inline text with no box; `ImageLightbox` sits on a black backdrop where `neutral-transparent` inverts to near-black; the two preview modals have 36px neighbours that neither size step matches; `BubbleMenuButton` serves two variants from one element. ## Verification `vue-tsc`, eslint (0 errors), vitest 691 in 94 files, `vite build`, Playwright e2e 66 passed / 3 skipped, `pre-commit run --all-files`. Cursor, hover plate and box size measured in Chromium against the built bundle.
The @-mention dropdown drew its own spinner: a bordered circle with
border-top-color and a keyframes rotate, plus a prefers-reduced-motion override.
nldd-activity-indicator draws itself and handles reduced motion in its own
stylesheet, so 23 lines of CSS become 4 - only the placement is ours.

The element is built imperatively by the Tiptap suggestion renderer rather than
by a Vue template, so this is document.createElement rather than a tag in
markup. main.ts imports the design-system barrel at boot, so the custom element
is defined by the time the dropdown mounts.

Nothing checks this: vue-tsc does not look inside a .ts file's createElement,
and no vitest or Playwright spec touches the loading row. It needs an eye on
the real dropdown, in the moment between typing @ and the candidates arriving.
The five sites where the geometry is a dead-on match: the quick actions and the
"Open in Mattermost" link, in both CardRow variants. All were 24x24 with a 16px
icon and a 4px radius, which is exactly nldd-icon-button size="xs". 38 lines of
CSS go.

Two things change that are worth seeing rather than reading:

The resting colour darkens from --semantics-content-secondary-color (#556273)
to NLDD's --semantics-buttons-neutral-transparent-content-color (#20252B). That
is 1.61x the step Waggle itself uses to mean "secondary", in the same direction,
so in light mode the icons end up darker than the body text beside them. The
hierarchy inverts: they read quieter than the text today and louder afterwards.

The hover plate disappears. neutral-transparent hard-codes a transparent hover
background, so instead of a filled grey chip there is only a colour step. This
is the one that cannot be undone through a public token - the value is a literal
in the shadow stylesheet, not a token reference.

Both are reversible if unwanted: scoping
--semantics-buttons-neutral-transparent-content-color back to
--semantics-content-secondary-color restores the resting colour with NLDD's
geometry and focus ring intact. The hover plate needs the component's private
--_is-hovered-background-color, which is not a supported API.

Left out deliberately, each for its own reason: ReactionBar's add button is a
bordered pill matching its badge siblings; MmSourceBreadcrumb's overflow trigger
is an inline text affordance with no box; ImageLightbox sits on a black backdrop
where neutral-transparent inverts to near-black; the two preview modals have
36px neighbours that neither size step matches; BubbleMenuButton serves two
variants from one element and needs a template fork first.
Give the swapped icon buttons their hover plate and cursor back
Some checks failed
security-scan / JS SCA (npm audit) (pull_request) Successful in 41s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 25s
CI / e2e (pull_request) Failing after 4m51s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 59s
CI / pre-commit (pull_request) Successful in 55s
CI / release-scripts (pull_request) Successful in 9s
security-scan / Python SAST (bandit) (pull_request) Successful in 41s
security-scan / SBOM (trivy) (pull_request) Successful in 16s
CI / frontend-test (pull_request) Successful in 5m31s
test-build / build (backend) (pull_request) Successful in 2m17s
test-build / build (frontend) (pull_request) Successful in 2m46s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 15m27s
2b5fad9c6f
Three regressions from the previous commit, found by looking at the running app.

The hover plate. neutral-transparent hard-codes a transparent hover background,
so the swap left the quick actions with only a colour step while BubbleMenuButton
beside them still painted a plate. The value is a literal in the shadow
stylesheet, so no variant and no --semantics-buttons-* token reaches it - but
NLDD sets no background on the host, and a transparent inner control lets the
host's own background through. Measured back to oklch(0.923 0.009 257.4) in
light and oklch(0.348 0.02 257.4) in dark, identical to what BubbleMenuButton
paints and to the token main's removed rules used.

display:flex on the host comes first: :host is inline-block, so it built a 27px
line box around a 24px control. That 3px overhang also produced a strip of the
card's pointer cursor under every button and a 1.5px misalignment against
BubbleMenuButton. All three go with the one rule; the hosts measure 24x24 again.

The cursor. Dragging along the row flipped arrow, hand, arrow, hand: the card's
own pointer leaked through the 2px gaps and the 3px overhang, and the href
variant takes --semantics-controls-link-cursor, which base.css sets to pointer.
Waggle's convention (base.css:94) is that buttons keep the arrow and the pointer
is for links; an icon button is a button whatever element it renders. Scoping
the token on the group settles all four at default and leaves every real text
link alone.

The skipped tab stop. useCardKeyboardNav's FOCUSABLE_SELECTOR only knew
light-DOM elements, so none of the three hosts counted and the jump to the next
section fired one control early - "Open in Mattermost" was unreachable by Tab.

No unit test for that last one: getFocusableWithin filters on offsetParent, which
happy-dom does not populate, so a custom element that is not already the active
element is filtered out before the assertion can see it. Verified in Chromium on
the built bundle instead, together with the cursor and the plate.
Match the bubble-up icon to its swapped siblings
All checks were successful
CI / release-scripts (pull_request) Successful in 10s
security-scan / JS SCA (npm audit) (pull_request) Successful in 21s
security-scan / SBOM (trivy) (pull_request) Successful in 12s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 17s
security-scan / Python SAST (bandit) (pull_request) Successful in 26s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 53s
CI / frontend-test (pull_request) Successful in 1m43s
CI / pre-commit (pull_request) Successful in 2m2s
test-build / build (backend) (pull_request) Successful in 1m52s
test-build / build (frontend) (pull_request) Successful in 1m53s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 3m47s
CI / e2e (pull_request) Successful in 6m13s
1abf0badf8
The swap moved three of the four controls in the card action row onto NLDD's
neutral-transparent content colour, and left BubbleMenuButton on
--semantics-content-secondary-color. It read as the one faded icon in the row.

Pointing it at the same two tokens the component uses keeps them matching by
construction rather than by coincidence: if NLDD moves the value, all four move
together. Its radius follows the same way.

Measured on the built bundle: all four icons now resolve to the same colour.
robbertbos force-pushed style/nldd-icon-buttons from 1abf0badf8
All checks were successful
CI / release-scripts (pull_request) Successful in 10s
security-scan / JS SCA (npm audit) (pull_request) Successful in 21s
security-scan / SBOM (trivy) (pull_request) Successful in 12s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 17s
security-scan / Python SAST (bandit) (pull_request) Successful in 26s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 53s
CI / frontend-test (pull_request) Successful in 1m43s
CI / pre-commit (pull_request) Successful in 2m2s
test-build / build (backend) (pull_request) Successful in 1m52s
test-build / build (frontend) (pull_request) Successful in 1m53s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 3m47s
CI / e2e (pull_request) Successful in 6m13s
to 317b391985
All checks were successful
CI / release-scripts (pull_request) Successful in 10s
security-scan / SBOM (trivy) (pull_request) Successful in 13s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 16s
security-scan / JS SCA (npm audit) (pull_request) Successful in 20s
security-scan / Python SAST (bandit) (pull_request) Successful in 28s
CI / pre-commit (pull_request) Successful in 35s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 46s
test-build / build (backend) (pull_request) Successful in 1m18s
test-build / build (frontend) (pull_request) Successful in 1m19s
CI / frontend-test (pull_request) Successful in 1m28s
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 2m48s
CI / e2e (pull_request) Successful in 4m42s
2026-08-06 18:20:20 +00:00
Compare
Author
Owner

Closing: this is being handled elsewhere.

For whoever picks it up, what was in here and what it cost to find:

  • Five hand-rolled icon buttons in CardRow (quick actions plus the Mattermost link, both variants) to nldd-icon-button size="xs", and the @-mention loading spinner to nldd-activity-indicator.
  • Three regressions the swap introduced, each only visible in the running app: the hover plate disappears (neutral-transparent hard-codes a transparent hover background, so the plate has to be painted on the host - public CSS, not the private --_is-hovered-background-color); the cursor flips along the row (the card's pointer leaks through the 2px gaps and through a 3px overhang from :host { display: inline-block }, fixed with display: flex on the host); and useCardKeyboardNav's FOCUSABLE_SELECTOR only knew light-DOM elements, so Tab skipped "Open in Mattermost".
  • BubbleMenuButton had to move to the same two --semantics-buttons-neutral-transparent-* tokens or it stayed visibly lighter than its swapped neighbours.

Worth knowing: none of that is unit-testable here. getFocusableWithin filters on offsetParent, which happy-dom does not populate, so a custom element that is not already the active element is filtered out before any assertion can see it. It was verified by measuring in Chromium on the built bundle.

Closing: this is being handled elsewhere. For whoever picks it up, what was in here and what it cost to find: - Five hand-rolled icon buttons in `CardRow` (quick actions plus the Mattermost link, both variants) to `nldd-icon-button size="xs"`, and the @-mention loading spinner to `nldd-activity-indicator`. - Three regressions the swap introduced, each only visible in the running app: the hover plate disappears (`neutral-transparent` hard-codes a transparent hover background, so the plate has to be painted on the host - public CSS, not the private `--_is-hovered-background-color`); the cursor flips along the row (the card's pointer leaks through the 2px gaps and through a 3px overhang from `:host { display: inline-block }`, fixed with `display: flex` on the host); and `useCardKeyboardNav`'s `FOCUSABLE_SELECTOR` only knew light-DOM elements, so Tab skipped "Open in Mattermost". - `BubbleMenuButton` had to move to the same two `--semantics-buttons-neutral-transparent-*` tokens or it stayed visibly lighter than its swapped neighbours. Worth knowing: none of that is unit-testable here. `getFocusableWithin` filters on `offsetParent`, which happy-dom does not populate, so a custom element that is not already the active element is filtered out before any assertion can see it. It was verified by measuring in Chromium on the built bundle.
robbertbos closed this pull request 2026-08-06 18:24:10 +00:00
Author
Owner

Opgegaan in #245, samen met #239 en #242. De commits zijn ongewijzigd overgenomen; #245 legt er de icoon-semantiek bovenop, die anders met #242 in conflict kwam.

Opgegaan in #245, samen met #239 en #242. De commits zijn ongewijzigd overgenomen; #245 legt er de icoon-semantiek bovenop, die anders met #242 in conflict kwam.
All checks were successful
CI / release-scripts (pull_request) Successful in 10s
Required
Details
security-scan / SBOM (trivy) (pull_request) Successful in 13s
Required
Details
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 16s
Required
Details
security-scan / JS SCA (npm audit) (pull_request) Successful in 20s
Required
Details
security-scan / Python SAST (bandit) (pull_request) Successful in 28s
Required
Details
CI / pre-commit (pull_request) Successful in 35s
Required
Details
security-scan / Python SCA (pip-audit) (pull_request) Successful in 46s
Required
Details
test-build / build (backend) (pull_request) Successful in 1m18s
test-build / build (frontend) (pull_request) Successful in 1m19s
CI / frontend-test (pull_request) Successful in 1m28s
Required
Details
test-build / build (pull_request) Successful in 0s
CI / backend-test (pull_request) Successful in 2m48s
Required
Details
CI / e2e (pull_request) Successful in 4m42s
Required
Details

Pull request closed

Sign in to join this conversation.
No reviewers
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
robbertbos/waggle!236
No description provided.