diff --git a/app/components/DS/pill.html.erb b/app/components/DS/pill.html.erb new file mode 100644 index 000000000..6712f0602 --- /dev/null +++ b/app/components/DS/pill.html.erb @@ -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. %> + " + title="<%= title || t("ds.pill.aria_label", label: label) %>"> +<% else %> + + <% if show_dot %> + + <% end %> + <%= label %> + +<% end %> diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb new file mode 100644 index 000000000..0e742612c --- /dev/null +++ b/app/components/DS/pill.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7604ed654..1d1389487 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/concerns/beta_gateable.rb b/app/controllers/concerns/beta_gateable.rb new file mode 100644 index 000000000..4268baaa6 --- /dev/null +++ b/app/controllers/concerns/beta_gateable.rb @@ -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 diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 83f9e2e44..d5bf9d75a 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 9f7f64d80..bcaa1446b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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! diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 4e52431dd..74d3e7022 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -206,3 +206,29 @@ <% 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. %> +
+ <%= form_with url: settings_preferences_path, method: :patch, + data: { controller: "auto-submit-form" } do |f| %> + <%# Wrapping the row in
diff --git a/config/locales/views/beta/en.yml b/config/locales/views/beta/en.yml new file mode 100644 index 000000000..bb9bcb8d3 --- /dev/null +++ b/config/locales/views/beta/en.yml @@ -0,0 +1,4 @@ +--- +en: + beta: + not_enabled: This feature is in beta. Enable beta features in Settings → Preferences to try it. diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml index 1f3bff829..fcfa566bd 100644 --- a/config/locales/views/components/en.yml +++ b/config/locales/views/components/en.yml @@ -17,6 +17,9 @@ en: warning: Warning error: Error destructive: Error + pill: + aria_label: "%{label}" + default_label: Beta dialog: close: Close provider_sync_summary: diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 85a25e193..2c4435988 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -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. diff --git a/config/routes.rb b/config/routes.rb index e7c5af0a7..c88e38c53 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/docs/llm-guides/gating-a-beta-feature.md b/docs/llm-guides/gating-a-beta-feature.md new file mode 100644 index 000000000..dc6c90979 --- /dev/null +++ b/docs/llm-guides/gating-a-beta-feature.md @@ -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? %> +
  • + <%= link_to t(".nav.goals"), goals_path %> +
  • +<% 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. diff --git a/test/components/previews/pill_component_preview.rb b/test/components/previews/pill_component_preview.rb new file mode 100644 index 000000000..05c1258a0 --- /dev/null +++ b/test/components/previews/pill_component_preview.rb @@ -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 diff --git a/test/controllers/settings/preferences_controller_test.rb b/test/controllers/settings/preferences_controller_test.rb index d2f5142fc..8f300d914 100644 --- a/test/controllers/settings/preferences_controller_test.rb +++ b/test/controllers/settings/preferences_controller_test.rb @@ -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 diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 9912e5a9e..600cbb72f 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -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) diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts index 872cf1a99..5e60a8757 100644 --- a/workers/preview/src/index.ts +++ b/workers/preview/src/index.ts @@ -62,7 +62,7 @@ const WAITING_MESSAGES: Record = { 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", diff --git a/workers/preview/wrangler.toml b/workers/preview/wrangler.toml index e180a62b7..17e95b899 100644 --- a/workers/preview/wrangler.toml +++ b/workers/preview/wrangler.toml @@ -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