293. A button that reads as a link
Date: 2026-09-12
Status
Accepted. Adds button.link — the fifth button variant.
Context
The catalogue has four button variants: secondary (the default), primary,
ghost and danger. All four are a fill plus the ink that fill carries.
There is a fifth thing a real application needs and none of them is: an action
that belongs in a sentence. “Take the tour.” “Read the licence.” “Show the six
that were filtered out.” A ghost button is the closest and it is not close — it
is a button-shaped hole with body-strong text in --gb-text, which reads as a
control that has lost its fill rather than as a link.
docs/core-widgets.md §2 already specifies a link, and it is a different
widget: text that navigates, a word inside a paragraph, href= or action=.
That one is unbuilt and this record does not build it — see below, because the
difference decides the one contentious question here.
Decision
button.link is a class, like the other four
button class="link" press="app.take-the-tour" "Take the tour"
Nothing new in Java. Button already carries no visual opinion — “the height,
the padding, the colours and the four variants are all in controls.css” — and
this is the fifth entry in that list. Semantically it is a button throughout: a
Tab stop, a focus ring, Space and Enter, Role.BUTTON.
Three declarations distinguish it, and each one is doing work:
--gb-button-link-text, not--gb-accent. See below; this is the part that was measured rather than chosen.--gb-weight-regular, where every other button is strong. §1.4 puts the strong weight on buttons because a button is chrome. This one is meant to sit in a sentence, and a bold word in a paragraph is emphasis, not a control.- 4px of horizontal padding, not 12. The 12 holds a fill off its label and there is no fill; a link indented like a button reads as a button somebody forgot to colour in.
The height stays --gb-button-height. §1.3’s ≥32 hit target is a floor
whatever the thing looks like, and a 20-pixel-tall click target in a card is the
failure that rule exists to prevent.
The ink is its own token, and the accent was rejected by measurement
--gb-accent was the obvious answer and it does not clear §1.2’s 4.5:1 as ink:
--gb-bg | --gb-surface | --gb-surface-2 | |
|---|---|---|---|
dark, --gb-accent #88c0d0 | 6.24 | 5.03 | 4.31 |
light, --gb-accent #5c7ea8 | 3.64 | 4.20 | 3.45 |
The light theme fails on every surface, and that is not a flaw in the accent — it is ADR-0088’s point restated. A light theme’s accent is chosen as a fill that carries white text; asking it to be ink on a white surface is the opposite job, and the two ramps parted company for exactly this reason.
So there is a token per theme, with the numbers written beside it:
- dark:
#a3d0dd— the accent one step lighter, an invented value on--gb-checkbox-border’s terms (ADR-0258): 7.51 / 6.05 / 5.19. - light:
var(--gb-accent-fill)— the two-steps-darker value ADR-0088 derived forbutton.primary, used here as ink rather than as a fill: 5.38 / 6.20 / 5.09. A fill dark enough to carry white text is dark enough to be read on white; the reuse is the Nord answer rather than a coincidence.
ContrastTest measures the ink on all three surfaces in both themes. It cannot
measure the variant itself — a transparent fill has no contrast ratio, which is
button.ghost’s situation and the reason that one is excluded from the sweep.
There is no underline, and that is the contentious part
Every link in every browser is underlined, and WCAG 1.4.1 is the reason: colour alone must not be the distinguishing feature.
That rule is about a link inside a block of text, where position says nothing
and colour is all there is. button.link is not that. It is a standalone control
in the tab order, with a focus ring, carrying three signals at once — no fill
where its neighbours have one, a different colour, and a different weight. The
widget that is a word inside a sentence is §2’s link, and for that one the
underline is not optional.
Two further things point the same way:
- §2.1 says hover changes the surface and never the text colour. So the hover
affordance is
ghost’s overlay wash, and there is no “underline on hover” to reach for without contradicting a rule one section up. - §8’s CSS subset has no
text-decoration. Drawing one would mean either a new property threaded fromComputedStylethroughBox.Textinto the paragraph painter, or a child box inButton’s Java — andButtonbuilds its content as boxes with no widget children, so the second means teaching Java which class it is wearing, which is the thing this widget is careful not to do.
So the underline is filed with §2’s link, where it is load-bearing, and
text-decoration lands for that widget’s sake rather than for this one’s.
Consequences
- Two golden images moved —
button-variants-darkandbutton-variants-light— and they are now five buttons wide. The light one is where the two themes visibly disagree about this variant: the ink differs where every other variant’s fill does. - The showcase’s Basic screen has a buttons card, in
basic.kdl, showing all five variants, both icon forms and a disabled one — nine buttons and not one line of Java, which is the argument for appearance being a class. --gb-button-link-textis a new component token, so an application restyles every link button by overriding one name.- §2’s
linkis still unbuilt, anddocs/design-system.mdnow says so on its own row rather than leaving two rows that look like the same feature. - The disabled link button is legible and unremarkable, because
:disabledis a global rule on opacity rather than per variant.
Alternatives considered
--gb-accentas the ink. Rejected by the table above, on the light theme by a wide margin. Worth recording because it is the change somebody will propose as a simplification.- An underline drawn as a child box, the way
tabsdraws its indicator andtext-inputdraws a composition’s rule (ADR-0292). It works and it putsattributes.classes().contains("link")inButton.render— a widget reading its own class to decide what to draw, which is precisely the linecontrols.cssexists on the other side of. text-decoration: underlinein §8’s subset, now, for this. It is the right feature and the wrong reason: its consumer is §2’slink, and building a CSS property for a variant that does not need it would fix its shape around the wrong case.- A
linkwidget instead of a button variant, satisfying §2 directly. A bigger piece of work with an unbuilt CSS property under it, and it would not have answered the thing that prompted this — an action, in a card, that should not look like a button.