Files
sure/app/components/DS/pill.rb
Guillem Arias Fauste d88d6e9e58 fix(ds): canonical transaction-row — stop category pills truncating (#2147)
* fix(ds): canonical transaction-row — stop category pills truncating (#2137)

The desktop transaction grid gave the name column col-span-8 (67%, usually
half-empty) and the category column only col-span-2 (17%), so the category
pill's name area was clamped to ~44px and ellipsized nearly every value
("Misc...", "Shop...", "Rest...") despite the empty space the audit flagged.

- Rebalance the lg:grid-cols-12 row: name col-span-8 -> 7, category 2 -> 3
  (amount unchanged; still 12). Category gains 50% width from the over-wide
  name column.
- categories/_badge: the pill was `flex w-full` (stretched to fill the column);
  make it `inline-flex max-w-full` so it hugs its content and short names render
  fully, capping + truncating only when genuinely too long.

Verified on /transactions: visible-row category truncation dropped 7/7 -> 2/7
even in the compressed (AI-panel-open) view; Payment / Shopping / Restaurants
now render in full. Fixes both the interactive categories/menu and the static
transfer categories/badge (menu reuses the badge partial).

* feat(ds): lift categories/_badge onto DS::Pill

Addresses the Drift Patrol finding (and jjmata's request) properly
instead of patching the bespoke span: the badge becomes a DS::Pill in
badge mode. The pill primitive grows the three capabilities the badge
needed and the bot's one-liner glossed over:

- truncate: pills that may shrink inside min-w-0 columns drop their
  shrink-0/whitespace-nowrap and ellipsize the label instead of
  overflowing (the transaction-row category cell this PR fixes).
- label_testid: stamps data-testid on the label span; five test files
  target [data-testid='category-name'].
- icon_size: passthrough to the icon helper (badge keeps its
  established sm glyph; default stays xs).

custom_color was already the sanctioned escape hatch for user-chosen
hues. Owned visual deltas from pill standardization: px-1.5/py-1 ->
px-2/py-0.5, gap-1 -> gap-1.5, border mix 10% -> 20%; bg mix, hex text,
radius, text scale, icon size, truncation and testid are parity.

Label wrapper only renders when truncate/label_testid ask for it, so
existing pill DOM (and the find("span", text:) assertions on it) is
unchanged. Four new pill tests + a Lookbook case cover the recipe.
2026-06-06 16:28:22 +02:00

194 lines
9.9 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class DS::Pill < DesignSystemComponent
TONES = %i[violet indigo fuchsia amber green gray red].freeze
STYLES = %i[soft filled outline].freeze
SIZES = %i[sm md].freeze
# Semantic-name → visual-tone aliases. Lets callers say
# `tone: :success` instead of binding to the underlying palette name.
# The aliases live here (not on the caller) so the visual palette can
# be retuned without touching every callsite.
SEMANTIC_TONE_ALIASES = {
success: :green,
warning: :amber,
error: :red,
destructive: :red,
info: :indigo,
neutral: :gray
}.freeze
attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker, :custom_color,
:truncate, :label_testid, :icon_size
# Generic inline pill primitive. Two modes:
#
# - `marker: true` (default) — the original shape from #1829: uppercase
# 10/11px text, tracking-wide. Reads as a stage marker (Beta, Canary,
# NEW, PRO, EXPERIMENTAL, …).
#
# - `marker: false` — normal case, snaps to the DS text scale
# (`text-xs` / `text-sm`). Reads as a status / category badge.
# Pair with semantic tones (`:success`, `:warning`, `:error`,
# `:info`, `:neutral`) for status badges; pair with visual tones
# (`:violet`, `:indigo`, etc.) for category tags.
#
# Other options:
#
# - `show_dot:` defaults per mode. Stage markers (`marker: true`) keep
# their dot; status / category badges (`marker: false`) are clean by
# default — the pill shape + tone + label already carry the signal, so
# a leading dot is usually redundant and noisy in dense lists. Pass
# `show_dot: true` to opt a badge back in where the dot is genuinely
# additive: live / temporal status ("Syncing", "Active"), or a single
# sparse pill where the dot anchors it as a discrete element.
# - `dot_only: true` renders only the colored dot (no label, no border).
# Use on the collapsed sidebar nav, where there's no room for the label.
# - `icon:` overrides the dot with a Lucide icon (sized xs, current color).
# Useful for status pills that benefit from a glyph (circle-check,
# triangle-alert, pause, etc.) rather than the generic dot.
# - Tones accept both visual names (`:violet`, `:amber`, …) and
# semantic aliases (`:success`, `:warning`, `:error`,
# `:destructive`, `:neutral`, `:info`). Aliases resolve via
# `SEMANTIC_TONE_ALIASES`.
# - Sure has full violet / indigo / fuchsia / amber / green / gray /
# red ramps in the design system; this component picks named tokens
# at render time. No raw hex — except `custom_color:`, which exists for
# user-defined entities (categories, tags) whose hue is data, not design.
# - `truncate: true` lets the pill shrink inside a `min-w-0` parent and
# ellipsize its label instead of overflowing (dense table cells like the
# transaction row's category column). Default pills stay `shrink-0`.
# - `label_testid:` stamps `data-testid` on the label span for system /
# controller tests that need to target the text node.
# - `icon_size:` passes through to the icon helper (default "xs"; the
# category badge uses "sm" to keep its established glyph size).
def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: nil, dot_only: false, title: nil, icon: nil, marker: true, custom_color: nil,
truncate: false, label_testid: nil, icon_size: "xs")
resolved_tone = SEMANTIC_TONE_ALIASES.fetch(tone.to_sym, tone.to_sym)
@label = label || I18n.t("ds.pill.default_label", default: "Beta")
@tone = TONES.include?(resolved_tone) ? resolved_tone : :violet
@style = STYLES.include?(style.to_sym) ? style.to_sym : :soft
@size = SIZES.include?(size.to_sym) ? size.to_sym : :sm
# Default per mode: markers keep their dot, badges are dot-less. An
# explicit show_dot: true/false always wins.
@show_dot = show_dot.nil? ? marker : show_dot
@dot_only = dot_only
@title = title
@icon = icon
@marker = marker
@custom_color = custom_color
@truncate = truncate
@label_testid = label_testid
@icon_size = icon_size
end
def palette
# Light-mode `text` is mixed 30% with black on top of the 700 stop so
# the 1011px uppercase label still reads against the very pale 50
# background. Without the mix the perceptual contrast feels low even
# though the raw ratio passes WCAG.
{
violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "color-mix(in oklab, var(--color-violet-700), black 30%)", text_dark: "var(--color-violet-200)", border: "var(--color-violet-200)", dot: "var(--color-violet-500)", fill: "var(--color-violet-500)" },
indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "color-mix(in oklab, var(--color-indigo-700), black 30%)", text_dark: "var(--color-indigo-200)", border: "var(--color-indigo-200)", dot: "var(--color-indigo-500)", fill: "var(--color-indigo-500)" },
fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "color-mix(in oklab, var(--color-fuchsia-700), black 30%)", text_dark: "var(--color-fuchsia-200)", border: "var(--color-fuchsia-200)", dot: "var(--color-fuchsia-500)", fill: "var(--color-fuchsia-500)" },
amber: { bg: "var(--color-yellow-50)", bg_dark: "var(--color-yellow-tint-10)", text: "color-mix(in oklab, var(--color-yellow-700), black 30%)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" },
green: { bg: "var(--color-green-50)", bg_dark: "var(--color-green-tint-10)", text: "color-mix(in oklab, var(--color-green-700), black 30%)", text_dark: "var(--color-green-200)", border: "var(--color-green-200)", dot: "var(--color-green-500)", fill: "var(--color-green-500)" },
gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "color-mix(in oklab, var(--color-gray-700), black 30%)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" },
red: { bg: "var(--color-red-50)", bg_dark: "var(--color-red-tint-10)", text: "color-mix(in oklab, var(--color-red-700), black 30%)", text_dark: "var(--color-red-200)", border: "var(--color-red-200)", dot: "var(--color-red-500)", fill: "var(--color-red-500)" }
}[tone]
end
def dot_size_px
size == :md ? 6 : 5
end
def container_styles
return custom_color_styles if custom_color.present?
p = palette
case style
when :filled
# Filled = solid / high-emphasis. The tone-500 fill fails white-label AA on
# the brighter tones (amber 2.4:1, green 2.6:1, red 4.0:1) and glares on dark
# surfaces. Deepen to tone-700 — every `fill` is a `*-500`, so derive -700 —
# so the white label clears AA on every tone in both themes.
strong_fill = p[:fill].sub("-500)", "-700)")
<<~CSS.strip.gsub(/\s+/, " ")
background-color: #{strong_fill};
color: var(--color-white);
border-color: transparent;
CSS
when :outline
<<~CSS.strip.gsub(/\s+/, " ")
background-color: transparent;
color: light-dark(#{p[:text]}, #{p[:text_dark]});
border-color: light-dark(#{p[:border]}, color-mix(in oklab, #{p[:dot]} 40%, transparent));
CSS
else # :soft
<<~CSS.strip.gsub(/\s+/, " ")
background-color: light-dark(#{p[:bg]}, #{p[:bg_dark]});
color: light-dark(#{p[:text]}, #{p[:text_dark]});
border-color: light-dark(#{p[:border]}, color-mix(in oklab, #{p[:dot]} 20%, transparent));
CSS
end
end
def dot_color
return custom_color if custom_color.present?
style == :filled ? "rgba(255,255,255,0.85)" : palette[:dot]
end
def custom_color_styles
case style
when :filled
<<~CSS.strip.gsub(/\s+/, " ")
background-color: #{custom_color};
color: var(--color-white);
border-color: transparent;
CSS
when :outline
<<~CSS.strip.gsub(/\s+/, " ")
background-color: transparent;
color: #{custom_color};
border-color: color-mix(in oklab, #{custom_color} 40%, transparent);
CSS
else
<<~CSS.strip.gsub(/\s+/, " ")
background-color: color-mix(in oklab, #{custom_color} 10%, transparent);
color: #{custom_color};
border-color: color-mix(in oklab, #{custom_color} 20%, transparent);
CSS
end
end
def container_classes
base = [
"inline-flex items-center align-middle font-medium",
# Truncating pills must be allowed to shrink (and let the label span
# ellipsize); everything else keeps its intrinsic width.
truncate ? "max-w-full min-w-0" : "whitespace-nowrap shrink-0",
"border leading-none"
]
if marker
# Marker mode (Beta / Canary / NEW): rounded-md (slight chip
# shape), uppercase, sub-12px text, wider tracking.
# text-[10/11px] stays as arbitrary values — the pill is
# intentionally sub-12px (Sure's smallest scale token is text-xs
# / 12px) so it reads as a marker, not a label. Padding / gap /
# tracking snap to Tailwind's scale to satisfy the design-system
# "no arbitrary values" rule.
base << "rounded-md uppercase"
base << (size == :md ? "px-2 py-0.5 text-[11px] tracking-wide gap-1" : "px-1.5 py-0.5 text-[10px] tracking-wider gap-1")
else
# Badge mode (Pending / Active / Past due / category tag):
# rounded-full pill shape (matches the existing convention used
# by `settings/providers/_status_pill`, `_maturity_badge`, and
# the inline transaction badges). Normal case, snaps to the
# design-system text scale (`text-xs` / `text-sm`).
base << "rounded-full"
base << (size == :md ? "px-2 py-0.5 text-sm gap-1.5" : "px-1.5 py-0.5 text-xs gap-1")
end
class_names(*base)
end
end