mirror of
https://github.com/we-promise/sure.git
synced 2026-06-06 11:19:02 +00:00
* 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.
178 lines
8.9 KiB
Ruby
178 lines
8.9 KiB
Ruby
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 10–11px 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
|