feat: beta features toggle + Beta pill primitive (#1829)

* 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>
This commit is contained in:
Guillem Arias Fauste
2026-05-18 20:07:55 +02:00
committed by GitHub
parent b73da38f49
commit 5249842c76
17 changed files with 382 additions and 3 deletions

View File

@@ -0,0 +1,18 @@
<% if dot_only %>
<%# Compact dot — no label, no border, just the colored marker. Used on the
collapsed sidebar nav. Keeps tone semantics without taking up label
width. %>
<span role="img"
class="inline-block shrink-0 align-middle rounded-full"
style="width: <%= dot_size_px %>px; height: <%= dot_size_px %>px; background-color: <%= dot_color %>;"
aria-label="<%= title || t("ds.pill.aria_label", label: label) %>"
title="<%= title || t("ds.pill.aria_label", label: label) %>"></span>
<% else %>
<span class="<%= container_classes %>" style="<%= container_styles %>" title="<%= title || label %>">
<% if show_dot %>
<span class="inline-block shrink-0 rounded-full"
style="width: <%= dot_size_px %>px; height: <%= dot_size_px %>px; background-color: <%= dot_color %>;"></span>
<% end %>
<%= label %>
</span>
<% end %>

82
app/components/DS/pill.rb Normal file
View File

@@ -0,0 +1,82 @@
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

View File

@@ -1,7 +1,8 @@
class ApplicationController < ActionController::Base
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
FeatureGuardable, Notifiable, SafePagination, AccountAuthorizable
FeatureGuardable, Notifiable, SafePagination, AccountAuthorizable,
BetaGateable
include Pundit::Authorization
include Pagy::Backend

View File

@@ -0,0 +1,20 @@
module BetaGateable
extend ActiveSupport::Concern
included do
helper_method :beta_features_enabled?
end
def beta_features_enabled?
Current.user&.beta_features_enabled? == true
end
# Use as a `before_action` on controllers that gate a beta feature.
# Redirects non-beta users to the dashboard with a flash explaining the
# feature is opt-in. Self-served via Settings → Preferences.
def require_beta_features!
return if beta_features_enabled?
redirect_to root_path, alert: I18n.t("beta.not_enabled")
end
end

View File

@@ -4,4 +4,21 @@ class Settings::PreferencesController < ApplicationController
def show
@user = Current.user
end
# Writes per-user boolean preferences stored in the JSONB `users.preferences`
# column. Mirrors Settings::AppearancesController#update so the toggle card on
# the Preferences page can submit directly without going through the broader
# UsersController#update flow (which expects a full user form payload).
def update
@user = Current.user
@user.transaction do
@user.lock!
updated_prefs = (@user.preferences || {}).deep_dup
if params.dig(:user, :beta_features_enabled)
updated_prefs["beta_features_enabled"] = params.dig(:user, :beta_features_enabled) == "1"
end
@user.update!(preferences: updated_prefs)
end
redirect_to settings_preferences_path
end
end

View File

@@ -365,6 +365,10 @@ class User < ApplicationRecord
preferences&.dig("dashboard_two_column") == true
end
def beta_features_enabled?
preferences&.dig("beta_features_enabled") == true
end
def update_transactions_preferences(prefs)
transaction do
lock!

View File

@@ -206,3 +206,29 @@
</div>
<% end %>
<% end %>
<%# Beta features toggle — visible to all users, not just admins. Lives at the
bottom of Preferences as a standalone card (no section header) so the toggle
row is the entire surface. Posts directly to settings#preferences#update via
the Settings::PreferencesController, matching the auto-submit pattern used
on the Appearance page. %>
<section class="bg-container shadow-border-xs rounded-xl p-4">
<%= form_with url: settings_preferences_path, method: :patch,
data: { controller: "auto-submit-form" } do |f| %>
<%# Wrapping the row in <label for=…> makes the title + description
themselves a click target for the toggle, so the cursor-pointer
affordance on the container is honest. %>
<label for="user_beta_features_enabled" class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".beta.title") %></h4>
<p class="text-secondary"><%= t(".beta.description") %></p>
</div>
<%= render DS::Toggle.new(
id: "user_beta_features_enabled",
name: "user[beta_features_enabled]",
checked: @user.beta_features_enabled?,
data: { auto_submit_form_target: "auto" }
) %>
</label>
<% end %>
</section>

View File

@@ -0,0 +1,4 @@
---
en:
beta:
not_enabled: This feature is in beta. Enable beta features in Settings → Preferences to try it.

View File

