Files
sure/docs/llm-guides/gating-a-beta-feature.md
Guillem Arias Fauste 5249842c76 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>
2026-05-18 20:07:55 +02:00

5.5 KiB

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.

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:

class TransactionsController < ApplicationController
  before_action :require_beta_features!, only: %i[forecast scenarios]
end

Gating a view

Wrap the relevant fragment in the helper:

<% 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:

<%# 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:

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.