Skip to content

fix(fuselage): align Button with the design system - #2151

Draft
josephjpearce wants to merge 13 commits into
mainfrom
feat/button-figma-parity
Draft

fix(fuselage): align Button with the design system#2151
josephjpearce wants to merge 13 commits into
mainfrom
feat/button-figma-parity

Conversation

@josephjpearce

@josephjpearce josephjpearce commented Aug 10, 2026

Copy link
Copy Markdown

fix(fuselage): align Button with the design system

Closes DSN-182.

Audits Button against the design system Component section, fixes the deviations, and adds a story documenting how a leading icon pairs with a label across sizes.

Four commits, reviewable independently:

  1. Metrics and icons — insets, leading-icon padding, icon sizing, icon-only centring, icon/label alignment, external-link affordance
  2. Disabled secondary-danger background — a mis-mapped palette entry
  3. Token-derived insets — removes the pixel literals introduced by (1)
  4. Icon and label story — plus two argTypes doc corrections

Method

Every number below was read off the rendered DOM (getComputedStyle + bounding boxes), not from source. The inset figures are total edge-to-content distances — border plus padding — because that is what is comparable to a Figma frame with no stroke.

Colour expectations come from @rocket.chat/fuselage-tokens, which is the design-token export of the Figma library; the correspondence was spot-checked directly in Figma on primary (default/hover/press/keyfocus), secondary (default) and secondary-danger (default/disabled).


1. The underlying spacing bug

A button's rendered inset is the sum of its border and its padding. The size mixin subtracted a hard-coded 2px on the assumption of a 2px border:

padding-inline: calc(lengths.padding($padding-x) - 2px);

--rcx-button-border-width is 1px, so every button sat 1px tight on every edge — horizontally and vertically, the vertical shortfall pushing the label 1px off-centre inside the fixed height. The mixin now takes the total inset and subtracts the actual border width, so the value in the stylesheet is the value you measure.

Spacing audit — every supported size

Insets are start / end / block. "Canvas" is the icon box, not the glyph.

Size Height Label-only inset Icon+label inset Canvas Gap min-width Icon-only box Figma
mini 40 14 / 14 / 10 12 / 14 16 4 80 20×20 ⚠️ no spec
tiny 40 14 / 14 / 10 12 / 14 16 4 80 24×24 ⚠️ no spec
small 28 8 / 8 / 6 8 / 10 16 4 56 28×28 ✅ match
medium 32 14 / 14 / 8 12 / 14 16 4 64 32×32 ✅ match
default 40 14 / 14 / 10 12 / 14 20 4 80 40×40 ✅ match
large 48 24 / 24 / 14 22 / 24 20 4 96 40×40 ⚠️ no spec

Before → after

Metric Before After Figma
Default inline / block inset 15 / 9 14 / 10 14 / 10
Medium inline / block inset 11 / 7 14 / 8 14 / 8
Small inline / block inset 7 / 5 8 / 6 8 / 6
Default leading-icon start / end 15 / 15 12 / 14 12 / 14
Medium leading-icon start / end 11 / 11 12 / 14 12 / 14
Small leading-icon start / end 7 / 7 8 / 10 8 / 10
Default & Large canvas 16 20 20
Icon-only canvas offset from centre 2px left 0 0
is="a" external leading icon none new-window new-window

Icon-only surrounds now match exactly: 6/6/6/6 at 28, 8/8/8/8 at 32, 10/10/10/10 at 40. The 2px offset came from marginInlineEnd={4} being applied even with no label to separate from; it is now applied only when a label is present. Being a constant 2px, it read worst on the smallest button.

Icon/label vertical alignment

Icon defaults to vertical-align: text-bottom, which drags the shared baseline down and pushes the label below centre. Scaling the default canvas to 20px made this visible; it was latent at the smaller sizes. Label offset from centre, positive = below:

main Mid-change Now Text-only reference
Small + icon 0 +0.5 −0.5 −0.5
Medium + icon 0 +0.5 −0.5 −0.5
Default + icon −0.5 +1.5 −0.5 −0.5

