Merge origin/main into feat/goals-v2-architecture

Pulls in #1857 (DS::Disclosure :card_inset), #1858 (:inline variant),
#1902 (DS::Pill marker:false + semantic tones + :red palette), #1903
(settings/debugs token fix), plus #1878 (entry.date guard) and other
minor fixes that landed.

Resolves one conflict in app/components/DS/pill.rb: takes main's new
extended API (marker: flag, SEMANTIC_TONE_ALIASES, :red tone, updated
docstring) and preserves the goals-branch color-mix(...30% black)
text treatment that was added for light-mode contrast. Applies the
same color-mix to the new :red tone for consistency.
This commit is contained in:
Guillem Arias
2026-05-22 08:53:10 +02:00
18 changed files with 280 additions and 93 deletions

View File

@@ -1,4 +1,4 @@
<details class="<%= details_classes %>" <%= "open" if open %>>
<%= tag.details class: details_classes, open: open, **details_opts do %>
<%= tag.summary class: summary_classes do %>
<% if summary_content? %>
<%# `<summary>` is `display: list-item`, so a flex inner div would
@@ -28,4 +28,4 @@
<div class="mt-2">
<%= content %>
</div>
</details>
<% end %>

View File

@@ -1,46 +1,77 @@
class DS::Disclosure < DesignSystemComponent
renders_one :summary_content
VARIANTS = %i[default card].freeze
VARIANTS = %i[default card card_inset inline].freeze
attr_reader :title, :align, :open, :variant, :opts
# `:default` — bg-surface summary, no chrome on the `<details>`. 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` — `<details>` itself becomes a `bg-container shadow-border-xs
# 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` — `<details>` 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`.
#
# `: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
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).
"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

View File

@@ -1,31 +1,60 @@
class DS::Pill < DesignSystemComponent
TONES = %i[violet indigo fuchsia amber green 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, :icon
# 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. Used for Beta / Canary markers and goal
# status badges, but designed so any future tag (NEW, PRO, EXPERIMENTAL,
# etc.) reuses the same shape without forking.
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.
# - `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.
# - Sure has full violet / indigo / fuchsia / amber / green / 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: nil)
# - 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
@@ -39,7 +68,8 @@ class DS::Pill < DesignSystemComponent
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)" }
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
@@ -77,15 +107,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

View File

@@ -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") %>

View File

@@ -9,7 +9,7 @@
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
<div class="flex items-center justify-center h-8 w-8 bg-gray-tint-10 rounded-full">
<div class="flex items-center justify-center h-8 w-8 bg-container-inset rounded-full">
<div class="flex items-center justify-center">
<%= tag.p indexa_capital_item.name.first.upcase, class: "text-primary text-xs font-medium" %>
</div>

View File

@@ -52,7 +52,7 @@
<div class="flex-1 text-sm">
<div class="font-medium text-primary"><%= entry.name %></div>
<div class="text-xs text-secondary">
<%= 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) %>
</div>
</div>

View File

@@ -21,7 +21,7 @@
<div class="flex items-center gap-2">
<%= tag.p plaid_item.name, class: "font-medium text-primary" %>
<% if plaid_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse">(deletion in progress...)</p>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<% if plaid_item.syncing? %>

View File

@@ -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 %>
<details <%= "open" if open %>
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? %>>
<summary class="flex items-center justify-between gap-2 cursor-pointer rounded-lg list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
<div>
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
<%= 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 %>
<div class="flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "text-secondary group-open:rotate-90 motion-safe:transition-transform motion-safe:duration-150" %>
<div>
<div class="flex items-center gap-2 flex-wrap">
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
<%= badge if badge.present? %>
</div>
<% if subtitle.present? %>
<p class="text-secondary text-sm"><%= subtitle %></p>
<% end %>
</div>
<% if subtitle.present? %>
<p class="text-secondary text-sm"><%= subtitle %></p>
<% end %>
</div>
<% if status.present? %>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status %>
<%= actions if actions.present? %>
</div>
<% end %>
</div>
<% if status.present? %>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status %>
<%= actions if actions.present? %>
</div>
<% end %>
</summary>
<% end %>
<div class="space-y-4 mt-4">
<%= content %>
</div>
</details>
<% end %>
<% else %>
<section class="bg-container shadow-border-xs rounded-xl p-4 space-y-4">
<div>

View File

