Files
sure/app/components/DS/buttonish.rb
T
Brandon fe0d27471d feat(bills): the bills pages, calendar feed and in-page AI helpers (#3202)
* feat(bills): the bills pages, calendar feed and in-page AI helpers

Second of three chunks carved out of #3083, stacked on the schema and domain
core. This is everything a user sees and clicks. The whole surface sits behind
the preview flag, so it is unreachable until someone opts in.

Pages, all under one nav entry:

- the pay run, a month calendar, the full bills table, and the paycheck planner
- a detail drawer per bill, with payment history, price changes and cost
  analytics
- create and edit flows for bills, subscriptions, installment plans and income

The overview marks pay periods inside the month, so a weekly paycheck no longer
reads as one undifferentiated month of bills. Markers appear only when income
actually subdivides the month, which means monthly and undeclared income render
exactly as before and there is no new setting to configure.

Navigation and design system:

- one preview-gated nav item shared by the desktop rail and the mobile bar
- DS::Sparkline for payment-history charts, replacing raw SVG in views
- status badges render through DS::Pill rather than hand-rolled spans
- the suggestions panel is a disclosure that remembers being collapsed, per
  device, the way privacy mode and the sidebar width already do
- every surface reflows to phone widths without horizontal scroll

Calendar feed: a signed ICS feed per family, served sessionless by token, with a
reset that revokes previously shared URLs.

In-page AI helpers: smart fill on the bill form and a smart configuration
proposal on an existing bill, each reading a bounded slice of charge history.
Provider-side prompt assembly sits behind the existing LlmConcept interface,
with an implementation for each of the two providers. These belong here rather
than with the assistant tools because they are buttons on these pages and lean
on the provider suggester, not on the tool registry.

Suite 7,776 runs green apart from the pre-existing passkey-session flake, which passes standalone. Rubocop clean, eager loading verified. The hosting guide for the feature ships here rather than with the schema, since its instructions walk pages this PR introduces.

* Render the suggested strip through DS::Disclosure

The hand-rolled details pair predates the component. The card_inset
variant is the same shape, so the strip now inherits the design system
chrome, and the persisted-disclosure controller rides along unchanged.

* Route the remaining hand-rolled chips through the design system

The subscription-state chips, rule-match chips and match-reason chips
become DS::Pill, with the state chips extracted to one shared partial so
the drawer and the summary tab stop carrying copy-pasted markup. The AI
prompt chips become DS::Button and the bills-index filter becomes
DS::SearchInput, both of which this PR already uses elsewhere for the
same shapes.

* Fix erb_lint whitespace offenses in bills views

* Address the post-ready review round

* Require a writable destination account and gate the feed on preview

* Reject an unresolvable declared account out loud
2026-09-02 02:08:58 +02:00

194 lines
6.9 KiB
Ruby

# Bills subsystem: adds the `accent` and `outline_muted` variants, with their
# colours declared in design/tokens/sure.tokens.json alongside every other
# button colour.
class DS::Buttonish < DesignSystemComponent
VARIANTS = {
primary: {
container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
icon_classes: "text-inverse"
},
secondary: {
container_classes: "text-primary bg-gray-200 theme-dark:bg-gray-700 hover:bg-gray-300 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
icon_classes: "text-primary"
},
# A tinted call to action for navigation that leaves the app. Backed by the
# button-bg-accent utilities in design/tokens/sure.tokens.json, so the colour
# lives with every other button colour rather than in this file.
accent: {
container_classes: "text-link button-bg-accent hover:button-bg-accent-hover disabled:button-bg-disabled",
icon_classes: "text-link"
},
destructive: {
container_classes: "text-inverse button-bg-destructive hover:button-bg-destructive-hover disabled:bg-red-200 theme-dark:disabled:bg-red-600",
icon_classes: "text-inverse"
},
outline: {
container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover",
icon_classes: "text-secondary"
},
# A quieter outline for "there is nothing here yet" affordances, which should sit
# behind the content they sit beside rather than compete with it. Pair with
# `border-dashed` for the empty-slot cue the split editor uses.
outline_muted: {
container_classes: "text-secondary hover:text-primary border border-secondary hover:border-primary bg-transparent",
icon_classes: "text-secondary"
},
outline_destructive: {
container_classes: "text-destructive border border-secondary bg-transparent hover:bg-container-inset-hover",
icon_classes: "text-secondary"
},
ghost: {
container_classes: "text-primary bg-transparent hover:bg-container-inset-hover",
icon_classes: "text-secondary"
},
icon: {
container_classes: "hover:bg-container-inset-hover",
icon_classes: "text-secondary"
},
icon_inverse: {
container_classes: "bg-inverse hover:bg-inverse-hover",
icon_classes: "text-inverse"
}
}.freeze
# Icon-only containers share a height rail with the text buttons of the
# same size (sm ≈ 28px, md ≈ 36px, lg ≈ 48px), so a mixed row — icon
# trigger next to text buttons, the most common header layout — lines up
# instead of mixing 32/44px squares with 36px buttons.
#
# pointer-coarse restores the 44px square on touch devices: the visual
# rail is a pointer-precision tradeoff, and WCAG 2.5.5's 44x44 target
# minimum is about fingers, not mice. Coarse-pointer users get the full
# target; fine-pointer users get the aligned row.
SIZES = {
sm: {
container_classes: "px-2 py-1",
icon_container_classes: "inline-flex items-center justify-center w-7 h-7 pointer-coarse:w-11 pointer-coarse:h-11",
radius_classes: "rounded-md",
text_classes: "text-sm"
},
md: {
container_classes: "px-3 py-2",
icon_container_classes: "inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11",
radius_classes: "rounded-lg",
text_classes: "text-sm"
},
lg: {
container_classes: "px-4 py-3",
icon_container_classes: "inline-flex items-center justify-center w-12 h-12",
radius_classes: "rounded-xl",
text_classes: "text-base"
}
}.freeze
attr_reader :variant, :size, :href, :icon, :icon_custom, :icon_position, :text, :full_width, :extra_classes, :frame, :opts
def initialize(variant: :primary, size: :md, href: nil, text: nil, icon: nil, icon_custom: false, icon_position: :left, full_width: false, frame: nil, **opts)
@variant = variant.to_s.underscore.to_sym
@size = size.to_sym
@href = href
@icon = icon
@icon_custom = icon_custom
@icon_position = icon_position.to_sym
@text = text
@full_width = full_width
@extra_classes = opts.delete(:class)
@frame = frame
@opts = opts
end
def call
raise NotImplementedError, "Buttonish is an abstract class and cannot be instantiated directly."
end
def container_classes(override_classes = nil)
class_names(
# Tailwind v4 preflight sets `cursor: pointer` on all <button>s, which
# also applies while disabled. Override so disabled buttons read as
# non-interactive. The aria-disabled twins cover buttons that gate via
# `aria-disabled` to stay clickable/focusable (e.g. submit buttons whose
# click handler surfaces validation errors — a truly disabled default
# submit would also swallow Enter-key implicit submission).
"font-medium whitespace-nowrap focus-ring disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
merged_base_classes,
full_width ? "w-full justify-center" : nil,
container_size_classes,
icon_only? ? nil : size_data.dig(:text_classes),
variant_data.dig(:container_classes)
)
end
def container_size_classes
icon_only? ? size_data.dig(:icon_container_classes) : size_data.dig(:container_classes)
end
def icon_color
# Map variant to icon color for the icon helper
case variant
when :primary, :icon_inverse
:white
when :destructive, :outline_destructive
:destructive
when :accent, :outline_muted
# Inherit the container's own text color so the glyph always matches its
# label, including on hover, instead of tracking a second colour by hand.
:current
else
:default
end
end
def icon_classes
class_names(
variant_data.dig(:icon_classes)
)
end
def icon_only?
variant.in?([ :icon, :icon_inverse ]) || (icon.present? && text.blank?)
end
private
def variant_data
self.class::VARIANTS.dig(variant)
end
def size_data
self.class::SIZES.dig(size)
end
# Make sure that user can override common classes like `hidden`
def merged_base_classes
base_display_classes = "inline-flex items-center gap-1"
base_radius_classes = size_data.dig(:radius_classes)
extra_classes_list = (extra_classes || "").split
has_display_override = extra_classes_list.any? { |c| permitted_display_override_classes.include?(c) }
has_radius_override = extra_classes_list.any? { |c| permitted_radius_override_classes.include?(c) }
base_classes = []
unless has_display_override
base_classes << base_display_classes
end
unless has_radius_override
base_classes << base_radius_classes
end
class_names(
base_classes,
extra_classes
)
end
def permitted_radius_override_classes
[ "rounded-full" ]
end
def permitted_display_override_classes
[ "hidden", "flex" ]
end
end