The residual −0.5 is present on text-only buttons on main too — font ascent/descent asymmetry within the line box, left alone.

Token provenance — one deviation

All spacing now derives from lengths.padding() and lengths.border-width(); no pixel literal is written by hand. But two design values are not expressible on the 4px scalelengths.padding() accepts 1, 2, or a multiple of 4:

Design value Composed as Where
14px padding(16) - padding(2) default & medium inline inset, label-side icon inset
22px padding(24) - padding(2) large icon-side inset

10px (padding(8) + padding(2)) is in the same category. These compose from scale steps rather than being literals, but they are off-scale values. The cleaner fix is a design decision — either move the inline inset to 12 or 16, or add the step to the scale. Flagging rather than silently absorbing it.

Block insets need no such treatment: they derive from height and line-height, both already on the scale.


2. Colour parity — state × variant

loading is included: it sets disabled, so its colours are identical to disabled in every case (verified, not assumed). Icon colour inherits the label colour in all 48 cells. Border colour equals background in every non-focus state.

Variant State Code Token Match
primary default / hover / active #156FF5 / #095AD2 / #10529E same
focus / disabled / loading #156FF5 / #D1EBFE / #D1EBFE same
label, all states #FFFFFF same
secondary default / hover / active #E4E7EA / #CBCED1 / #9EA2A8 same
focus / disabled / loading #E4E7EA / #EBECEF / #EBECEF same
label / label disabled #1F2329 / #CBCED1 same
danger default / hover / active #EC0D2A / #D40C26 / #BB0B21 same
focus / disabled / loading #EC0D2A / #FFC1C9 / #FFC1C9 same
label, all states #FFFFFF same
secondary-danger default / hover / active #E4E7EA / #CBCED1 / #9EA2A8 same
focus #E4E7EA same
disabled / loading #EBECEF #EBECEF fixed — was #FFC1C9
label / label disabled #BB0B21 / #F98F9D same ✅ ¹
success default / hover / active #148660 / #106D4F / #0D5940 same
focus / disabled / loading #148660 / #C0F6E4 / #C0F6E4 same
warning all states #FFD95A / #FFD031 / #F3BE08 / #FFECAD no token ⚠️ ²
secondary-warning all states #E4E7EA / #CBCED1 / #9EA2A8 / #EBECEF no token ⚠️ ²
secondary-success all states #E4E7EA / #CBCED1 / #9EA2A8 / #EBECEF no token ⚠️ ²

No colour mismatches remain. Every variant/state with a design token matches the rendered value exactly.