@@ -59,7 +59,7 @@
<% if @debug_log_entries.any? %>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-surface-default border-b border-primary">
<thead class="bg-surface border-b border-primary">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.time") %></th>
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.level") %></th>
@@ -70,7 +70,7 @@
<th class="px-4 py-3 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.metadata") %></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tbody class="divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200">
<% @debug_log_entries.each do |entry| %>
<tr>
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap"><%= l(entry.created_at, format: :long) %></td>

View File

@@ -13,17 +13,17 @@
t(".steps.step_5")
] %>
<details class="group bg-surface-inset rounded-xl p-4">
<summary class="list-none cursor-pointer [&::-webkit-details-marker]:hidden">
<%= render DS::Disclosure.new(variant: :card_inset) do |disclosure| %>
<% disclosure.with_summary_content do %>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-medium uppercase text-subdued tracking-wider mb-2"><%= t(".flex_query_details.eyebrow") %></p>
<p class="text-sm font-medium text-primary"><%= t(".flex_query_details.title") %></p>
<p class="text-xs text-secondary mt-1"><%= t(".flex_query_details.summary") %></p>
</div>
<%= 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" %>
</div>
</summary>
<% end %>
<div class="mt-4 space-y-4 text-sm text-secondary">
<div class="space-y-2">
@@ -89,7 +89,7 @@
<p class="text-xs text-secondary"><%= t(".report_window_note") %></p>
</div>
</details>
<% end %>
<%
ibkr_item = Current.family.ibkr_items.first_or_initialize(name: "Interactive Brokers")

View File

@@ -29,10 +29,12 @@
type: :password %>
<p class="text-xs text-secondary px-1 -mt-1"><%= t("indexa_capital_items.panel.fields.api_token.description") %></p>
<details class="group">
<summary class="text-sm text-secondary cursor-pointer hover:text-primary transition-colors">
<%= t("indexa_capital_items.panel.alternative_auth") %>
</summary>
<%= render DS::Disclosure.new(variant: :inline) do |disclosure| %>
<% disclosure.with_summary_content do %>
<span class="text-sm text-secondary hover:text-primary transition-colors">
<%= t("indexa_capital_items.panel.alternative_auth") %>
</span>
<% end %>
<div class="mt-3 space-y-3 pt-3 border-t border-primary">
<%= 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 %>
</div>
</details>
<% end %>
<div class="flex justify-end">
<%= form.submit is_new_record ? t("indexa_capital_items.panel.save_button") : t("indexa_capital_items.panel.update_button"),

View File

@@ -57,25 +57,31 @@
<% if items&.any? && items.first.user_registered? %>
<% item = items.first %>
<div class="border-t border-primary pt-4 mt-4">
<details class="group"
data-controller="lazy-load"
data-action="toggle->lazy-load#toggled"
data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>"
data-lazy-load-auto-open-param-value="manage">
<summary class="flex items-center justify-between cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-2">
<p class="text-sm text-secondary">
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
<%= 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 %>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm text-secondary">
<%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %>
<% if item.unlinked_accounts_count > 0 %>
<span class="text-warning">(<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>)</span>
<% end %>
</p>
</div>
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
<%= 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" %>
</span>
</div>
<span class="flex items-center gap-1 text-sm text-secondary hover:text-primary">
<%= t("providers.snaptrade.manage_connections") %>
<%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %>
</span>
</summary>
<% end %>
<div class="mt-3 space-y-3" data-lazy-load-target="content">
<div data-lazy-load-target="loading" class="flex items-center gap-2 text-sm text-secondary py-2">
@@ -86,7 +92,7 @@
<div data-lazy-load-target="frame">
</div>
</div>
</details>
<% end %>
</div>
<% end %>
</div>

View File

@@ -31,7 +31,7 @@
</div>
<span class="text-sm text-secondary">
<%= I18n.l(entry.date, format: :long) %>
<%= entry.date ? I18n.l(entry.date, format: :long) : "—" %>
</span>
<% end %>

View File

@@ -19,7 +19,7 @@
</h3>
<div class="flex items-center gap-2">
<span class="text-sm text-secondary">
<%= I18n.l(entry.date, format: :long) %>
<%= entry.date ? I18n.l(entry.date, format: :long) : "—" %>
</span>
<% if entry.transaction.pending? %>
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="<%= t("transactions.transaction.pending_tooltip") %>">

View File

@@ -19,6 +19,6 @@
</div>
<span class="text-sm text-secondary">
<%= I18n.l(entry.date, format: :long) %>
<%= entry.date ? I18n.l(entry.date, format: :long) : "—" %>
</span>
<% end %>