@@ -17,6 +17,9 @@ en:
warning: Warning
error: Error
destructive: Error
pill:
aria_label: "%{label}"
default_label: Beta
dialog:
close: Close
provider_sync_summary:

View File

@@ -153,6 +153,9 @@ en:
sharing_default_label: Default sharing for new accounts
sharing_shared: Share with all members
sharing_private: Keep private by default
beta:
title: Enable beta features
description: Opt in to in-progress features tagged beta or canary.
profiles:
destroy:
cannot_remove_self: You cannot remove yourself from the account.

View File

@@ -236,7 +236,7 @@ Rails.application.routes.draw do
namespace :settings do
resource :profile, only: [ :show, :destroy ]
resource :preferences, only: :show
resource :preferences, only: %i[show update]
resource :appearance, only: %i[show update]
resource :debug, only: :show
resource :hosting, only: %i[show update] do

View File

@@ -0,0 +1,128 @@
# Gating a beta feature
Sure ships beta features behind a single per-user toggle. Users opt in via Settings → Preferences. Opted-in users see your feature; everyone else doesn't. This guide is for hooking a new feature into the gate.
The intent is to ship in-progress work without blocking smaller PRs on a "feels finished" bar. You gate the entry points (routes, nav, anything that links into your feature) and iterate behind them. Once stable, you remove the gate in a small follow-up PR.
## How the gate works
The state lives on `users.preferences["beta_features_enabled"]`, a key inside the existing JSONB column. It defaults to `false`. Reading it goes through `User#beta_features_enabled?`.
`ApplicationController` includes the `BetaGateable` concern, which exposes two methods to every controller:
- `beta_features_enabled?`. Returns a boolean. `false` for logged-out callers.
- `require_beta_features!`. A `before_action` helper. Redirects non-beta users to `/` with a flash that points them at Settings → Preferences.
The concern also registers `beta_features_enabled?` as a helper method, so views can call it directly.
Key files:
- `app/controllers/concerns/beta_gateable.rb`. The concern.
- `app/models/user.rb`. The `beta_features_enabled?` predicate.
- `app/views/settings/preferences/show.html.erb`. The toggle UI users see.
- `app/components/DS/pill.rb`. The `Beta` / `Canary` marker pill.
- `config/locales/views/beta/en.yml`. The redirect flash copy.
## Gating a controller
Add `require_beta_features!` as a `before_action`. That's it.
```ruby
class GoalsController < ApplicationController
before_action :require_beta_features!
end
```
Routes stay defined; the gate runs per-request. Non-beta users hitting `/goals` get redirected with a flash. Beta users pass through.
If only some actions are gated, scope the `before_action`:
```ruby
class TransactionsController < ApplicationController
before_action :require_beta_features!, only: %i[forecast scenarios]
end
```
## Gating a view
Wrap the relevant fragment in the helper:
```erb
<% if beta_features_enabled? %>
<li>
<%= link_to t(".nav.goals"), goals_path %>
</li>
<% end %>
```
Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces beta data alongside non-beta data. The helper resolves on every request and reflects the current user's preference.
## Marking the feature in the UI
When a beta surface renders for an opted-in user, mark it. The pill component lives in the design system:
```erb
<%# Next to a page header. The md size pairs with h1 / h2. %>
<%= render DS::Pill.new(label: "Beta", size: :md) %>
<%# Next to a sidebar nav label or section title. sm is the default. %>
<%= render DS::Pill.new(label: "Beta") %>
<%# Same shape, fuchsia tone, for canary / experimental surfaces. %>
<%= render DS::Pill.new(label: "Canary", tone: :fuchsia) %>
<%# Sidebar icon rail has no room for a label. The dot-only mode keeps the tone semantics without the text. %>
<%= render DS::Pill.new(tone: :violet, dot_only: true, title: "Beta") %>
```
Default tone is violet. Tones available: `violet`, `indigo`, `fuchsia`, `amber`, `gray`. Styles: `soft` (default), `filled`, `outline`. Sizes: `sm` (default), `md`. The Lookbook preview at `/design-system` (look for `PillComponentPreview#default`) flips every option, so you can see what your call site renders without a round trip to Rails.
## Tests
Gated controllers should test both states. The pattern:
```ruby
class GoalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
end
test "redirects users without beta access" do
@user.update!(preferences: (@user.preferences || {}).merge("beta_features_enabled" => false))
get goals_url
assert_redirected_to root_path
assert_match(/beta/i, flash[:alert])
end
test "renders for users with beta access" do
@user.update!(preferences: (@user.preferences || {}).merge("beta_features_enabled" => true))
get goals_url
assert_response :success
end
end
```
If you write a system test, flip the preference in setup the same way before the visit.
## Removing the gate when the feature ships GA
When a feature moves from beta to general availability, removing the gate is a small mechanical PR:
1. Drop the `before_action :require_beta_features!` line from the controller.
2. Unwrap the `if beta_features_enabled?` blocks in views.
3. Drop the `DS::Pill` markers from headers, nav, and section titles.
4. Delete the controller / view tests that exercise the redirect.
Grep for `require_beta_features!` and `beta_features_enabled?` near your feature to confirm nothing's left behind.
## Notes
The flag is per-user, not per-family. Two users in the same family can see different versions of the product if one opts in and the other doesn't. That's intentional. Data is family-scoped, but visibility is a personal preference. If you write a feature that creates family-shared data (goals, budgets, etc.), the data persists when a user toggles beta off. The UI just disappears from their view while still showing up for opted-in family members.
The gate does nothing for background jobs. If your feature has a Sidekiq cron job, it runs regardless of who has beta enabled. That's usually correct (data should keep flowing), but if the job sends notifications or emails, gate those at the send site too.
The redirect target is `/`. If you want gated controllers to land somewhere else (a docs page, an opt-in nudge), override `require_beta_features!` in the controller, or write a thin custom `before_action` that calls `beta_features_enabled?` directly.

