EnvStyle logo

EnvStyle

Gem Version Build Status License: MIT Ruby >= 3.2 Rails >= 7.2

A distinct favicon color per environment, so you never deploy to the wrong tab.

EnvStyle is a mountable Rails engine that tints your app's favicon a different color in development, staging, and preview — while leaving production untouched. It's the Rails equivalent of env.style, but it runs at request time instead of build time, and it sits below your front-end framework, so it works identically in Hotwire and Inertia apps and depends on neither.

Table of Contents

The Problem

You have five tabs open: production, staging, a preview branch, and two local servers. They all render the same favicon. You run a destructive rake task, submit a form, or click "Delete" — in the wrong tab. Every tab looks the same, so the only defense is reading the URL every single time.

env.style solves this beautifully for build-time front-ends, but it has two gaps for a Rails app:

  • It restyles at build time, so a build promoted from staging to production keeps its staging tint (see rails/rails#44211 for the friction this causes).
  • It's coupled to a JS build pipeline, which doesn't map cleanly onto a server-rendered Rails app that may or may not use Hotwire, Inertia, or a bundler at all.

The Solution

EnvStyle serves the favicon from a route it owns and tints it when the request comes in, based on the environment resolved at that moment. A build promoted to production re-resolves and serves the untouched icon — no rebuild, no stale tint. Because the whole mechanism is a server route plus one <link> tag in your layout <head>, it is completely framework agnostic: no JavaScript, no coupling to Turbo, Stimulus, Inertia, or Vue.

  • Runtime, not build time. The environment is resolved per request.
  • Production untouched. Production is never tinted and does zero image work.
  • Framework agnostic. A route and a <link> tag — that's the entire integration surface.
  • Zero-config. With no initializer, it auto-discovers your icon and uses a built-in color map. Adding the helper is the only required step.

Requirements

  • Ruby >= 3.2
  • Rails >= 7.2 (railties, actionpack, activesupport)
  • libvips — only for tinting raster icons (PNG/ICO). SVG icons need no system dependency. libvips already ships in the default Rails 8 Dockerfile (it is Active Storage's default variant processor), so most modern apps already have it.

Installation

Add the gem to your Gemfile:

gem "env_style"

Install it:

bundle install

If you serve a raster favicon (PNG/ICO), install libvips (skip this for SVG-only apps):

# macOS
brew install vips

# Debian / Ubuntu
apt-get install -y libvips

Mount the engine in config/routes.rb:

Rails.application.routes.draw do
  mount EnvStyle::Engine => "/env_style"
end

Quick Start

Three snippets and you're done. First, the Gemfile (above). Then mount the engine (above). Then add the helper to your layout <head> — the same line whether you use Hotwire or Inertia:

<%# app/views/layouts/application.html.erb %>
<head>
  <%= env_style_favicon_tags %>
</head>

That's it. With no initializer, EnvStyle auto-discovers public/icon.svg (or public/icon.png, public/favicon.svg, public/favicon.ico), tints it blue in development and amber in staging/preview, and leaves production untouched.

Usage

Environment resolution

The environment name is resolved in this order (mirroring env.style):

  1. config.environment (explicit override)
  2. ENV["ENV_STYLE_ENV"]
  3. Rails.env

The resolved name is mapped to a color via config.colors, falling back to default_color for any non-production environment that isn't listed. production is never tinted, and a nil default_color leaves unknown environments untouched too.

Colors

The built-in map covers the common environments:

{
  "development" => "#3b82f6", # blue
  "staging"     => "#f59e0b", # amber
  "preview"     => "#f59e0b"  # amber
}

Override or extend it in the initializer (see Configuration). Any CSS color string works for SVG sources; raster sources require a hex color.

Source resolution

You never need the icon's public URL — only its bytes on disk — so its location and asset pipeline are irrelevant. Resolution has two tiers:

Auto-discovery (default). The first existing path wins, preferring SVG:

public/icon.svg  →  public/icon.png  →  public/favicon.svg  →  public/favicon.ico

Explicit config.source (override). A Rails.root-relative path (string or Pathname) always wins, for any non-conventional name or location:

c.source = Rails.root.join("app/frontend/images/logo.svg")

The path must resolve within Rails.root — absolute paths under the root are fine, but a path that escapes it (via .. or an unrelated absolute path) is ignored, so deriving source from untrusted input can't be used to read arbitrary files.

Per-environment icons

Pass a hash to use a different icon per environment (like env.style). Environments absent from the hash fall back to auto-discovery:

c.source = {
  "development" => "public/icon-dev.svg",
  "staging"     => "public/icon-staging.png"
}

Raster tinting and exclude_colors

Raster icons are recolored with a luminance-preserving tint (shading is kept; the hue shifts to the environment color), with alpha preserved. Colors listed in exclude_colors are left exactly as they are — handy for keeping a white background white:

c.exclude_colors = ["#ffffff"]

An exact-match exclusion can leave a faint tinted halo around the mark, because the anti-aliased edge pixels next to (say) a white background aren't exactly white and so still get tinted. Widen the match with exclude_tolerance — the per-channel sRGB distance (0–255) a pixel may sit from an excluded color and still be preserved. It defaults to 0 (exact match only); a small value such as 8 also keeps the near-matching halo:

c.exclude_colors    = ["#ffffff"]
c.exclude_tolerance = 8

SVG rasterization (opt-in)

By default, SVG sources stay SVG (crisp, scalable, no system dependency). If you specifically want a tinted PNG rendered from an SVG source, opt in:

c.rasterize_svg = true

The PNG is rendered at rasterize_size pixels per edge (default 32) rather than the SVG's intrinsic size, so a small viewBox still yields a crisp favicon on hi-dpi tabs. Bump it for sharper output:

c.rasterize_size = 64

This path uses libvips' SVG loader, which requires a libvips built with librsvg.

Disabling

enabled is the master switch. When it's false, env_style_favicon_tags emits nothing, so no favicon <link> is added to your pages. (The mounted route still responds if requested directly, streaming the source untouched — but nothing links to it.)

c.enabled = !Rails.env.production?

Configuration

An initializer is entirely optional. When you want one:

# config/initializers/env_style.rb
EnvStyle.configure do |c|
  c.enabled        = true                                 # master switch
  c.source         = Rails.root.join("public/icon.svg")   # optional; auto-discovered otherwise
  c.colors         = { "development" => "#3b82f6",
                       "staging"     => "#f59e0b",
                       "preview"     => "#f59e0b" }
  c.default_color  = "#6b7280"                             # unknown non-prod environments
  c.exclude_colors = ["#ffffff"]                           # raster: preserve these pixels
  c.exclude_tolerance = 0                                  # raster: per-channel sRGB match slack
  c.environment    = ENV["ENV_STYLE_ENV"]                  # optional environment override
  c.rasterize_svg  = false                                 # opt-in: render SVG → PNG via libvips
  c.rasterize_size = 32                                    # edge length (px) of the rasterized PNG
end
Option Default Purpose
enabled true Master switch; false stops the helper from emitting a <link> (the route still responds if hit directly).
source nil (auto-discover) Icon path, Pathname, or per-environment hash.
colors built-in map Environment name → tint color.
default_color "#6b7280" Tint for non-production environments not in colors; nil to skip them.
exclude_colors [] Raster pixels to preserve verbatim.
exclude_tolerance 0 Per-channel sRGB distance (0–255) a raster pixel may sit from an exclude_colors entry and still be preserved; 0 matches exactly, a small value keeps the anti-aliased halo.
environment nil Explicit environment override (highest precedence).
rasterize_svg false Render SVG sources to tinted PNG via libvips.
rasterize_size 32 Edge length in pixels of the rasterized PNG (only when rasterize_svg is true); larger renders crisper on hi-dpi tabs.
logger Rails.logger Where the "no source icon found" warning is written.

How It Works

Rendering pipeline

env_style_favicon_tags renders a <link> that points at the engine route (/env_style/favicon.svg or /env_style/favicon.png, chosen from the resolved source). When the browser requests it, EnvStyle::FaviconsController resolves the environment and either tints the source or — for production and any untouched environment — streams the source bytes verbatim.

  • SVG is tinted by rewriting markup: a monochrome mark has its fill/currentColor swapped; a multicolor mark is wrapped in a group carrying a luminance-preserving <feColorMatrix> duotone filter. No image library involved.
  • Raster is tinted with libvips: luminance scales the tint per pixel, alpha is carried through, and exclude_colors pixels are masked back to their originals.

Caching

Tinting happens on demand and is deterministic in (source digest, environment, color, format) — plus exclude_colors for raster output. Rather than keep a server-side cache coherent across workers, EnvStyle leans on HTTP: every response carries a strong ETag over that tuple and Cache-Control: no-cache, must-revalidate. Browsers and proxies cache the icon and revalidate with a conditional request, which the controller answers with a 304 Not Modified before doing any image work — so a favicon is tinted at most once per client cache-miss, and a promoted deploy always re-resolves the environment instead of serving a stale-env icon.

Framework compatibility

The mechanism sits at the Rails/document layer, below your front-end framework:

  • Hotwire. A plain server route plus a layout <head> tag. Turbo never touches it.
  • Inertia. The <link> renders in the root ERB layout on the initial full-page load and persists across client-side visits (the document is never reloaded). The /env_style/favicon.* request carries no X-Inertia header, so Inertia's middleware passes it straight through, and SSR is unaffected because the root layout <head> is still Rails-rendered. Caveat: if your app sets its favicon via Inertia's <Head> component, manage it in one place — a client-side favicon set there would override this one.

Design notes / rejected alternatives

  • No-dependency, SVG-only. Rejected as the sole strategy: Safari has historically cached and mishandled SVG favicons, and many apps ship raster icons. EnvStyle handles SVG with zero dependencies but also supports raster.
  • Pure-Ruby raster gems. chunky_png is PNG-only; pura-image can't recolor. Neither covers the raster tinting requirement.
  • Runtime-detect-with-squircle fallback. Rejected as non-deterministic across machines.
  • libvips. Chosen as the one robust raster backend. It already ships with modern Rails (the default Rails 8 Dockerfile, Active Storage's default variant processor), and the dependency is stated upfront so it is never a surprise.

Operational Notes

  • Production does zero image work. Production is untouched — the controller streams the source bytes with no tinting.
  • Performance. Repeat requests from a client that already has the icon short-circuit to a 304 Not Modified before any tinting, so image work happens only on a genuine cache-miss — a few milliseconds for a small icon. There is no server-side cache to grow or invalidate.
  • libvips. Required only for raster tinting. SVG-only apps never load it (ruby-vips is required lazily).
  • Source changes. Because the ETag includes the source digest, editing the source icon produces a new validator and a freshly tinted result on the next request.
  • Scope. v1 tints the favicon only — no apple-touch or PWA icon variants.

Troubleshooting

  • env_style: no source icon found — set EnvStyle.config.source — no icon was auto- discovered. Add public/icon.svg (or .png), or set config.source. The helper emits nothing and nothing crashes.
  • The favicon isn't tinted in development. Confirm the engine is mounted, the helper is in your layout <head>, and the resolved environment has a color (it isn't production, and default_color isn't nil).
  • A pure-black raster (PNG/ICO) icon barely changes color. Raster tinting is luminance-preserving, so a pixel with zero luminance stays dark for any tint. Prefer an SVG source (the default) or a non-pure-black raster for a visible tint.
  • Vips::Error / "class "svgload" not found" with rasterize_svg = true. Your libvips was built without librsvg. Install a librsvg-enabled libvips, or leave rasterize_svg false and serve tinted SVG.
  • LoadError: cannot load such file -- vips — install libvips (see Installation); it is only needed for raster icons.

Development

bin/setup            # install dependencies
bundle exec rake     # run the tests and RuboCop

Tests run against a bare Rails app under test/dummy that mounts the engine and includes both a Hotwire-style and an Inertia-style layout. To exercise every supported Rails version:

bundle exec appraisal install
bundle exec appraisal rake test

With mise installed, mise run test and mise run appraisal wrap the above.

Contributing

Bug reports and pull requests are welcome at https://github.com/milkstrawai/env_style. Please add tests for any change and keep bundle exec rake green.

License

Released under the MIT License.