NLDD icon buttons, and an icon per concept that exists and means it #245

Merged
robbertbos merged 7 commits from style/nldd-icon-alignment into main 2026-08-06 20:04:36 +00:00
Owner

Supersedes #236, #239 and #242, and closes #171. Those three converted
hand-rolled controls to nldd-icon-button; this branch adds the layer they do
not touch - the glyph inside the button, and a test that notices when a glyph
name stops existing. They land together because #242 rewrites the exact lines
the icon work changes.

The buttons (commits 1-6, unchanged from #236/#239/#242)

Where Was Now
CardRow quick actions + "Open in Mattermost" (5 sites) 24x24 hand-rolled <button>/<a> nldd-icon-button size="xs" variant="neutral-transparent"
@-mention loading row border-top-color + @keyframes nldd-activity-indicator size="16"
Card-detail modal source link 24x24 hand-rolled, own hover + colour nldd-icon-button, md like the close button beside it
Outgoing-post row actions, in QueuedRow and ScheduledStrip 32x28 (not square) / 26x26 nldd-icon-button size="sm", 32x32 in both

Three regressions surfaced only in the running app and are fixed in place: the
hover plate (neutral-transparent hard-codes a transparent hover background, so
the plate has to come from the host), display: flex on the host (:host is
inline-block and built a 27px line box around a 24px control, which leaked the
card's pointer cursor and misaligned it 1.5px), and a skipped tab stop
(useCardKeyboardNav's selector only knew light-DOM elements, so "Open in
Mattermost" was unreachable by Tab).

The glyphs (commit 7)

<nldd-icon> resolves a name through the registry or the alias table and
renders nothing when it hits neither: no warning, no throw, an empty box. The
icon field is typed string, so typecheck cannot see it either, and NLDD
retires and renames icons inside 0.8.x patch releases.

An audit of every icon name in src/ against the 311 registry names and the
alias table found exactly one dead name - and it sat on the case #171 is about:

  • video: 'media' in AttachmentPreview. NLDD retired the bare name when the
    media-transport set gained its media- prefix, so every video attachment
    card has rendered an empty 40x40 box
    . Now play, which is what the card's
    own hint already says ("… afspelen"). The map moves to the plain <script>
    block as ATTACHMENT_ICON so the test can read it.

Three more names carried a meaning NLDD has since assigned elsewhere:

  • "Verzend nu" drew clock-arrow-counter-clockwise, aliased history. The
    action skips the wait → media-forward-end, one of the icons #171 asked
    about.
  • Backlog drew inbox, which TRACK_ICON.inbox already owns; the two sit in
    one card menu ("Naar backlog" above "Verplaats naar inbox"). Now check-list,
    NLDD's own backlog alias. rectangle-stack looked better on paper (it
    collides with nothing, while check-list is also the composer's task-list
    button) but measured at 16px it is the same lidded box as file-box, two
    section headers below. A collision you can see beats one you cannot; the
    composer button and the section headers never share a screen.
  • Retry drew arrow-2-counter-clockwise, aliased sync. Now
    arrow-clockwise (refresh).

image becomes photo: unreachable in practice (images go to the thumbnail
row, never to the file-card grid), but a text-document glyph for an image is
wrong in the map.

No custom video controls. #171 suggested them; the modal keeps
<video controls>. Hand-built transport controls mean re-implementing the
keyboard, scrubbing and screen-reader behaviour the browser already gets right,
and NLDD's own guidance is to lean on native HTML.

The point of the change

iconNames.test.ts fails on a name that is in neither the registry nor the
alias table. It covers the six icon maps and every literal name in a template,
and asserts the registry parse itself looks sane so a broken regex cannot make
the whole thing vacuous. Same reasoning as nlddTokens.test.ts, for the other
silent failure mode: a dead var() and a dead icon name both fail without a
word anywhere.

Proven to fail, not just to pass: injecting backlog: 'media' and
name="chevron-left-teeny" makes it report both, with file names.

Verification

vue-tsc, eslint (0 errors), vitest 700 in 96 files, vite build. No backend file
is in the diff. Looked at in the running app: the section headers, a video
attachment card, the outbox rows and the card quick actions.

Supersedes #236, #239 and #242, and closes #171. Those three converted hand-rolled controls to `nldd-icon-button`; this branch adds the layer they do not touch - the glyph *inside* the button, and a test that notices when a glyph name stops existing. They land together because #242 rewrites the exact lines the icon work changes. ## The buttons (commits 1-6, unchanged from #236/#239/#242) | Where | Was | Now | |---|---|---| | CardRow quick actions + "Open in Mattermost" (5 sites) | 24x24 hand-rolled `<button>`/`<a>` | `nldd-icon-button size="xs" variant="neutral-transparent"` | | @-mention loading row | `border-top-color` + `@keyframes` | `nldd-activity-indicator size="16"` | | Card-detail modal source link | 24x24 hand-rolled, own hover + colour | `nldd-icon-button`, md like the close button beside it | | Outgoing-post row actions, in QueuedRow *and* ScheduledStrip | 32x28 (not square) / 26x26 | `nldd-icon-button size="sm"`, 32x32 in both | Three regressions surfaced only in the running app and are fixed in place: the hover plate (neutral-transparent hard-codes a transparent hover background, so the plate has to come from the host), `display: flex` on the host (`:host` is inline-block and built a 27px line box around a 24px control, which leaked the card's pointer cursor and misaligned it 1.5px), and a skipped tab stop (`useCardKeyboardNav`'s selector only knew light-DOM elements, so "Open in Mattermost" was unreachable by Tab). ## The glyphs (commit 7) `<nldd-icon>` resolves a name through the registry or the alias table and renders nothing when it hits neither: no warning, no throw, an empty box. The `icon` field is typed `string`, so typecheck cannot see it either, and NLDD retires and renames icons inside 0.8.x patch releases. An audit of every icon name in `src/` against the 311 registry names and the alias table found exactly one dead name - and it sat on the case #171 is about: - **`video: 'media'`** in AttachmentPreview. NLDD retired the bare name when the media-transport set gained its `media-` prefix, so **every video attachment card has rendered an empty 40x40 box**. Now `play`, which is what the card's own hint already says ("… afspelen"). The map moves to the plain `<script>` block as `ATTACHMENT_ICON` so the test can read it. Three more names carried a meaning NLDD has since assigned elsewhere: - **"Verzend nu"** drew `clock-arrow-counter-clockwise`, aliased `history`. The action skips the wait → `media-forward-end`, one of the icons #171 asked about. - **Backlog** drew `inbox`, which `TRACK_ICON.inbox` already owns; the two sit in one card menu ("Naar backlog" above "Verplaats naar inbox"). Now `check-list`, NLDD's own `backlog` alias. `rectangle-stack` looked better on paper (it collides with nothing, while `check-list` is also the composer's task-list button) but measured at 16px it is the same lidded box as `file-box`, two section headers below. A collision you can see beats one you cannot; the composer button and the section headers never share a screen. - **Retry** drew `arrow-2-counter-clockwise`, aliased `sync`. Now `arrow-clockwise` (`refresh`). `image` becomes `photo`: unreachable in practice (images go to the thumbnail row, never to the file-card grid), but a text-document glyph for an image is wrong in the map. **No custom video controls.** #171 suggested them; the modal keeps `<video controls>`. Hand-built transport controls mean re-implementing the keyboard, scrubbing and screen-reader behaviour the browser already gets right, and NLDD's own guidance is to lean on native HTML. ## The point of the change `iconNames.test.ts` fails on a name that is in neither the registry nor the alias table. It covers the six icon maps and every literal name in a template, and asserts the registry parse itself looks sane so a broken regex cannot make the whole thing vacuous. Same reasoning as `nlddTokens.test.ts`, for the other silent failure mode: a dead `var()` and a dead icon name both fail without a word anywhere. Proven to fail, not just to pass: injecting `backlog: 'media'` and `name="chevron-left-teeny"` makes it report both, with file names. ## Verification vue-tsc, eslint (0 errors), vitest 700 in 96 files, vite build. No backend file is in the diff. Looked at in the running app: the section headers, a video attachment card, the outbox rows and the card quick actions.
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.
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.
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.
The modal header held two controls at very different sizes: Modal.vue's close
button is an nldd-icon-button with no size, so NLDD's md default (44x44, 24px
icon), while the "Open in Mattermost" link beside it was hand-rolled at 24x24
with a 16px icon. It was also the only one of the two with a hover plate, and
the only one taking the link cursor.

The component supplies all of it, so 15 lines of CSS go with the swap. No size
attribute, matching the close button: both land on md, the header stays 60px,
and NLDD uses neutral-transparent on md for a panel dismissal itself.

:rel and :title fall away - the component sets rel="noopener noreferrer" under
target="_blank" and renders its own tooltip from the accessible label.
Bewerken, Verzend nu and Annuleren appear twice, in QueuedRow and in
ScheduledStrip, and the two had drifted to different sizes: 32x28 (not even
square, because a 16px icon plus 16px of padding exceeds the 28px min-width) and
26x26. Both are now nldd-icon-button size="sm", so 32x32 in both places, and the
rows grow accordingly. 22 lines of dead CSS go from ScheduledStrip; QueuedRow
keeps .btn for the two labelled buttons, which are an nldd-button case.

Two things the mechanical swap would have got wrong.

The danger hover. .btnDanger:hover turned the trash icon red, and a document
rule cannot reach into a shadow root. critical-transparent would fix that but
paints the trash red at rest, which it never was. Scoping the component's own
--semantics-buttons-neutral-transparent-is-hovered-content-color on the host
keeps today's behaviour with a public token.

The tests. Every selector in QueuedRow.test.ts was button[title=...] or
button[aria-label=...], and NLDD sets neither on the host - so two tests failed
loudly and the ones asserting .toBe(false) would have gone green while matching
nothing. They now target nldd-icon-button[accessible-label^=...] through one
helper, with a comment saying why.

display:flex on the hosts for the same reason as in CardRow: :host is
inline-block and rendered the buttons 32x33. Measured 32x32 on both surfaces.
Give each concept an icon that exists and means it
All checks were successful
CI / release-scripts (pull_request) Successful in 7s
security-scan / SBOM (trivy) (pull_request) Successful in 9s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 14s
security-scan / JS SCA (npm audit) (pull_request) Successful in 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 21s
CI / pre-commit (pull_request) Successful in 27s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 39s
test-build / build (frontend) (pull_request) Successful in 55s
test-build / build (backend) (pull_request) Successful in 57s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m7s
CI / backend-test (pull_request) Successful in 1m34s
CI / e2e (pull_request) Successful in 3m54s
850785f695
<nldd-icon> resolves a name through the registry or the alias table and
renders nothing when it hits neither - no warning, no throw, an empty box.
The `icon` field is typed `string`, so typecheck cannot see it either.

AttachmentPreview mapped video to `media`, which NLDD retired when the
media-transport set gained its `media-` prefix. Every video attachment card
has shown an empty 40x40 box since. It is `play` now, which is also what the
card's own hint says ("... afspelen"), and the map moves to the plain
<script> block as ATTACHMENT_ICON so a test can read it.

Three more names carried a meaning NLDD has since assigned elsewhere:

- "Verzend nu" drew clock-arrow-counter-clockwise, aliased `history`. The
  action skips the wait, so media-forward-end.
- Backlog drew `inbox`, which TRACK_ICON.inbox already owns; the two sit in
  one card menu. Now check-list, NLDD's own `backlog` alias. rectangle-stack
  looked like the better pick on paper - it collides with nothing, while
  check-list is also the composer's task-list button - but measured at 16px
  it is the same lidded box as file-box, which sits two section headers
  below it. A collision you can see beats one you cannot.
- Retry drew arrow-2-counter-clockwise, aliased `sync`. Now arrow-clockwise
  (`refresh`).

`image` becomes `photo` - unreachable in practice, images never reach the
file-card grid, but a text-document glyph for an image is wrong in the map.

iconNames.test.ts is the point of the change: it fails on a name that is in
neither the registry nor the alias table, covering the icon maps and every
literal name in a template. Verified against injected bad names in both
places. Same reasoning as nlddTokens.test.ts, for the other silent failure.
robbertbos deleted branch style/nldd-icon-alignment 2026-08-06 20:04:36 +00:00
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!245
No description provided.