🔖 v0.28.1 — Icon-Only Badges & Badge Contrast
A focused pass over BadgeComponent. A badge given an icon and no label now renders as a round icon badge instead of a pill with an empty tail; the automatic black-or-white text colour is retuned so light themes like yellow no longer fail contrast; and the label finally sits where the eye expects it, centred on its cap height rather than riding high. Alongside the component work, the get_turbo_patterns MCP tool documents how to open a remote dialog from a Turbo Stream — and the autoclose trap that makes the naive version navigate away.
What's New
Icon-only badges
Pass an icon and leave the label empty and the badge now recognises there is nothing for the icon to sit beside. The icon chip's styling exists to separate it from a label — it hugs the left edge with extra padding there and squares off its right edge so the label can butt up against it — so with no label that geometry read as a lopsided blob. A badge in this state picks up .is-icon-only, squares its padding back up, rounds the chip fully, and skips the empty .value span entirely:
<%# Round icon badge — no label, no empty tail %><%= midwest_badge(nil, state: :success) do |badge| %> <% badge.with_icon { midwest_icon(:check) } %><% end %>The label default is 'Badge', so this is opt-in: you pass nil or a blank string to get it. "Icon only" means genuinely alone — an icon alongside support:, an action slot, or removable: true keeps the pill geometry, because in each of those cases there is something to separate from. A blank-but-present label (' ') counts as labelless too.
Badges that are labelless but not icon-only — a bare support chip with a dismiss button, say — get a matching fix: with no padded .value holding the trailing button off the chip beside it, .is-labelless restores that gap directly.
The playground's label falls back to
Badgewhen blank, so it always renders the pill form — pick an icon there to see the chip geometry the icon-only rule squares up, and compare against the snippet above.
What Changed
Automatic badge contrast now clears AA
Badge text colour is derived from the badge's own background with a single oklch() expression — a lightness threshold that flips the text between black and white. That threshold sat at 0.65, which put two theme colours on the wrong side of it: the yellow icon chip kept white text, and so did a green pill, both failing AA. The theme ramp clusters tightly either side of a narrow window, and 0.57 is the one value that clears both — yellow flips to black, green keeps white and passes.
The icon chip gets its own decision rather than inheriting the pill's. The chip is a shade darker than the badge around it (theme-9 against theme-7), so a threshold evaluated against the pill left a black icon sitting on a dark chip wherever the pill was light enough to want black text. Each surface now resolves independently against its own background.
Badge text is vertically centred
Badge copy is always uppercase, so the band that should be optically centred is cap-top to baseline — not the font's full ascent-to-descent box, which carries asymmetric slack for diacritics above and descenders below and left the text visibly high. The label and support spans now use text-box: trim-both cap alphabetic to trim to exactly that band.
The previous attempt at this (text-box-trim: trim-both) was a silent no-op: text-box-trim only applies to block containers, and those spans carried display: flex. They are blocks now, with the half-leading the trim removes added back as real padding through a --badge-leading custom property, so pill heights are unchanged. The whole thing is behind an @supports check — where text-box is unsupported nothing is trimmed and --badge-leading stays 0px, so the old rendering is the fallback.
Support chip height and icon sizing
The support chip's h-full resolved against the badge's auto height, fell back to auto, and left the chip visibly shorter than the icon chip beside it. It uses flex self-stretch now, which fills the line's cross size properly. This applies to row layouts only — is-stacked turns the badge into a column, where stretching would shunt the chip sideways instead.
The icon chip also shrinks from 1.5em to 1.15em, a better match for the label beside it. If you were relying on the larger icon, size the badge up rather than the icon.
get_turbo_patterns documents stream-opened dialogs
The MCP tool's remote_dialog pattern gains an open_from_stream note covering the case a link click doesn't handle: opening a remote dialog from a server response. Render midwest_dialog(only_content: true) and turbo_stream.update the dialog's frame with it — the remoteContent target connecting calls show() for you.
It also gains a dialog_autoclose_navigation gotcha, which is the trap sitting underneath that pattern. autoclose (on by default) wires turbo:submit-end → hide onto the dialog content wrapper, so any form inside a midwest_dialog triggers it. hide then visits fetchResponse.location.href on success — and for a non-redirect 2xx (a 200 Turbo Stream that opens another dialog or re-renders in place) that location is the POST URL itself, so the page navigates there via GET and usually 404s. The fix is either returning 422 for in-place re-renders, so detail.success is false and hide early-returns, or setting autoclose: false on that dialog and driving the close yourself.
What Was Removed
Nothing was removed. No options, slots, or helpers were dropped, and there are no API changes — icon_only? and labelless? are derived from the label you already pass, so a badge that renders today renders the same after upgrading.
Two rendering changes are visible without any code change on your side, both intentional: badge text colour shifts on the light end of the theme ramp (that is the contrast fix), and the icon chip is smaller. A badge built with an icon and a deliberately blank label will also switch from pill to round.
Dev Process
- Roadmap — no roadmap items changed state. The v0.25–v0.27 milestones are all complete and none of this release's badge work was a scoped item, so like v0.28.0 it lands as net-new polish. The roadmap now carries a short note recording the v0.28.x line as post-milestone work so the page reflects what has actually shipped.
- Tests — five new
BadgeComponentcases covering the icon-only decision: an icon with no label, a blank-string label, no-icon and with-label negatives, and the cases wheresupport:orremovable:keeps the pill. The full badge suite passes green across Rails 7, Rails 8, Bridgetown v1, and Bridgetown v2. - Repo — a
.flyover.jsonproject-extraction artifact was committed alongside the badge work; it is repo tooling metadata and ships in neither the gem nor the npm package. - Version bumps — the gem, the
@unabridged/midwestnpm package, and the@unabridged/midwest-language-serverLSP package are all bumped to0.28.1.