Files
sure/app/components/DS/pill.rb
Guillem Arias Fauste 5abf9cb537 fix(ds): dark-mode token parity — contrast & state fixes (#2139)
* fix(ds): dark-mode token parity — text, borders, checkbox, toggle, over-budget badge

Dark-mode contrast/parity pass (design-review epic #2134, follow-up to #1736):

- text-subdued (dark): gray-500 -> gray-400; faint/eyebrow text ~3.6:1 -> ~6.7:1
  (clears AA), staying below text-secondary so hierarchy holds.
- border-primary/secondary/subdued (dark): +1 alpha-white step each; restores
  group-card edges and in-card row hairlines (border-primary 1.86:1 -> 2.8:1).
  Alpha keeps them surface-relative across any dark bg.
- Dark checkbox: unchecked was a solid white square (read as already-selected) —
  now a transparent outlined box; checked/indeterminate use a white fill with a
  #171717 glyph (was #808080, ~2:1); added an explicit indeterminate dash;
  disabled muted to gray-700.
- Toggle (light off-state): track gray-100 -> gray-300 plus a thumb shadow; the
  white-thumb-on-white-track invisible off-state now reads (dark off-track was
  already hardened to gray-700).
- Over-budget badge: text-red-500 -> text-destructive (theme-aware, matching the
  on-track/near-limit siblings); in-situ dark contrast 4.18:1 -> 4.55:1 (AA).
- Correct invalid icon color keys (red/yellow/green -> destructive/warning/success)
  on the three budget status badges.

Verified in-browser, light+dark: isolated checkbox states, /accounts card borders,
/reports muted text, toggle off-state, /budgets over-budget badge (in-situ 4.55:1).

* fix(ds): reconcile destructive color + fix filled-pill contrast

Continues the dark-parity pass (#2134):

- Reconcile destructive: border-destructive and button-bg-destructive were
  red-500 while the destructive text/icon token was red-600. Unify on
  red-600 (light) / red-400 (dark) across text, border, and button — the text
  token can't drop to red-500 (3.96:1 on white, fails AA), so border/button
  move up instead. White-on-destructive-button 3.96:1 -> 4.36:1 (AA-large);
  hover red-600 -> red-700.
- DS::Pill filled style: deepen the fill tone-500 -> tone-700. White label text
  on tone-500 failed AA on nearly every tone (amber 2.35:1, green 2.62:1,
  red 3.95:1); tone-700 clears it (amber 5.43, green 4.30, red 5.86, others
  6.4-12) in both themes and removes the dark-surface glare.

Date-input calendar glyph in dark verified already-correct (existing invert(1)
rules; color-scheme is normal, so no conflict) — no change needed.

Verified in-browser: real .button-bg-destructive (red-600) + filled pills,
all tones, light and dark.

* fix(ds): destructive button consumes the reconciled red-600

Follow-up to the destructive reconcile in this branch: DS::Buttonish's
destructive variant still used raw bg-red-500 / hover:bg-red-600, so
destructive *buttons* didn't match the reconciled destructive text/border
(red-600). Align to red-600 / hover red-700 (light); dark unchanged
(red-400 / red-500). White-on-red-600 = 4.37:1 (AA-large), consistent
with the rest of the destructive family.

* refactor(ds): tokenize budget-category badge + bar backgrounds

Status-badge foregrounds already used semantic tokens (text-destructive/
warning/success) but backgrounds + progress-bar fills stayed on the raw
palette (bg-red-500/10, bg-yellow-500, ...). Switch to the matching semantic
tokens (bg-destructive/10, bg-warning, bg-success) — same value in light, now
theme-aware in dark. Mirrors DS::Alert. Addresses CodeRabbit/Codex on #2139.
2026-06-03 12:02:50 +02:00

178 lines
8.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
# 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.
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)
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
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 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