mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04:56 +00:00
* feat: beta features toggle + Beta pill primitive Adds the infrastructure for self-service beta opt-in. No call sites yet: this PR is meant to land first so feature PRs (Goals, etc.) can ship behind the gate incrementally. User opts in via a single toggle at the bottom of Settings → Preferences. The flag persists in the existing `users.preferences` JSONB column under `beta_features_enabled` — same shape as `dashboard_two_column` and `show_split_grouped`, so no migration is needed. Controllers gate a beta feature by adding `before_action :require_beta_features!` from the new `BetaGateable` concern (included in ApplicationController). Views use the `beta_features_enabled?` helper to hide / show nav items, banners, etc. Logged-out callers always return false. Ships `DS::BetaPill`, a small inline marker for tagging features as Beta / Canary in nav, headers, and lists. Five tones (violet by default, indigo, fuchsia, amber, gray) map to existing Sure color tokens — no raw hex. Three styles (soft / filled / outline) and two sizes (sm / md) cover the surfaces in the design handoff. The `dot_only:` mode renders just the colored dot for use on a collapsed sidebar. * review: rename to DS::Pill, fix CR/Codex nits, add tests CodeRabbit + Codex review feedback: - Rename DS::BetaPill → DS::Pill. The component was already generic in shape (tones, styles, sizes); the name was misleading scope. "Beta" becomes the default label (still i18n-driven). Goals' StatusPill can later refactor onto this primitive without a third pill. - Localize the default pill label via i18n (`ds.pill.default_label`) instead of hard-coding English. - Add role="img" to the dot-only span so the aria-label is consistently exposed to assistive tech. - Wrap the Preferences toggle row in <label for="…"> so the title and description become an honest click target for the toggle (matches the cursor-pointer affordance). - Drop arbitrary Tailwind values (py-[3px], gap-[5px], tracking-[…]) in favor of scale tokens. text-[10/11px] stays because the pill is intentionally sub-12px (Sure's smallest scale token is text-xs / 12px) to read as a marker, not a label. - Add User#beta_features_enabled? predicate tests covering default-off, explicit-true, and non-boolean truthy values. Won't fix: - Palette refs (`--color-violet-*` etc.). Sure has no semantic Beta/ Canary tokens; introducing them in this PR would be a design-system change beyond the scope. The component centralizes palette use in one `palette` method, matching the existing pattern in Goals::StatusPillComponent. * review: consistent title fallback in full-pill branch * docs: how to gate a feature behind the beta toggle * docs: unwrap doc lines to match existing style * chore(preview): run Cloudflare PR previews on basic instances (#1831) * fix(preview): use Rails health endpoint for container ping (#1823) * fix(preview): use Rails health endpoint for container ping * fix(preview): point container ping to localhost/up --------- Co-authored-by: Sure Admin (bot) <sure-admin@splashblot.com>
83 lines
4.0 KiB
Ruby
83 lines
4.0 KiB
Ruby
class DS::Pill < DesignSystemComponent
|
|
TONES = %i[violet indigo fuchsia amber gray].freeze
|
|
STYLES = %i[soft filled outline].freeze
|
|
SIZES = %i[sm md].freeze
|
|
|
|
attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title
|
|
|
|
# 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.
|
|
#
|
|
# - `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)
|
|
@label = label || I18n.t("ds.pill.default_label", default: "Beta")
|
|
@tone = TONES.include?(tone.to_sym) ? tone.to_sym : :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
|
|
end
|
|
|
|
def palette
|
|
{
|
|
violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "var(--color-violet-700)", 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: "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)" }
|
|
}[tone]
|
|
end
|
|
|
|
def dot_size_px
|
|
size == :md ? 6 : 5
|
|
end
|
|
|
|
def container_styles
|
|
p = palette
|
|
case style
|
|
when :filled
|
|
<<~CSS.strip.gsub(/\s+/, " ")
|
|
background-color: #{p[: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
|
|
style == :filled ? "rgba(255,255,255,0.85)" : palette[:dot]
|
|
end
|
|
|
|
def container_classes
|
|
base = [
|
|
"inline-flex items-center align-middle font-medium uppercase 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")
|
|
class_names(*base)
|
|
end
|
|
end
|