View File

@@ -0,0 +1,26 @@
class PillComponentPreview < ViewComponent::Preview
# @param tone select ["violet", "indigo", "fuchsia", "amber", "gray"]
# @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: "Beta", show_dot: true, dot_only: false)
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
)
end
def canary
render DS::Pill.new(label: "Canary", tone: :fuchsia)
end
def dot_only_collapsed_sidebar
render DS::Pill.new(dot_only: true, tone: :violet)
end
end

View File

@@ -21,4 +21,33 @@ class Settings::PreferencesControllerTest < ActionDispatch::IntegrationTest
assert_includes response.body, "your group"
assert_select "select[name='user[family_attributes][currency]']", count: 0
end
test "renders beta features toggle for non-admin users too" do
sign_in users(:family_member)
get settings_preferences_url
assert_response :success
assert_includes response.body, "Enable beta features"
end
test "update toggles beta_features_enabled on" do
user = users(:family_admin)
assert_not user.beta_features_enabled?
patch settings_preferences_url, params: { user: { beta_features_enabled: "1" } }
assert_redirected_to settings_preferences_url
assert user.reload.beta_features_enabled?
end
test "update toggles beta_features_enabled off" do
user = users(:family_admin)
user.update!(preferences: (user.preferences || {}).merge("beta_features_enabled" => true))
assert user.beta_features_enabled?
patch settings_preferences_url, params: { user: { beta_features_enabled: "0" } }
assert_redirected_to settings_preferences_url
assert_not user.reload.beta_features_enabled?
end
end

View File

@@ -660,6 +660,23 @@ class UserTest < ActiveSupport::TestCase
assert_equal "custom_role", User.role_for_new_family_creator(fallback_role: "custom_role")
end
# Beta features preference tests
test "beta_features_enabled? defaults to false" do
@user.update!(preferences: {})
assert_not @user.beta_features_enabled?
end
test "beta_features_enabled? true only when explicitly true" do
@user.update!(preferences: { "beta_features_enabled" => true })
assert @user.beta_features_enabled?
@user.update!(preferences: { "beta_features_enabled" => false })
assert_not @user.beta_features_enabled?
@user.update!(preferences: { "beta_features_enabled" => "yes" })
assert_not @user.beta_features_enabled?, "truthy non-boolean should not enable"
end
# ActiveStorage attachment cleanup tests
test "purging a user removes attached profile image" do
user = users(:family_admin)

View File

@@ -62,7 +62,7 @@ const WAITING_MESSAGES: Record<string, string> = {
export class RailsContainer extends Container {
defaultPort = 3000;
pingEndpoint = "container/up";
pingEndpoint = "localhost/up";
entrypoint = ["/rails/bin/preview-entrypoint", "bundle", "exec", "puma", "-C", "config/puma.rb"];
envVars = {
RAILS_ENV: "production",

View File

@@ -17,6 +17,7 @@ enabled = true
[[containers]]
class_name = "RailsContainer"
image = "../../Dockerfile.preview"
instance_type = "basic"
max_instances = 1
# Durable Object binding for the container