¹ Value correct, but the token key is onSecondaryDangerDisabled where every sibling is fontOn*. The emitted CSS property is right (--rcx-color-button-font-on-secondary-danger-disabled, matching Figma's Button/Font/on-secondary-danger-disabled). Cosmetic — but it is what made the mis-mapping in §3 easy to miss.

² warning, secondary-warning and secondary-success are driven by primitives, not semantic button tokens, and have no Figma counterpart at all. success (filled) does have tokens. Out of scope to invent values; raised as a design-system gap.

Focus ring

Every variant: 1px #2F343D border (stroke-extra-dark) plus a 2px outer ring — structurally matching Figma, which draws a 2px Stroke/extra-light-highlight ring outside a Stroke/extra-dark border.

Variant family Ring Assessment
primary, secondary #D1EBFE
danger, secondary-danger #FFC1C9 ✅ variant-tinted
warning, success families #CBCED1 ⚠️ neutral grey — no focus-shadow-color token, so it falls back

--rcx-color-shadow-highlight (used in code) and --rcx-color-stroke-extra-light-highlight (Figma's name) both resolve to #D1EBFE — naming difference only, no visual difference. Worth reconciling in the token export.

Token names, code vs Figma

Code Figma Note
button-background-{v}-focus Button/Background/{V}/keyfocus same value, different word
--rcx-color-shadow-highlight Stroke/extra-light-highlight both #D1EBFE
button-font-on-secondary-danger-disabled Button/Font/on-secondary-danger-disabled

3. Disabled secondary-danger background

getPalette mapped it to the filled danger token:

  {
    name: 'button-background-secondary-danger-disabled',
-   color: button.backgroundDangerDisabled,          // #FFC1C9
+   color: button.backgroundSecondaryDangerDisabled, // #EBECEF
  },

Every sibling in that list uses backgroundSecondaryDanger*, and the SCSS fallback chain already resolved to #EBECEF — the :root custom property was the only thing overriding it. A disabled secondary danger button rendered as a pink filled button instead of a greyed-out neutral one.

Confirmed in Figma: Secondary / Disabled / Danger Action = True uses Button/Background/Secondary danger/disabled with Button/Font/on-secondary-danger-disabled.


4. Icon and label story

New story under Inputs / Button, covering the four rectangular sizes — small, medium, default (unset), large — side by side with:

  • the icon canvas outlined, so its scaling per size is observable rather than inferred
  • the 4px gap tinted (scoped to --with-icon, so it does not draw where no label follows)
  • doc text stating the rule: canvas scales with the label's line-height, gap is a constant 4px and does not scale, icon-side inset is one 4px step tighter than the label side

It breaks visibly if canvas or gap drift.

mini and tiny are deliberately absent from Storybook entirely — both the control and this story. They type-check as size values but have no rectangular styles, and presenting a broken option as a working choice is worse than leaving it out of the UI. Their measurements are retained in the spacing table above, which is where the case for fixing or removing them belongs.

Deviation from the ticket, recorded rather than glossed: DSN-182 scope 3 asks for every supported size to appear in the Storybook comparison. mini and tiny do not. That is a deliberate call — full coverage is kept in the audit table instead.

Also corrects two argTypes:

  • size now documents the unset default (40px) and offers small / medium / large only.
  • square was described as replacing a "pill shape"; buttons are 4px-radius rectangles.

Before / after screenshots

Attached in the first comment. Every visual claim above is also backed by measured numbers, so the review stands without them.
matrix-before
matrix-after

Change Before After
Default — label only before/label-undefined.png after/label-undefined.png
Medium — label only before/label-medium.png after/label-medium.png
Small — label only before/label-small.png after/label-small.png
Default — leading icon before/icon-undefined.png after/icon-undefined.png
Medium — leading icon before/icon-medium.png after/icon-medium.png
Small — leading icon before/icon-small.png after/icon-small.png
Icon only (40) before/square-undefined.png after/square-undefined.png
Icon only (32) before/square-medium.png after/square-medium.png
Icon only (28) before/square-small.png after/square-small.png
External link before/external.png after/external.png
secondary-danger disabled before/sd-disabled.png after/sd-disabled.png
secondary disabled (reference, unchanged) before/s-disabled.png after/s-disabled.png
danger disabled (reference, unchanged) before/d-disabled.png after/d-disabled.png
Whole matrix matrix-before.png matrix-after.png

Intentionally out of scope

  • mini / tiny rectangular styles. They accept a size but only have square definitions, so they inherit the default box while taking a 16px canvas — internally inconsistent with default. Figma has no spec for them, so there is nothing to implement against. Documented in the spacing table rather than guessed at, and kept out of Storybook so nothing advertises them as working. This is the one acceptance criterion in the ticket not satisfied — "no size silently inherits values from another" still fails for these two. It needs a decision: give them real rectangular styles, or drop them from ButtonProps['size'] as a breaking change. IconButton is unaffected — tiny/mini are square by nature there, the styles exist, and they are used in SidebarBanner, SidebarV2 and ToastBar.
  • large (48px). No Figma counterpart. Its inline inset is set to the token value (24px), moving the rendered inset 23px → 24px.
  • size="large" square renders 40px, not 48px — pre-existing, left alone.
  • warning / success families. No Figma counterpart and no semantic tokens; see §2 note ².
  • Visual-regression snapshots. Committed without a platform suffix and generated on ubuntu-latest; regenerating on macOS would rewrite every file with font-rasterisation noise. All inputs-button--* and inputs-buttongroup--* snapshots need regenerating in CI, plus any story embedding a default-size Button (toastbar, codesnippet, throbber, card, callout), plus a new snapshot for the story added here.

Test plan

  • yarn tsc -p tsconfig.build.json --noEmit — clean
  • yarn eslint src/components/Button — clean
  • yarn stylelint src/components/Button/Button.styles.scss — clean
  • yarn jest src/components/Button src/components/PaletteStyleTag — 9 passed
  • Spacing, canvas, gap, centring and resolved colours measured in-browser for every size and every variant × state above
  • Token refactor verified non-regressive — insets identical at every size before and after

Joseph RC and others added 2 commits August 10, 2026 15:52
The button's rendered inset is the sum of its border and padding. The size
mixin subtracted a hard-coded 2px on the assumption of a 2px border, but
`--rcx-button-border-width` is 1px, so every button sat 1px tighter than
specified — horizontally and vertically.

The mixin now takes the *total* inset and subtracts the actual border width,
and the per-size values are taken from the design system:

- default 14px inline / 10px block, medium 14px / 8px, small 8px / 6px
- a leading icon tightens the inset on the icon side (12/14, 12/14, 8/10)
- the 40px and 48px sizes pair with a 20px icon; smaller sizes stay at 16px
- `is="a"` + `external` gets a `new-window` leading icon unless one is supplied

Visual-regression snapshots are generated on Linux and will need regenerating
in CI; they are deliberately not updated here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ound

`getPalette` mapped `button-background-secondary-danger-disabled` to
`button.backgroundDangerDisabled` (#FFC1C9 — the filled danger disabled
background) rather than `button.backgroundSecondaryDangerDisabled` (#EBECEF).
Every sibling entry in the same list uses `backgroundSecondaryDanger*`, and the
SCSS fallback chain already expected #EBECEF, so the `:root` custom property was
the only thing overriding it.

A disabled secondary danger button therefore rendered as a pink filled button
instead of a greyed-out neutral one. Confirmed against the design system, where
the variant uses Button/Background/Secondary danger/disabled with
Button/Font/on-secondary-danger-disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5668c6a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@rocket.chat/fuselage Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@CLAassistant

CLAassistant commented Aug 10, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
0 out of 2 committers have signed the CLA.

❌ Joseph RC
❌ josephjpearce


Joseph RC seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

The previous commit expressed the per-size insets as raw pixel literals, which
regressed the token-first rule. They are now composed from `lengths.padding()`
and the border-width length, so no pixel value is written by hand.

Two of the values the design system specifies — the 14px inline inset and the
22px leading-icon inset — are not directly expressible on the 4px scale
(`lengths.padding()` accepts 1, 2, or a multiple of 4). They are built as
`padding(16) - padding(2)` and `padding(24) - padding(2)` and flagged in the PR
description; the cleaner fix is for the design to land on a scale value.

Rendered output is unchanged — verified identical insets at every size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@josephjpearce
josephjpearce force-pushed the feat/button-figma-parity branch from 35acfd1 to 10c2e3e Compare August 10, 2026 21:48
Adds a story comparing the same icon + label pairing across the four rectangular
sizes, with the icon canvas outlined and the 4px gap tinted so both are
observable rather than inferred. Doc text states the intended rule: the canvas
scales with the label's line-height, the gap does not, and the icon-side inset
is one 4px step tighter than the label side.

`mini` and `tiny` are left out of Storybook entirely, both here and in the size
control. They type-check as `size` values but have no rectangular styles, so
they silently fall back to the default box with a mismatched 16px canvas;
offering them would present a broken option as a working choice. Their
measurements are recorded in the PR's spacing audit instead, which is where the
case for fixing or removing them belongs. `IconButton` is untouched — tiny and
mini are square by nature there and work correctly.

Also corrects two argTypes: `size` did not document the unset default, and
`square` was described as replacing a "pill shape" when buttons are 4px-radius
rectangles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@josephjpearce
josephjpearce force-pushed the feat/button-figma-parity branch from 10c2e3e to 53f047c Compare August 11, 2026 15:21
@juliajforesti juliajforesti changed the title fix(fuselage): align Button with the design system fix(fuselage): align Button with the design system Aug 19, 2026

@juliajforesti juliajforesti left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code comments should not be that verbose - I suggested changes removing references to previous code, PR description, and narrative justification, keeping only the non-obvious info needed to understand the following logic. Comments should not be a history tracker - PRs description are.

All relevant and necessary comments (for both dev and design team) can be added to the storybook description

Comment thread .changeset/button-figma-parity.md Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this patch changeset. We can rely on the minor changeset message here since the PR only has a few changes, that are already covered by the minor description.
Please delete this file

Comment on lines +54 to +56
// `mini` and `tiny` are valid `size` values but have no rectangular
// styles, so they silently fall back to the default box. They are left
// out of Storybook entirely rather than presented as a working choice.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for this comment.
If you need to clarify why those variations are not stated in this story, you can add to the story description so it's visible in the UI, not just in the code.

Suggested change
// `mini` and `tiny` are valid `size` values but have no rectangular
// styles, so they silently fall back to the default box. They are left
// out of Storybook entirely rather than presented as a working choice.

Comment thread packages/fuselage/src/components/Button/Button.stories.tsx Outdated
Comment on lines +368 to +436
<table style={{ borderCollapse: 'collapse' }}>
<thead>
<tr>
{['Size', 'Icon + label', 'Label only', 'Icon only'].map((h) => (
<th
key={h}
style={{
padding: '8px 16px',
textAlign: 'left',
font: '700 12px/16px Inter, sans-serif',
color: '#6C727A',
borderBottom: '1px solid #E4E7EA',
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{SIZE_ROWS.map(({ label, size }) => (
<tr key={label}>
<td
style={{
padding: '12px 16px',
font: '700 12px/16px Inter, sans-serif',
color: '#6C727A',
borderBottom: '1px solid #E4E7EA',
}}
>
{label}
</td>
<td
style={{
padding: '12px 16px',
borderBottom: '1px solid #E4E7EA',
}}
>
<Button variant='primary' size={size} icon='baloon-text'>
Button
</Button>
</td>
<td
style={{
padding: '12px 16px',
borderBottom: '1px solid #E4E7EA',
}}
>
<Button variant='primary' size={size}>
Button
</Button>
</td>
<td
style={{
padding: '12px 16px',
borderBottom: '1px solid #E4E7EA',
}}
>
<Button
variant='primary'
size={size}
square
icon='baloon-text'
/>
</td>
</tr>
))}
</tbody>
</table>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use PropsVariationSection to build the table variations

Comment thread packages/fuselage/src/components/Button/Button.styles.scss Outdated
Comment thread packages/fuselage/src/components/Button/Button.styles.scss Outdated
padding-block: calc(
(#{lengths.size($height)} - #{$line-height}) / 2 - #{$border-width}
);
padding-inline: calc(#{$inline-inset} - #{$border-width});

@juliajforesti juliajforesti Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for #{} here too

}

@mixin with-leading-icon-inset($start, $end) {
padding-inline: calc(#{$start} - #{$border-width})

@juliajforesti juliajforesti Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for #{} here too

(large && 'large') ||
undefined;

const effectiveIcon = resolveIcon(icon, is, external);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the motivation and this results in a more concise and drift proof devExp - but i'm wondering whether we shouldn't have an opt-out (e.g. allow icon={false}) ?
maybe there are cases where there's no space for icon + text, idk...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that makes sense to allow that.

josephjpearce and others added 9 commits August 19, 2026 16:30
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants