From 834ec19fdc456ba3d933a9d9173c4717a4a60353 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Thu, 21 May 2026 16:25:01 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(design-system):=20DS::Disclosure=20:ca?= =?UTF-8?q?rd=5Finset=20variant=20+=20migrate=20ibkr=5Fpanel=20+=20setting?= =?UTF-8?q?s/=5Fsection=20(#1715=20=C2=A76)=20(#1857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(design-system): add :card_inset variant + migrate ibkr_panel and settings/_section Wraps up the disclosure migration cluster from #1715 §6: 1. **New `:card_inset` variant** on `DS::Disclosure`. Same contract as `:card` but uses `bg-surface-inset rounded-xl p-4` (no shadow) for inset sub-panels embedded inside a parent card surface. 2. **Migrate `_ibkr_panel.html.erb`** — the "flex query details" disclosure (`
`) was the one panel skipped from #1856 because it used the inset surface. Now uses `DS::Disclosure(variant: :card_inset)`. Chevron gets the `motion-safe:transition-transform motion-safe:duration-150` treatment along the way. 3. **Migrate `settings/_section.html.erb`** — the global "collapsible settings card" primitive backing 19 callsites via the `settings_section(...)` helper. The collapsible branch's `
` becomes `DS::Disclosure(variant: :card, open: open, data: ...)`. While here: - Update `disclosure.html.erb` to spread `**opts` onto the `
` element via `tag.details`. Previously opts were captured but never applied; the `settings/_section` migration needs `data-controller` + `data-auto-open-param-value` to flow through to the rendered `
`. - Non-collapsible branch in `settings/_section.html.erb` stays as raw `
` — different semantics (not expandable), DS::Disclosure can't replace because it always renders `
`. API: DS::Disclosure.new( variant: :card | :card_inset | :default, open: bool, data: { controller: "...", ... } # forwarded to
) * fix(review): merge caller class in DS::Disclosure + i18n plaid deletion - DS::Disclosure: extract caller class: from opts and merge via class_names before forwarding to tag.details. Prevents the latent duplicate keyword arg error when callers pass class: alongside the variant-derived classes. - plaid_items/_plaid_item: localize "(deletion in progress...)" via t('.deletion_in_progress') + add en locale key, matching lunchflow / mercury / sophtron / coinstats convention. * fix(panels): replace text-white and bg-gray-tint-10 with semantic tokens `text-white` → `text-inverse` on the EnableBanking reauthorize button (`bg-warning` background); `bg-gray-tint-10` → `bg-container-inset` on the IndexaCapital item avatar wrapper. Both flagged by sure-design as non-functional palette tokens. Pre-existing on main; surfaced by the re-indentation that this PR applied during the disclosure migration. --- app/components/DS/disclosure.html.erb | 4 +- app/components/DS/disclosure.rb | 30 ++++++++--- .../_enable_banking_item.html.erb | 2 +- .../_indexa_capital_item.html.erb | 2 +- app/views/plaid_items/_plaid_item.html.erb | 2 +- app/views/settings/_section.html.erb | 52 ++++++++++--------- .../settings/providers/_ibkr_panel.html.erb | 10 ++-- config/locales/views/plaid_items/en.yml | 1 + 8 files changed, 63 insertions(+), 40 deletions(-) diff --git a/app/components/DS/disclosure.html.erb b/app/components/DS/disclosure.html.erb index 6923183b5..876dc054c 100644 --- a/app/components/DS/disclosure.html.erb +++ b/app/components/DS/disclosure.html.erb @@ -1,4 +1,4 @@ -
> +<%= tag.details class: details_classes, open: open, **details_opts do %> <%= tag.summary class: summary_classes do %> <% if summary_content? %> <%# `` is `display: list-item`, so a flex inner div would @@ -28,4 +28,4 @@
<%= content %>
-
+<% end %> diff --git a/app/components/DS/disclosure.rb b/app/components/DS/disclosure.rb index a634b2143..ad4bdb37e 100644 --- a/app/components/DS/disclosure.rb +++ b/app/components/DS/disclosure.rb @@ -1,7 +1,7 @@ class DS::Disclosure < DesignSystemComponent renders_one :summary_content - VARIANTS = %i[default card].freeze + VARIANTS = %i[default card card_inset].freeze attr_reader :title, :align, :open, :variant, :opts @@ -12,8 +12,15 @@ class DS::Disclosure < DesignSystemComponent # rounded-xl` card; the summary inherits the container (no own bg). # Use for provider-item rows (binance, lunchflow, plaid, etc.) where # each card is the surface and the summary is custom rich content. - # Callers in `:card` mode should pass their own `summary_content` - # slot; the built-in title rendering assumes the `:default` shape. + # + # `:card_inset` — `
` is `bg-surface-inset rounded-xl` (no + # shadow). Use for inset sub-panels inside a parent card surface + # (e.g. the IBKR flex-query "report details" panel embedded inside + # the IBKR settings flow). Same summary contract as `:card`. + # + # In both card variants, callers should pass their own + # `summary_content` slot; the built-in title rendering assumes the + # `:default` shape. def initialize(title: nil, align: "right", open: false, variant: :default, **opts) @title = title @align = align.to_sym @@ -25,18 +32,29 @@ class DS::Disclosure < DesignSystemComponent end def details_classes - case variant + base = case variant when :card "group bg-container p-4 shadow-border-xs rounded-xl" + when :card_inset + "group bg-surface-inset rounded-xl p-4" else "group" end + + class_names(base, opts[:class]) + end + + # `opts` minus the `:class` key, since `details_classes` merges that + # separately to avoid duplicate-keyword collisions when forwarding to + # `tag.details`. + def details_opts + opts.except(:class) end def summary_classes case variant - when :card - # Card variant: no bg on summary — the parent details *is* the + when :card, :card_inset + # Card variants: no bg on summary — the parent details *is* the # surface. Keep cursor + focus-visible ring + flex baseline. # Ring token matches `settings/provider_card.html.erb` (the # established focus pattern on container cards). diff --git a/app/views/enable_banking_items/_enable_banking_item.html.erb b/app/views/enable_banking_items/_enable_banking_item.html.erb index a63eaa478..2e3af279b 100644 --- a/app/views/enable_banking_items/_enable_banking_item.html.erb +++ b/app/views/enable_banking_items/_enable_banking_item.html.erb @@ -55,7 +55,7 @@ <% if enable_banking_item.requires_update? %> <%= button_to reauthorize_enable_banking_item_path(enable_banking_item), method: :post, - class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-white bg-warning hover:opacity-90 transition-colors", + class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg text-inverse bg-warning hover:opacity-90 transition-colors", data: { turbo: false } do %> <%= icon "refresh-cw", size: "sm" %> <%= t(".update") %> diff --git a/app/views/indexa_capital_items/_indexa_capital_item.html.erb b/app/views/indexa_capital_items/_indexa_capital_item.html.erb index e6535d76a..d03c2ffb1 100644 --- a/app/views/indexa_capital_items/_indexa_capital_item.html.erb +++ b/app/views/indexa_capital_items/_indexa_capital_item.html.erb @@ -9,7 +9,7 @@
<%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> -
+
<%= tag.p indexa_capital_item.name.first.upcase, class: "text-primary text-xs font-medium" %>
diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 96e621d64..36408d89e 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -21,7 +21,7 @@
<%= tag.p plaid_item.name, class: "font-medium text-primary" %> <% if plaid_item.scheduled_for_deletion? %> -

(deletion in progress...)

+

<%= t(".deletion_in_progress") %>

<% end %>
<% if plaid_item.syncing? %> diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index 0c05833e8..14ef45a61 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,35 +1,39 @@ <%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil, status: nil, meta: nil, actions: nil, badge: nil) %> <% if collapsible %> -
- class="group bg-container shadow-border-xs rounded-xl p-4" - <%= "data-controller=\"auto-open\" data-auto-open-param-value=\"#{h(auto_open_param)}\"".html_safe if auto_open_param.present? %>> - -
- <%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %> -
-
-

<%= title %>

- <%= badge if badge.present? %> + <%= render DS::Disclosure.new( + variant: :card, + open: open, + data: auto_open_param.present? ? { controller: "auto-open", auto_open_param_value: auto_open_param } : nil + ) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+ <%= icon "chevron-right", class: "text-secondary group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> +
+
+

<%= title %>

+ <%= badge if badge.present? %> +
+ <% if subtitle.present? %> +

<%= subtitle %>

+ <% end %>
- <% if subtitle.present? %> -

<%= subtitle %>

- <% end %>
+ <% if status.present? %> +
+ <% if meta.present? %> + <%= meta %> + <% end %> + <%= status %> + <%= actions if actions.present? %> +
+ <% end %>
- <% if status.present? %> -
- <% if meta.present? %> - <%= meta %> - <% end %> - <%= status %> - <%= actions if actions.present? %> -
- <% end %> -
+ <% end %>
<%= content %>
-
+ <% end %> <% else %>
diff --git a/app/views/settings/providers/_ibkr_panel.html.erb b/app/views/settings/providers/_ibkr_panel.html.erb index 578c6af34..7e9e686d9 100644 --- a/app/views/settings/providers/_ibkr_panel.html.erb +++ b/app/views/settings/providers/_ibkr_panel.html.erb @@ -13,17 +13,17 @@ t(".steps.step_5") ] %> -
- + <%= render DS::Disclosure.new(variant: :card_inset) do |disclosure| %> + <% disclosure.with_summary_content do %>

<%= t(".flex_query_details.eyebrow") %>

<%= t(".flex_query_details.title") %>

<%= t(".flex_query_details.summary") %>

- <%= icon "chevron-down", class: "mt-0.5 text-secondary transition-transform group-open:rotate-180" %> + <%= icon "chevron-down", class: "mt-0.5 text-secondary group-open:rotate-180 motion-safe:transition-transform motion-safe:duration-150" %>
-
+ <% end %>
@@ -89,7 +89,7 @@

<%= t(".report_window_note") %>

-
+ <% end %> <% ibkr_item = Current.family.ibkr_items.first_or_initialize(name: "Interactive Brokers") diff --git a/config/locales/views/plaid_items/en.yml b/config/locales/views/plaid_items/en.yml index 0dbe8ee51..25e87511c 100644 --- a/config/locales/views/plaid_items/en.yml +++ b/config/locales/views/plaid_items/en.yml @@ -15,6 +15,7 @@ en: connection_lost_description: This connection is no longer valid. You'll need to delete this connection and add it again to continue syncing data. delete: Delete + deletion_in_progress: (deletion in progress...) error: Error occurred while syncing data no_accounts_description: We could not load any accounts from this financial institution. From 8de14ed2a54b9940b626621d9b5ade0cb89b59c2 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Fri, 22 May 2026 02:14:44 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(design-system):=20DS::Disclosure=20:in?= =?UTF-8?q?line=20variant=20+=20migrate=20indexa=5Fcapital=20+=20snaptrade?= =?UTF-8?q?=20panels=20(#1715=20=C2=A76)=20(#1858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(design-system): add :inline variant + migrate indexa_capital + snaptrade panels Adds an `:inline` variant to `DS::Disclosure` for plain text-link-style toggles that have no surface, no padding, no shadow — the disclosure reads as a clickable summary text + revealed content, nothing more. Use case: "Alternative auth" form section toggle in the Indexa Capital provider panel; "Manage connections" lazy-loaded toggle in the Snaptrade provider panel. Both were the last raw-`
` callsites in `app/views/settings/providers/`. Migrations: - `_indexa_capital_panel.html.erb` — single inline `
` revealing username / document / password form fields under an "Alternative auth" summary text. - `_snaptrade_panel.html.erb` — lazy-load `
` with `data-controller="lazy-load"` etc. The new `tag.details ... **opts` forwarding from #1857 lets the Stimulus controller attrs flow through cleanly via DS::Disclosure's `data:` keyword. Chevron rotation on snaptrade gets the standard `motion-safe:transition-transform motion-safe:duration-150` treatment (was `transition-transform` without the motion-safe gate). Variant summary now: | Variant | Details surface | Use case | |---|---|---| | `:default` | none / bg-surface summary | inline expander inside parent card | | `:card` | `bg-container shadow-border-xs rounded-xl p-4` | provider rows, settings sections | | `:card_inset` | `bg-surface-inset rounded-xl p-4` | inset sub-panels | | `:inline` | no surface | text-link-style toggles | * fix(review): guard variant.to_sym against nil in DS::Disclosure CodeRabbit on #1858 flagged that `variant: nil` crashed with `NoMethodError` at `variant.to_sym` before the explicit `VARIANTS` check could run. Use safe navigation (`variant&.to_sym`) so nil falls through to the validation, and inspect `@variant` in the error message so nil / non-symbol inputs render readably. Verified manually via runner: `DS::Disclosure.new(variant: nil)` now raises `ArgumentError: Invalid variant: nil. Must be one of [:default, :card, :card_inset, :inline]`. --- app/components/DS/disclosure.rb | 23 +++++++--- .../providers/_indexa_capital_panel.html.erb | 12 ++--- .../providers/_snaptrade_panel.html.erb | 44 +++++++++++-------- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/app/components/DS/disclosure.rb b/app/components/DS/disclosure.rb index ad4bdb37e..5f993d6c1 100644 --- a/app/components/DS/disclosure.rb +++ b/app/components/DS/disclosure.rb @@ -1,12 +1,13 @@ class DS::Disclosure < DesignSystemComponent renders_one :summary_content - VARIANTS = %i[default card card_inset].freeze + VARIANTS = %i[default card card_inset inline].freeze attr_reader :title, :align, :open, :variant, :opts # `:default` — bg-surface summary, no chrome on the `
`. Use - # for inline expanders inside a parent card. + # for inline expanders that sit inside a parent card (the summary + # itself reads as the surface). # # `:card` — `
` itself becomes a `bg-container shadow-border-xs # rounded-xl` card; the summary inherits the container (no own bg). @@ -18,17 +19,23 @@ class DS::Disclosure < DesignSystemComponent # (e.g. the IBKR flex-query "report details" panel embedded inside # the IBKR settings flow). Same summary contract as `:card`. # - # In both card variants, callers should pass their own + # `:inline` — no surface, no padding, no shadow. The disclosure reads + # as a plain text-link-style toggle (e.g. "Alternative auth" inside + # a form, or a "Manage connections" lazy-load opener). Caller provides + # the summary text (and optional chevron) via the `summary_content` + # slot. + # + # In card / inline variants, callers should pass their own # `summary_content` slot; the built-in title rendering assumes the # `:default` shape. def initialize(title: nil, align: "right", open: false, variant: :default, **opts) @title = title @align = align.to_sym @open = open - @variant = variant.to_sym + @variant = variant&.to_sym @opts = opts - raise ArgumentError, "Invalid variant: #{@variant}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant) + raise ArgumentError, "Invalid variant: #{@variant.inspect}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant) end def details_classes @@ -59,6 +66,12 @@ class DS::Disclosure < DesignSystemComponent # Ring token matches `settings/provider_card.html.erb` (the # established focus pattern on container cards). "list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-xl" + when :inline + # Inline variant: no surface, no padding — the summary reads as + # plain text-link copy. Caller markup (text + optional chevron) + # provides the visual. Keep cursor + focus-visible ring + matching + # alpha-black-300 token used by the card variants for consistency. + "list-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300 rounded-sm" else "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-300" end diff --git a/app/views/settings/providers/_indexa_capital_panel.html.erb b/app/views/settings/providers/_indexa_capital_panel.html.erb index c751e0feb..255126e42 100644 --- a/app/views/settings/providers/_indexa_capital_panel.html.erb +++ b/app/views/settings/providers/_indexa_capital_panel.html.erb @@ -29,10 +29,12 @@ type: :password %>

<%= t("indexa_capital_items.panel.fields.api_token.description") %>

-
- - <%= t("indexa_capital_items.panel.alternative_auth") %> - + <%= render DS::Disclosure.new(variant: :inline) do |disclosure| %> + <% disclosure.with_summary_content do %> + + <%= t("indexa_capital_items.panel.alternative_auth") %> + + <% end %>
<%= form.text_field :username, label: t("indexa_capital_items.panel.fields.username.label"), @@ -49,7 +51,7 @@ placeholder: is_new_record ? t("indexa_capital_items.panel.fields.password.placeholder_new") : t("indexa_capital_items.panel.fields.password.placeholder_update"), type: :password %>
-
+ <% end %>
<%= form.submit is_new_record ? t("indexa_capital_items.panel.save_button") : t("indexa_capital_items.panel.update_button"), diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index ec877bdcd..1c814b234 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -57,25 +57,31 @@ <% if items&.any? && items.first.user_registered? %> <% item = items.first %>
-
- -
-

- <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> - <% if item.unlinked_accounts_count > 0 %> - (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) - <% end %> -

+ <%= render DS::Disclosure.new( + variant: :inline, + data: { + controller: "lazy-load", + action: "toggle->lazy-load#toggled", + lazy_load_url_value: connections_snaptrade_item_path(item), + lazy_load_auto_open_param_value: "manage" + } + ) do |disclosure| %> + <% disclosure.with_summary_content do %> +
+
+

+ <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> + <% if item.unlinked_accounts_count > 0 %> + (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) + <% end %> +

+
+ + <%= t("providers.snaptrade.manage_connections") %> + <%= icon "chevron-right", class: "w-3 h-3 group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %> +
- - <%= t("providers.snaptrade.manage_connections") %> - <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> - -
+ <% end %>
@@ -86,7 +92,7 @@
-
+ <% end %>
<% end %>
From 09058b0cc616245f650248fd1a4835ef40bab404 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Fri, 22 May 2026 02:16:33 +0200 Subject: [PATCH 3/5] feat(design-system): extend DS::Pill with badge mode + semantic tones (#1751 PR A) (#1902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(design-system): extend DS::Pill with badge mode + semantic tones (#1751) Adds two extensions to the existing `DS::Pill` (originally landed as a stage marker primitive in #1829) so it can also serve as the shared status / category badge across the app — the use case tracked by #1751. **Badge mode (`marker: false`)** The original `DS::Pill` was intentionally sub-12px (text-[10px] / text-[11px]) + uppercase + tracking-wide so it reads as a marker (`Beta`, `Canary`, `NEW`), not a label. That shape is wrong for status badges where the surrounding context is regular UI copy and the pill needs to feel like a chip (`Pending`, `Active`, `Past due`, `Failed`). The new `marker: false` flag drops the uppercase + arbitrary sub-12px text and snaps the chrome to the DS text scale: - `marker: false, size: :sm` → `text-xs` (12px), normal case - `marker: false, size: :md` → `text-sm` (14px), normal case - `marker: true` (default) → existing #1829 behavior, unchanged **Semantic tone aliases** Status badges read more naturally with semantic tone names than with the underlying palette colors: | Alias | Resolves to | |---|---| | `:success` | `:green` | | `:warning` | `:amber` | | `:error` / `:destructive` | `:red` (new tone, added here) | | `:info` | `:indigo` | | `:neutral` | `:gray` | Visual-name tones (`:violet`, `:indigo`, `:fuchsia`, `:amber`, `:green`, `:gray`, `:red`) still work as before — semantic aliases resolve through `SEMANTIC_TONE_ALIASES` at component init time, so the callsite can pick whichever name reads better. Unknown tones still fall back to `:violet` (existing behavior). **Red palette** Adds the `:red` tone (palette already present in `design/tokens/sure.tokens.json` — `red-50/100/200/500/700/tint-10`). Needed for `:error` / `:destructive` status badges. **Icon slot** Adds an `icon:` option (already documented in the component's doc-comment as planned). When set, the Lucide glyph replaces the colored dot inside the pill — useful for status badges that read better with a glyph (`circle-check`, `triangle-alert`, `loader`, etc.) than the generic dot. **Scope** API + tests + Lookbook preview only. No callsite migrations in this PR — that's the next slice of #1751, done as separate per-bucket PRs (transaction badges, provider badges, misc) to keep diffs small. DS::Pill currently has no in-app callsites (#1829 shipped the primitive ahead of consumers), so this is a pure-additive change. Existing API is fully backwards-compatible — `marker:` defaults to `true`, so without that flag the pill renders exactly as it does today. * fix(test): use assert_no_selector for dot-suppression assertion `refute_selector ..., count: 1` only fails when there are exactly 1 matches — it would silently pass for 0 OR 2+. The intent is "no dots should render when an icon is set"; `assert_no_selector` strictly asserts zero matches. Flagged by coderabbit on #1902. --- app/components/DS/pill.html.erb | 4 +- app/components/DS/pill.rb | 81 +++++++++++++++---- test/components/DS/pill_test.rb | 59 ++++++++++++++ .../previews/pill_component_preview.rb | 48 ++++++++++- 4 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 test/components/DS/pill_test.rb diff --git a/app/components/DS/pill.html.erb b/app/components/DS/pill.html.erb index 6712f0602..2dc15e0ec 100644 --- a/app/components/DS/pill.html.erb +++ b/app/components/DS/pill.html.erb @@ -9,7 +9,9 @@ title="<%= title || t("ds.pill.aria_label", label: label) %>"> <% else %> - <% if show_dot %> + <% if icon %> + <%= helpers.icon(icon, size: "xs", color: "current") %> + <% elsif show_dot %> <% end %> diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index 0e742612c..6e6aae924 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -1,27 +1,60 @@ class DS::Pill < DesignSystemComponent - TONES = %i[violet indigo fuchsia amber gray].freeze + TONES = %i[violet indigo fuchsia amber green gray red].freeze STYLES = %i[soft filled outline].freeze SIZES = %i[sm md].freeze - attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title + # 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 - # Generic inline pill primitive. Currently the home of Beta / Canary - # markers; can be reused for future tags (NEW, PRO, EXPERIMENTAL, etc.) - # without forking the component. + attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon, :marker + + # 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: # # - `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. - # - Sure has full violet / indigo / fuchsia / amber / gray 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: true, dot_only: false, title: nil) + # - `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: true, dot_only: false, title: nil, icon: nil, marker: true) + 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?(tone.to_sym) ? tone.to_sym : :violet + @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 @show_dot = show_dot @dot_only = dot_only @title = title + @icon = icon + @marker = marker end def palette @@ -30,7 +63,9 @@ class DS::Pill < DesignSystemComponent indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "var(--color-indigo-700)", 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: "var(--color-fuchsia-700)", 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: "var(--color-yellow-700)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" }, - gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "var(--color-gray-700)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" } + green: { bg: "var(--color-green-50)", bg_dark: "var(--color-green-tint-10)", text: "var(--color-green-700)", 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: "var(--color-gray-700)", 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: "var(--color-red-700)", text_dark: "var(--color-red-200)", border: "var(--color-red-200)", dot: "var(--color-red-500)", fill: "var(--color-red-500)" } }[tone] end @@ -68,15 +103,27 @@ class DS::Pill < DesignSystemComponent def container_classes base = [ - "inline-flex items-center align-middle font-medium uppercase whitespace-nowrap shrink-0", + "inline-flex items-center align-middle font-medium whitespace-nowrap shrink-0", "border rounded-md", "leading-none" ] - # text-[10/11px] stays as arbitrary values: the pill is intentionally - # sub-12px (Sure's smallest scale token is text-xs / 12px) to read as - # a marker, not a label. Padding / gap / tracking snap to Tailwind's - # scale to satisfy the design-system "no arbitrary values" rule. - 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") + + if marker + # Marker mode (Beta / Canary / NEW): 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 << "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): + # normal case, snaps to the design-system text scale + # (`text-xs` / `text-sm`). Padding bumps slightly so the badge + # reads as a status chip rather than a sub-12px marker. + 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 diff --git a/test/components/DS/pill_test.rb b/test/components/DS/pill_test.rb new file mode 100644 index 000000000..f108593b4 --- /dev/null +++ b/test/components/DS/pill_test.rb @@ -0,0 +1,59 @@ +require "test_helper" + +class DS::PillTest < ViewComponent::TestCase + test "marker mode (default) renders uppercase sub-12px chrome" do + render_inline(DS::Pill.new(label: "Beta", tone: :violet)) + + pill = page.find("span", text: "Beta") + assert_includes pill[:class], "uppercase" + # Marker keeps sub-12px text via arbitrary value (intentional — see component docs). + assert_match(/text-\[1[01]px\]/, pill[:class]) + end + + test "marker: false renders normal-case DS-scale chrome" do + render_inline(DS::Pill.new(label: "Active", tone: :success, marker: false)) + + pill = page.find("span", text: "Active") + refute_includes pill[:class], "uppercase" + # Badge mode snaps to text-xs / text-sm — no sub-12px arbitrary values. + assert_match(/text-(xs|sm)/, pill[:class]) + refute_match(/text-\[1[01]px\]/, pill[:class]) + end + + test "semantic tone aliases resolve to visual palette tones" do + { + success: :green, + warning: :amber, + error: :red, + destructive: :red, + info: :indigo, + neutral: :gray + }.each do |alias_name, expected_visual| + pill = DS::Pill.new(label: "x", tone: alias_name) + assert_equal expected_visual, pill.tone, "Expected #{alias_name} → #{expected_visual}, got #{pill.tone}" + end + end + + test "unknown tone falls back to violet" do + pill = DS::Pill.new(label: "x", tone: :nonexistent) + assert_equal :violet, pill.tone + end + + test "red tone palette resolves to red-* tokens" do + pill = DS::Pill.new(label: "Failed", tone: :error) + assert_includes pill.palette[:dot], "color-red-500" + assert_includes pill.palette[:bg], "color-red-50" + end + + test "icon option renders glyph in place of dot" do + render_inline(DS::Pill.new(label: "Syncing", tone: :info, marker: false, icon: "loader")) + + # Lucide icon helper renders the inline SVG; verifying we see at least one + # is enough — the icon helper is covered by its own tests. + assert_selector "svg" + # And the dot is suppressed when an icon takes its place. `refute_selector + # ..., count: N` only fails when there are exactly N matches, so use + # `assert_no_selector` to strictly assert zero dots. + assert_no_selector "span.rounded-full[style*='background-color']" + end +end diff --git a/test/components/previews/pill_component_preview.rb b/test/components/previews/pill_component_preview.rb index 88a7aa93f..ae036179c 100644 --- a/test/components/previews/pill_component_preview.rb +++ b/test/components/previews/pill_component_preview.rb @@ -1,26 +1,68 @@ class PillComponentPreview < ViewComponent::Preview - # @param tone select ["violet", "indigo", "fuchsia", "amber", "gray"] + # @param tone select ["violet", "indigo", "fuchsia", "amber", "green", "gray", "red", "success", "warning", "error", "info", "neutral"] # @param style select ["soft", "filled", "outline"] # @param size select ["sm", "md"] # @param label text # @param show_dot toggle # @param dot_only toggle - def default(tone: "violet", style: "soft", size: "sm", label: "Preview", show_dot: true, dot_only: false) + # @param marker toggle + # @param icon text + def default(tone: "violet", style: "soft", size: "sm", label: "Preview", show_dot: true, dot_only: false, marker: true, icon: nil) render DS::Pill.new( label: label, tone: tone.to_sym, style: style.to_sym, size: size.to_sym, show_dot: show_dot, - dot_only: dot_only + dot_only: dot_only, + marker: marker, + icon: icon.presence ) end + # @!group Stage markers (marker: true — original #1829 shape) def canary render DS::Pill.new(label: "Canary", tone: :fuchsia) end + def beta + render DS::Pill.new(label: "Beta", tone: :violet) + end + + def new_marker + render DS::Pill.new(label: "New", tone: :indigo) + end + def dot_only_collapsed_sidebar render DS::Pill.new(dot_only: true, tone: :violet) end + # @!endgroup + + # @!group Status badges (marker: false, semantic tones) + def status_active + render DS::Pill.new(label: "Active", tone: :success, marker: false) + end + + def status_pending + render DS::Pill.new(label: "Pending", tone: :warning, marker: false) + end + + def status_failed + render DS::Pill.new(label: "Failed", tone: :error, marker: false, icon: "circle-alert") + end + + def status_archived + render DS::Pill.new(label: "Archived", tone: :neutral, marker: false) + end + + def status_info + render DS::Pill.new(label: "Syncing", tone: :info, marker: false, icon: "loader") + end + # @!endgroup + + # @!group Sizes (md) + def status_md + render DS::Pill.new(label: "Past due", tone: :error, marker: false, size: :md) + end + # @!endgroup end From 548c4d1a3f58be4dd7a6203718e701d000398926 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Fri, 22 May 2026 02:17:32 +0200 Subject: [PATCH 4/5] fix(settings/debugs): replace 2 raw palette tokens flagged by DS drift scan (#1903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app/views/settings/debugs/show.html.erb` had two non-functional Tailwind classes flagged by sure-design's weekly merged-commit scan (#1895, #1898): - `bg-surface-default` → `bg-surface`. `bg-surface-default` doesn't map to any DS color variable (`--color-surface-default` isn't defined); `--color-surface` is the canonical token, auto-generates `bg-surface`. - `divide-gray-100` → `divide-alpha-black-200 theme-dark:divide-alpha-white-200`. Matches the existing pattern used by `admin/sso_providers/index.html.erb`, `admin/users/index.html.erb`, and `settings/preferences/show.html.erb` for tbody dividers. No `divide-primary` utility exists yet, so the bot's suggestion gets the same effect via the alpha tokens. The third drift finding on this file — the in-cell `
` metadata expander — is deferred until #1858's `DS::Disclosure :inline` variant lands on `main`. The `:default` variant renders a `bg-surface px-3 py-2 rounded-xl` card chrome that's wrong for an in-table-cell trigger; the `:inline` variant in #1858 is the right shape and will get a follow-up PR once that lands. Closes #1895 partially. Closes #1898 partially. Both bot issues stay open until the `
` migration also lands. --- app/views/settings/debugs/show.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/settings/debugs/show.html.erb b/app/views/settings/debugs/show.html.erb index 252ec4ed5..7d7d6c8b5 100644 --- a/app/views/settings/debugs/show.html.erb +++ b/app/views/settings/debugs/show.html.erb @@ -59,7 +59,7 @@ <% if @debug_log_entries.any? %>
- + @@ -70,7 +70,7 @@ - + <% @debug_log_entries.each do |entry| %> From ced133d06e5ed966d582f1b8de2a753562a46ff6 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 02:33:28 +0200 Subject: [PATCH 5/5] fix(views): guard against nil entry.date in partials (#1878) Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- app/views/pending_duplicate_merges/new.html.erb | 2 +- app/views/trades/_header.html.erb | 2 +- app/views/transactions/_header.html.erb | 2 +- app/views/valuations/_header.html.erb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/pending_duplicate_merges/new.html.erb b/app/views/pending_duplicate_merges/new.html.erb index 580603f62..4a21bd2a4 100644 --- a/app/views/pending_duplicate_merges/new.html.erb +++ b/app/views/pending_duplicate_merges/new.html.erb @@ -52,7 +52,7 @@
<%= entry.name %>
- <%= I18n.l(entry.date, format: :short) %> • + <%= entry.date ? I18n.l(entry.date, format: :short) : "—" %> • <%= number_to_currency(entry.amount.abs, unit: Money::Currency.new(entry.currency).symbol) %>
diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb index 94e8a2c5a..eea444354 100644 --- a/app/views/trades/_header.html.erb +++ b/app/views/trades/_header.html.erb @@ -31,7 +31,7 @@ - <%= I18n.l(entry.date, format: :long) %> + <%= entry.date ? I18n.l(entry.date, format: :long) : "—" %> <% end %> diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index d80c13bed..a5ca70b63 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -19,7 +19,7 @@
- <%= I18n.l(entry.date, format: :long) %> + <%= entry.date ? I18n.l(entry.date, format: :long) : "—" %> <% if entry.transaction.pending? %> "> diff --git a/app/views/valuations/_header.html.erb b/app/views/valuations/_header.html.erb index d4a376876..2a8566273 100644 --- a/app/views/valuations/_header.html.erb +++ b/app/views/valuations/_header.html.erb @@ -19,6 +19,6 @@
- <%= I18n.l(entry.date, format: :long) %> + <%= entry.date ? I18n.l(entry.date, format: :long) : "—" %> <% end %>
<%= t(".table.time") %> <%= t(".table.level") %><%= t(".table.metadata") %>
<%= l(entry.created_at, format: :long) %>