env_style 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f87c2b9a5857a93ec7c9d6613a4611a8008a9cdb3d2c9aee4ef4b3eaa4a18155
4
+ data.tar.gz: 6b016e7ba90eba8ee50af1cdbb2288aef0bbaca5a12333468e1ccde9397af496
5
+ SHA512:
6
+ metadata.gz: adc331a51d5f5d17379d0b00c413189b7e884f2a32be3db7beb66bd2a861fca3057d2d42995ee58b44673ce8068811a45c0256b07b279d42a12a4de03ef28934
7
+ data.tar.gz: 6de84f78750a3436f57c6c451a927e872efd38f341a0cc44d0c81a29894cc339006eec937970df5ceced67d6d337edf01299c23260aeb211f59849bcdf00048f
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
6
+
7
+ ## [0.1.0] - 2026-07-07
8
+
9
+ ### Added
10
+
11
+ - Initial release.
12
+ - Mountable Rails engine that tints an application's favicon a distinct color per environment at runtime, leaving production untouched.
13
+ - Environment resolution mirroring env.style: `config.environment` → `ENV["ENV_STYLE_ENV"]` → `Rails.env`, with per-environment colors and a `default_color` for unknown non-production environments.
14
+ - Source resolution with zero-config auto-discovery (`public/icon.svg` → `public/icon.png` → `public/favicon.svg` → `public/favicon.ico`, SVG preferred), an explicit `config.source` override (contained within `Rails.root`), and per-environment source hashes. The served `Content-Type` reflects the source's true media type, sniffed from its magic bytes.
15
+ - SVG tinting with no image library: a `fill`/`stroke`/`currentColor` swap for monochrome marks and a luminance-preserving `<feColorMatrix>` duotone for multicolor marks. Color spellings are canonicalized (3-digit hex, common CSS names) when counting distinctness, so a mark written `#000`/`black`/`#000000` is treated as monochrome.
16
+ - Raster tinting with libvips (`ruby-vips`): a luminance-preserving recolor with alpha preserved and `exclude_colors`, plus `exclude_tolerance` to also preserve the anti-aliased halo of near-matching pixels around an excluded region.
17
+ - Opt-in `rasterize_svg` to render an SVG source to a tinted PNG at a configurable `rasterize_size` (default `32` px), staying crisp on hi-dpi tabs.
18
+ - `env_style_favicon_tags` view helper that emits the favicon `<link>` for the resolved source, working identically in Hotwire and Inertia apps.
19
+ - On-demand, deterministic tinting keyed on `(source digest, environment, color, format, exclude_colors, exclude_tolerance, rasterize_size)`, served with a strong ETag and `Cache-Control: no-cache, must-revalidate`, so browsers cache the icon and revalidate with a `304 Not Modified` before any image work.
20
+ - Graceful degradation: if tinting ever fails, the favicon route serves the untouched source (non-cacheably) instead of raising, matching the never-break-the-page contract.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MilkStraw AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,304 @@
1
+ <p align="center">
2
+ <img src="docs/logo.png" alt="EnvStyle logo" width="160">
3
+ </p>
4
+
5
+ <h1 align="center">EnvStyle</h1>
6
+
7
+ <p align="center">
8
+ <a href="https://badge.fury.io/rb/env_style"><img src="https://badge.fury.io/rb/env_style.svg" alt="Gem Version"></a>
9
+ <a href="https://github.com/milkstrawai/env_style/actions/workflows/ci.yml"><img src="https://github.com/milkstrawai/env_style/actions/workflows/ci.yml/badge.svg" alt="Build Status"></a>
10
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
11
+ <img src="https://img.shields.io/badge/Ruby-%3E%3D%203.2-CC342D.svg" alt="Ruby >= 3.2">
12
+ <img src="https://img.shields.io/badge/Rails-%3E%3D%207.2-D30001.svg" alt="Rails >= 7.2">
13
+ </p>
14
+
15
+ <p align="center">
16
+ <strong>A distinct favicon color per environment, so you never deploy to the wrong tab.</strong>
17
+ </p>
18
+
19
+ 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][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.
20
+
21
+ ## Table of Contents
22
+
23
+ - [The Problem](#the-problem)
24
+ - [The Solution](#the-solution)
25
+ - [Requirements](#requirements)
26
+ - [Installation](#installation)
27
+ - [Quick Start](#quick-start)
28
+ - [Usage](#usage)
29
+ - [Configuration](#configuration)
30
+ - [How It Works](#how-it-works)
31
+ - [Operational Notes](#operational-notes)
32
+ - [Troubleshooting](#troubleshooting)
33
+ - [Development](#development)
34
+ - [Contributing](#contributing)
35
+ - [License](#license)
36
+
37
+ ## The Problem
38
+
39
+ 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.
40
+
41
+ [env.style][env-style] solves this beautifully for build-time front-ends, but it has two gaps for a Rails app:
42
+
43
+ - It restyles at **build time**, so a build promoted from staging to production keeps its staging tint (see [rails/rails#44211][rails-44211] for the friction this causes).
44
+ - 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.
45
+
46
+ ## The Solution
47
+
48
+ 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.
49
+
50
+ - **Runtime, not build time.** The environment is resolved per request.
51
+ - **Production untouched.** Production is never tinted and does zero image work.
52
+ - **Framework agnostic.** A route and a `<link>` tag — that's the entire integration surface.
53
+ - **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.
54
+
55
+ ## Requirements
56
+
57
+ - Ruby >= 3.2
58
+ - Rails >= 7.2 (`railties`, `actionpack`, `activesupport`)
59
+ - [libvips][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.
60
+
61
+ ## Installation
62
+
63
+ Add the gem to your `Gemfile`:
64
+
65
+ ```ruby
66
+ gem "env_style"
67
+ ```
68
+
69
+ Install it:
70
+
71
+ ```sh
72
+ bundle install
73
+ ```
74
+
75
+ If you serve a **raster** favicon (PNG/ICO), install libvips (skip this for SVG-only apps):
76
+
77
+ ```sh
78
+ # macOS
79
+ brew install vips
80
+
81
+ # Debian / Ubuntu
82
+ apt-get install -y libvips
83
+ ```
84
+
85
+ Mount the engine in `config/routes.rb`:
86
+
87
+ ```ruby
88
+ Rails.application.routes.draw do
89
+ mount EnvStyle::Engine => "/env_style"
90
+ end
91
+ ```
92
+
93
+ ## Quick Start
94
+
95
+ 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:
96
+
97
+ ```erb
98
+ <%# app/views/layouts/application.html.erb %>
99
+ <head>
100
+ <%= env_style_favicon_tags %>
101
+ </head>
102
+ ```
103
+
104
+ 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.
105
+
106
+ ## Usage
107
+
108
+ ### Environment resolution
109
+
110
+ The environment name is resolved in this order (mirroring env.style):
111
+
112
+ 1. `config.environment` (explicit override)
113
+ 2. `ENV["ENV_STYLE_ENV"]`
114
+ 3. `Rails.env`
115
+
116
+ 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.
117
+
118
+ ### Colors
119
+
120
+ The built-in map covers the common environments:
121
+
122
+ ```ruby
123
+ {
124
+ "development" => "#3b82f6", # blue
125
+ "staging" => "#f59e0b", # amber
126
+ "preview" => "#f59e0b" # amber
127
+ }
128
+ ```
129
+
130
+ Override or extend it in the initializer (see [Configuration](#configuration)). Any CSS color string works for SVG sources; raster sources require a hex color.
131
+
132
+ ### Source resolution
133
+
134
+ 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:
135
+
136
+ **Auto-discovery (default).** The first existing path wins, preferring SVG:
137
+
138
+ ```
139
+ public/icon.svg → public/icon.png → public/favicon.svg → public/favicon.ico
140
+ ```
141
+
142
+ **Explicit `config.source` (override).** A `Rails.root`-relative path (string or `Pathname`) always wins, for any non-conventional name or location:
143
+
144
+ ```ruby
145
+ c.source = Rails.root.join("app/frontend/images/logo.svg")
146
+ ```
147
+
148
+ 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.
149
+
150
+ ### Per-environment icons
151
+
152
+ Pass a hash to use a different icon per environment (like env.style). Environments absent from the hash fall back to auto-discovery:
153
+
154
+ ```ruby
155
+ c.source = {
156
+ "development" => "public/icon-dev.svg",
157
+ "staging" => "public/icon-staging.png"
158
+ }
159
+ ```
160
+
161
+ ### Raster tinting and `exclude_colors`
162
+
163
+ 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:
164
+
165
+ ```ruby
166
+ c.exclude_colors = ["#ffffff"]
167
+ ```
168
+
169
+ 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:
170
+
171
+ ```ruby
172
+ c.exclude_colors = ["#ffffff"]
173
+ c.exclude_tolerance = 8
174
+ ```
175
+
176
+ ### SVG rasterization (opt-in)
177
+
178
+ 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:
179
+
180
+ ```ruby
181
+ c.rasterize_svg = true
182
+ ```
183
+
184
+ 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:
185
+
186
+ ```ruby
187
+ c.rasterize_size = 64
188
+ ```
189
+
190
+ This path uses libvips' SVG loader, which requires a libvips built with librsvg.
191
+
192
+ ### Disabling
193
+
194
+ `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.)
195
+
196
+ ```ruby
197
+ c.enabled = !Rails.env.production?
198
+ ```
199
+
200
+ ## Configuration
201
+
202
+ An initializer is entirely optional. When you want one:
203
+
204
+ ```ruby
205
+ # config/initializers/env_style.rb
206
+ EnvStyle.configure do |c|
207
+ c.enabled = true # master switch
208
+ c.source = Rails.root.join("public/icon.svg") # optional; auto-discovered otherwise
209
+ c.colors = { "development" => "#3b82f6",
210
+ "staging" => "#f59e0b",
211
+ "preview" => "#f59e0b" }
212
+ c.default_color = "#6b7280" # unknown non-prod environments
213
+ c.exclude_colors = ["#ffffff"] # raster: preserve these pixels
214
+ c.exclude_tolerance = 0 # raster: per-channel sRGB match slack
215
+ c.environment = ENV["ENV_STYLE_ENV"] # optional environment override
216
+ c.rasterize_svg = false # opt-in: render SVG → PNG via libvips
217
+ c.rasterize_size = 32 # edge length (px) of the rasterized PNG
218
+ end
219
+ ```
220
+
221
+ | Option | Default | Purpose |
222
+ |---|---|---|
223
+ | `enabled` | `true` | Master switch; `false` stops the helper from emitting a `<link>` (the route still responds if hit directly). |
224
+ | `source` | `nil` (auto-discover) | Icon path, `Pathname`, or per-environment hash. |
225
+ | `colors` | built-in map | Environment name → tint color. |
226
+ | `default_color` | `"#6b7280"` | Tint for non-production environments not in `colors`; `nil` to skip them. |
227
+ | `exclude_colors` | `[]` | Raster pixels to preserve verbatim. |
228
+ | `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. |
229
+ | `environment` | `nil` | Explicit environment override (highest precedence). |
230
+ | `rasterize_svg` | `false` | Render SVG sources to tinted PNG via libvips. |
231
+ | `rasterize_size` | `32` | Edge length in pixels of the rasterized PNG (only when `rasterize_svg` is `true`); larger renders crisper on hi-dpi tabs. |
232
+ | `logger` | `Rails.logger` | Where the "no source icon found" warning is written. |
233
+
234
+ ## How It Works
235
+
236
+ ### Rendering pipeline
237
+
238
+ `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.
239
+
240
+ - **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.
241
+ - **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.
242
+
243
+ ### Caching
244
+
245
+ 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.
246
+
247
+ ### Framework compatibility
248
+
249
+ The mechanism sits at the Rails/document layer, below your front-end framework:
250
+
251
+ - **Hotwire.** A plain server route plus a layout `<head>` tag. Turbo never touches it.
252
+ - **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.
253
+
254
+ ### Design notes / rejected alternatives
255
+
256
+ - **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.
257
+ - **Pure-Ruby raster gems.** `chunky_png` is PNG-only; `pura-image` can't recolor. Neither covers the raster tinting requirement.
258
+ - **Runtime-detect-with-squircle fallback.** Rejected as non-deterministic across machines.
259
+ - **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.
260
+
261
+ ## Operational Notes
262
+
263
+ - **Production does zero image work.** Production is untouched — the controller streams the source bytes with no tinting.
264
+ - **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.
265
+ - **libvips.** Required only for raster tinting. SVG-only apps never load it (`ruby-vips` is required lazily).
266
+ - **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.
267
+ - **Scope.** v1 tints the favicon only — no apple-touch or PWA icon variants.
268
+
269
+ ## Troubleshooting
270
+
271
+ - **`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.
272
+ - **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`).
273
+ - **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.
274
+ - **`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.
275
+ - **`LoadError: cannot load such file -- vips`** — install libvips (see [Installation](#installation)); it is only needed for raster icons.
276
+
277
+ ## Development
278
+
279
+ ```sh
280
+ bin/setup # install dependencies
281
+ bundle exec rake # run the tests and RuboCop
282
+ ```
283
+
284
+ 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:
285
+
286
+ ```sh
287
+ bundle exec appraisal install
288
+ bundle exec appraisal rake test
289
+ ```
290
+
291
+ With [mise][mise] installed, `mise run test` and `mise run appraisal` wrap the above.
292
+
293
+ ## Contributing
294
+
295
+ 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.
296
+
297
+ ## License
298
+
299
+ Released under the [MIT License](LICENSE.txt).
300
+
301
+ [env-style]: https://github.com/QuadDepo/env.style
302
+ [rails-44211]: https://github.com/rails/rails/issues/44211
303
+ [libvips]: https://www.libvips.org/
304
+ [mise]: https://mise.jdx.dev/
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # Base controller for the engine. It inherits straight from
5
+ # ActionController::Base rather than the host's ApplicationController so the
6
+ # public favicon route is never gated by the host's authentication filters.
7
+ class ApplicationController < ActionController::Base
8
+ end
9
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # Serves the (possibly tinted) favicon. Bytes are deterministic, so the
5
+ # response carries a strong ETag and revalidation headers — a promoted deploy
6
+ # re-resolves the environment instead of serving a stale-env icon.
7
+ class FaviconsController < ApplicationController
8
+ def show
9
+ favicon = Favicon.new(root: Rails.root)
10
+ return head :not_found unless favicon.available?
11
+
12
+ fresh_when(strong_etag: favicon.etag)
13
+ response.cache_control.replace(no_cache: true, extras: ["must-revalidate"])
14
+ return if performed?
15
+
16
+ send_favicon(favicon)
17
+ end
18
+
19
+ private
20
+
21
+ # Tint failures (e.g. a non-hex color on a raster source) must never break
22
+ # the tab: fall back to the untouched source, labeled with the source's own
23
+ # content type so the bytes are never mislabeled.
24
+ def send_favicon(favicon)
25
+ send_data(favicon.bytes, type: favicon.content_type, disposition: "inline")
26
+ rescue StandardError => e
27
+ EnvStyle.logger.warn("env_style: favicon tinting failed (#{e.class}: #{e.message}); serving source untouched")
28
+ serve_untouched(favicon)
29
+ end
30
+
31
+ # The ETag stamped by fresh_when describes the intended tint output, not
32
+ # these fallback bytes, so drop it and mark the response no-store. (Rack's
33
+ # ETag middleware may re-add a weak body-digest validator downstream; that is
34
+ # harmless — it differs from the tinted validator, and no-store forbids
35
+ # caching anyway.) This stops a client from caching the fallback under the
36
+ # tinted validator and then getting a 304 once tinting recovers.
37
+ def serve_untouched(favicon)
38
+ response.headers.delete("ETag")
39
+ response.cache_control.replace(no_store: true)
40
+ send_data(favicon.source_bytes, type: favicon.source_content_type, disposition: "inline")
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # Mixed into the host application's views so a single line in the layout head
5
+ # emits the right <link> for the resolved source and environment.
6
+ module FaviconHelper
7
+ # Emits the favicon <link> tag, or nothing when EnvStyle is disabled or no
8
+ # source icon can be found. The tag always points at the engine route; the
9
+ # controller decides whether to tint or stream the source untouched.
10
+ def env_style_favicon_tags
11
+ return unless EnvStyle.config.enabled
12
+
13
+ favicon = EnvStyle::Favicon.new(root: Rails.root)
14
+ return unless favicon.available?
15
+
16
+ href = favicon.format == EnvStyle::Favicon::PNG ? env_style.png_favicon_path : env_style.svg_favicon_path
17
+ tag.link(rel: "icon", type: favicon.content_type, href: href)
18
+ end
19
+ end
20
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ EnvStyle::Engine.routes.draw do
4
+ get "favicon.svg", to: "favicons#show", as: :svg_favicon, defaults: { format: "svg" }
5
+ get "favicon.png", to: "favicons#show", as: :png_favicon, defaults: { format: "png" }
6
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # Built-in per-environment tint colors. Production is intentionally absent so
5
+ # it is never tinted; unknown non-production environments fall back to
6
+ # {Configuration#default_color}.
7
+ DEFAULT_COLORS = {
8
+ "development" => "#3b82f6",
9
+ "staging" => "#f59e0b",
10
+ "preview" => "#f59e0b"
11
+ }.freeze
12
+
13
+ # Neutral gray used for non-production environments with no explicit color.
14
+ DEFAULT_COLOR = "#6b7280"
15
+
16
+ # Holds every knob EnvStyle exposes. Defaults make the gem work with no
17
+ # initializer at all; a host app overrides what it needs.
18
+ class Configuration
19
+ # Master switch. When false, the helper emits no favicon link (the route
20
+ # still serves the source untouched if requested directly).
21
+ attr_accessor :enabled
22
+
23
+ # Source icon path (String/Pathname, Rails.root-relative) or a per-env Hash.
24
+ # nil means auto-discover (see EnvStyle::Source).
25
+ attr_accessor :source
26
+
27
+ # Environment name => tint color (any CSS color string).
28
+ attr_accessor :colors
29
+
30
+ # Tint color for non-production environments not listed in #colors.
31
+ attr_accessor :default_color
32
+
33
+ # Colors preserved as-is when tinting raster icons (e.g. white backgrounds).
34
+ attr_accessor :exclude_colors
35
+
36
+ # Per-channel sRGB tolerance (0..255) for #exclude_colors matching. 0 keeps
37
+ # only exact matches; a higher value also preserves the anti-aliased halo of
38
+ # near-matching pixels around an excluded region.
39
+ attr_accessor :exclude_tolerance
40
+
41
+ # Explicit environment override, highest precedence in resolution.
42
+ attr_accessor :environment
43
+
44
+ # When true, SVG sources are rasterized to PNG via libvips instead of
45
+ # served as tinted SVG.
46
+ attr_accessor :rasterize_svg
47
+
48
+ # Edge length in pixels of the rasterized PNG when #rasterize_svg is true.
49
+ # Larger values render crisper favicons on hi-dpi tabs.
50
+ attr_accessor :rasterize_size
51
+
52
+ # Logger for EnvStyle warnings; defaults to Rails.logger at runtime.
53
+ attr_accessor :logger
54
+
55
+ def initialize
56
+ @enabled = true
57
+ @source = nil
58
+ @colors = DEFAULT_COLORS.dup
59
+ @default_color = DEFAULT_COLOR
60
+ @exclude_colors = []
61
+ @exclude_tolerance = 0
62
+ @environment = nil
63
+ @rasterize_svg = false
64
+ @rasterize_size = 32
65
+ @logger = nil
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # The mountable engine. Isolating the namespace keeps the favicon route and
5
+ # controller from colliding with the host app, and the initializer makes the
6
+ # +env_style_favicon_tags+ helper available in the host's layouts.
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace EnvStyle
9
+
10
+ initializer "env_style.view_helpers" do
11
+ ActiveSupport.on_load(:action_view) do
12
+ include EnvStyle::FaviconHelper
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # Resolves the current environment name and the tint color it maps to.
5
+ #
6
+ # Resolution order mirrors env.style:
7
+ # config.environment -> ENV["ENV_STYLE_ENV"] -> Rails.env
8
+ #
9
+ # Production is never tinted, and a disabled configuration or an environment
10
+ # with no color leaves the favicon untouched.
11
+ class Environment
12
+ PRODUCTION = "production"
13
+
14
+ def initialize(config: EnvStyle.config, rails_env: self.class.rails_env,
15
+ env_style_env: ENV.fetch("ENV_STYLE_ENV", nil))
16
+ @config = config
17
+ @rails_env = presence(rails_env)
18
+ @env_style_env = presence(env_style_env)
19
+ end
20
+
21
+ # The resolved environment name, or nil when it cannot be determined.
22
+ def name
23
+ presence(@config.environment) || @env_style_env || @rails_env
24
+ end
25
+
26
+ # Whether the favicon should be tinted for this environment.
27
+ def tint?
28
+ !color.nil?
29
+ end
30
+
31
+ # The tint color for this environment, or nil when it must stay untouched.
32
+ def color
33
+ return nil unless @config.enabled
34
+ return nil if name.nil? || name == PRODUCTION
35
+
36
+ presence(@config.colors[name]) || presence(@config.default_color)
37
+ end
38
+
39
+ # Rails.env as a string when Rails is loaded, otherwise nil.
40
+ def self.rails_env
41
+ Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
42
+ end
43
+
44
+ private
45
+
46
+ def presence(value)
47
+ string = value.to_s
48
+ string unless string.empty?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module EnvStyle
6
+ # Ties the pieces together: resolves the source and environment, decides the
7
+ # output format, and returns the bytes to serve — tinted for non-production
8
+ # environments, verbatim otherwise.
9
+ #
10
+ # Tinting happens on demand. The result is deterministic, so the strong ETag
11
+ # lets browsers and proxies cache it and revalidate with a 304 — there is no
12
+ # server-side cache to keep coherent across workers.
13
+ class Favicon
14
+ SVG = :svg
15
+ PNG = :png
16
+ CONTENT_TYPES = {
17
+ SVG => SvgTinter::CONTENT_TYPE,
18
+ PNG => RasterTinter::CONTENT_TYPE
19
+ }.freeze
20
+
21
+ def initialize(config: EnvStyle.config, environment: Environment.new(config: config),
22
+ root: Source.rails_root)
23
+ @config = config
24
+ @environment = environment
25
+ @source = Source.new(config: config, root: root, env_name: environment.name)
26
+ end
27
+
28
+ def available?
29
+ @source.found?
30
+ end
31
+
32
+ # The format of the bytes actually served. An untouched favicon streams the
33
+ # source verbatim, so its format (and content type) follow the source — never
34
+ # the tint-output format, which would mislabel the bytes.
35
+ def format
36
+ return @format if defined?(@format)
37
+
38
+ @format = tinted? ? tinted_format : source_format
39
+ end
40
+
41
+ # The content type of the bytes actually served: the tint-output type when
42
+ # tinted, otherwise the source's own media type. A non-PNG raster served
43
+ # untouched (say a real favicon.ico) is labeled with its true type rather
44
+ # than the .png route it happens to travel.
45
+ def content_type
46
+ tinted? ? CONTENT_TYPES.fetch(format) : source_content_type
47
+ end
48
+
49
+ # The media type of the untouched source, used on the degradation path so
50
+ # source bytes are never mislabeled with the tint-output type.
51
+ def source_content_type
52
+ @source.content_type
53
+ end
54
+
55
+ def tinted?
56
+ @environment.tint?
57
+ end
58
+
59
+ # A strong validator for the served bytes. exclude_colors and its matching
60
+ # tolerance are folded in for raster output: both change which pixels are preserved.
61
+ def etag
62
+ @etag ||= Digest::SHA256.hexdigest(etag_inputs.join("|"))
63
+ end
64
+
65
+ # Bytes to serve: tinted when the environment calls for it, otherwise the
66
+ # untouched source (zero image work).
67
+ def bytes
68
+ return @bytes if defined?(@bytes)
69
+
70
+ @bytes = tinted? ? render : source_bytes
71
+ end
72
+
73
+ # The untouched source bytes, used both for passthrough and as the graceful
74
+ # fallback when tinting fails.
75
+ def source_bytes
76
+ @source.read
77
+ end
78
+
79
+ private
80
+
81
+ def tinted_format
82
+ @source.raster? || @config.rasterize_svg ? PNG : SVG
83
+ end
84
+
85
+ def source_format
86
+ @source.raster? ? PNG : SVG
87
+ end
88
+
89
+ def etag_inputs
90
+ inputs = [@source.digest, @environment.name, @environment.color, format]
91
+ inputs.push(@config.exclude_colors.join(","), @config.exclude_tolerance, @config.rasterize_size) if format == PNG
92
+ inputs
93
+ end
94
+
95
+ def render
96
+ case format
97
+ when SVG
98
+ SvgTinter.new(color: @environment.color).call(@source.read)
99
+ when PNG
100
+ RasterTinter.new(color: @environment.color, exclude_colors: @config.exclude_colors,
101
+ tolerance: @config.exclude_tolerance)
102
+ .call(raster_source_bytes)
103
+ end
104
+ end
105
+
106
+ def raster_source_bytes
107
+ @source.raster? ? @source.read : rasterize_svg(@source.read)
108
+ end
109
+
110
+ # Rasterize at a target pixel size rather than the SVG intrinsic size: a
111
+ # small viewBox would otherwise yield a low-res favicon on hi-dpi tabs.
112
+ # thumbnail_buffer drives the SVG loader scale to hit +rasterize_size+.
113
+ def rasterize_svg(svg_bytes)
114
+ require "vips"
115
+
116
+ Vips::Image.thumbnail_buffer(svg_bytes, @config.rasterize_size).write_to_buffer(".png")
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # Tints a raster icon (PNG/ICO/...) with libvips through ruby-vips.
5
+ #
6
+ # The recolor is luminance-preserving: each pixel's perceptual brightness
7
+ # scales the tint color, so shading survives while the hue shifts. Alpha is
8
+ # carried through untouched, and any pixel within +tolerance+ of a color in
9
+ # +exclude_colors+ (for example a white background) is left exactly as it was.
10
+ # A +tolerance+ of 0 keeps only exact matches; a larger value also preserves
11
+ # the anti-aliased halo of near-matching pixels around an excluded region.
12
+ #
13
+ # +vips+ is required lazily so SVG-only applications never need libvips.
14
+ class RasterTinter
15
+ CONTENT_TYPE = "image/png"
16
+
17
+ def initialize(color:, exclude_colors: [], tolerance: 0)
18
+ @color = color.to_s
19
+ @exclude_colors = Array(exclude_colors)
20
+ @tolerance = tolerance
21
+ end
22
+
23
+ def content_type
24
+ CONTENT_TYPE
25
+ end
26
+
27
+ def call(bytes)
28
+ require "vips"
29
+
30
+ image = Vips::Image.new_from_buffer(bytes, "").colourspace("srgb")
31
+ alpha = image.bands >= 4 ? image.extract_band(3) : nil
32
+ rgb = image.extract_band(0, n: 3)
33
+
34
+ recolored = apply_exclusions(rgb, tint(rgb))
35
+ recolored = recolored.bandjoin(alpha) if alpha
36
+ recolored.cast(:uchar).copy(interpretation: :srgb).write_to_buffer(".png")
37
+ end
38
+
39
+ private
40
+
41
+ # Scale the tint by each pixel's luminance (0..1), preserving shading.
42
+ def tint(rgb)
43
+ red, green, blue = rgb_channels(@color)
44
+ scale = rgb.colourspace("b-w").cast(:float) / 255.0
45
+
46
+ (scale * red).bandjoin([scale * green, scale * blue]).cast(:uchar)
47
+ end
48
+
49
+ # Keep original pixels wherever they are within tolerance of an excluded color.
50
+ def apply_exclusions(rgb, tinted)
51
+ mask = exclusion_mask(rgb)
52
+ return tinted if mask.nil?
53
+
54
+ mask.ifthenelse(rgb, tinted)
55
+ end
56
+
57
+ def exclusion_mask(rgb)
58
+ masks = @exclude_colors.map { |color| color_match_mask(rgb, color) }
59
+ masks.reduce(:|)
60
+ end
61
+
62
+ # A pixel matches when it is within +tolerance+ of the target on every channel.
63
+ # Casting to float first avoids uchar underflow when a channel sits below the
64
+ # target; with tolerance 0 this reduces to an exact per-channel equality.
65
+ def color_match_mask(rgb, color)
66
+ distance = (rgb.cast(:float) - rgb_channels(color)).abs
67
+ distance.relational_const(:lesseq, @tolerance).bandand
68
+ end
69
+
70
+ def rgb_channels(color)
71
+ hex = color.to_s.delete_prefix("#")
72
+ hex = hex.chars.map { |char| char * 2 }.join if hex.length == 3
73
+ unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
74
+ raise ConfigurationError, "env_style: raster tinting needs a hex color, got #{color.inspect}"
75
+ end
76
+
77
+ hex.scan(/../).map { |pair| pair.to_i(16) }
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "digest"
5
+
6
+ module EnvStyle
7
+ # Resolves and reads the source icon: the file whose bytes we tint and serve.
8
+ #
9
+ # Resolution has two tiers:
10
+ # 1. Explicit +config.source+ (a Rails.root-relative path/Pathname, or a
11
+ # per-environment Hash) always wins.
12
+ # 2. Otherwise auto-discovery walks a conventional list, preferring SVG.
13
+ #
14
+ # Nothing found never raises: it logs an actionable warning (outside
15
+ # production) and reports itself as not found.
16
+ class Source
17
+ # Auto-discovery candidates, in priority order. SVG is preferred over raster.
18
+ AUTO_DISCOVERY = %w[
19
+ public/icon.svg
20
+ public/icon.png
21
+ public/favicon.svg
22
+ public/favicon.ico
23
+ ].freeze
24
+
25
+ # Sniffs SVG by content so a mislabeled extension still resolves correctly.
26
+ SVG_SNIFF = /<svg[\s>]/i
27
+
28
+ SVG_MEDIA_TYPE = "image/svg+xml"
29
+
30
+ # Raster media types keyed by their leading magic bytes, checked in order.
31
+ # WEBP is matched separately because its "WEBP" marker sits at byte 8, past
32
+ # the leading "RIFF" chunk id.
33
+ RASTER_SIGNATURES = {
34
+ "\x89PNG".b => "image/png",
35
+ "\x00\x00\x01\x00".b => "image/vnd.microsoft.icon",
36
+ "GIF8".b => "image/gif",
37
+ "\xFF\xD8\xFF".b => "image/jpeg",
38
+ "BM".b => "image/bmp"
39
+ }.freeze
40
+
41
+ # Media type for an unrecognized raster. Conservative on purpose: the only
42
+ # raster engine route is +.png+, and browsers sniff the bytes themselves, so
43
+ # a wrong-but-harmless label never breaks the tab.
44
+ DEFAULT_RASTER_MEDIA_TYPE = "image/png"
45
+
46
+ MISSING_MESSAGE = "env_style: no source icon found — set EnvStyle.config.source"
47
+
48
+ def initialize(config: EnvStyle.config, root: self.class.rails_root, env_name: nil)
49
+ @config = config
50
+ @root = root && Pathname(root)
51
+ @env_name = env_name
52
+ end
53
+
54
+ # The resolved icon path, or nil when nothing was found.
55
+ def path
56
+ return @path if defined?(@path)
57
+
58
+ @path = resolve_path
59
+ end
60
+
61
+ def found?
62
+ !path.nil?
63
+ end
64
+
65
+ # Raw binary bytes of the source icon, or nil when not found.
66
+ def read
67
+ return @read if defined?(@read)
68
+
69
+ @read = path&.binread
70
+ end
71
+
72
+ # :svg or :raster, decided by content; nil when not found.
73
+ def format
74
+ return nil unless found?
75
+
76
+ svg_content? ? :svg : :raster
77
+ end
78
+
79
+ def svg?
80
+ format == :svg
81
+ end
82
+
83
+ def raster?
84
+ format == :raster
85
+ end
86
+
87
+ # The true media type of the resolved source, sniffed from its bytes; nil
88
+ # when not found. SVG keeps the content-based sniff (a mislabeled extension
89
+ # still resolves), every other type is matched on its magic bytes, and an
90
+ # unrecognized raster falls back to {DEFAULT_RASTER_MEDIA_TYPE}. Unlike
91
+ # +format+, this distinguishes non-PNG rasters so untouched passthrough bytes
92
+ # are labeled with what they actually are.
93
+ def content_type
94
+ return nil unless found?
95
+ return SVG_MEDIA_TYPE if svg?
96
+
97
+ raster_content_type
98
+ end
99
+
100
+ # Content digest used to key caches and ETags; nil when not found.
101
+ def digest
102
+ return nil unless found?
103
+
104
+ Digest::SHA256.hexdigest(read)
105
+ end
106
+
107
+ def self.rails_root
108
+ Rails.root if defined?(Rails) && Rails.respond_to?(:root)
109
+ end
110
+
111
+ private
112
+
113
+ def resolve_path
114
+ candidate_path || warn_missing
115
+ end
116
+
117
+ def candidate_path
118
+ case @config.source
119
+ when Hash then hash_source_path
120
+ when nil then auto_discovered_path
121
+ else file_at(@config.source)
122
+ end
123
+ end
124
+
125
+ def hash_source_path
126
+ raw = @config.source[@env_name]
127
+ raw.nil? ? auto_discovered_path : file_at(raw)
128
+ end
129
+
130
+ def auto_discovered_path
131
+ return nil if @root.nil?
132
+
133
+ AUTO_DISCOVERY.map { |relative| @root.join(relative) }.find(&:file?)
134
+ end
135
+
136
+ def file_at(raw)
137
+ return nil if @root.nil?
138
+
139
+ candidate = @root.join(raw.to_s)
140
+ return nil unless candidate.file?
141
+
142
+ within_root?(candidate) ? candidate : nil
143
+ end
144
+
145
+ # Keeps an explicit source (relative, absolute, traversal, or symlink) from
146
+ # escaping Rails.root. realpath resolves symlinks and "..", so only files
147
+ # physically under the root are accepted; absolute paths under it still work.
148
+ def within_root?(path)
149
+ real = path.realpath
150
+ root = @root.realpath
151
+ real == root || real.to_s.start_with?("#{root}/")
152
+ rescue SystemCallError
153
+ false
154
+ end
155
+
156
+ def svg_content?
157
+ head = read.to_s.byteslice(0, 1024).to_s.force_encoding(Encoding::BINARY)
158
+ head.match?(SVG_SNIFF)
159
+ end
160
+
161
+ def raster_content_type
162
+ head = read.to_s.byteslice(0, 16).to_s.force_encoding(Encoding::BINARY)
163
+ return "image/webp" if webp?(head)
164
+
165
+ RASTER_SIGNATURES.each { |magic, media_type| return media_type if head.start_with?(magic) }
166
+ DEFAULT_RASTER_MEDIA_TYPE
167
+ end
168
+
169
+ def webp?(head)
170
+ head.start_with?("RIFF".b) && head.byteslice(8, 4) == "WEBP".b
171
+ end
172
+
173
+ def warn_missing
174
+ logger.warn(MISSING_MESSAGE) unless @env_name == Environment::PRODUCTION
175
+ nil
176
+ end
177
+
178
+ def logger
179
+ @config.logger || EnvStyle.logger
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ # Tints an SVG icon by rewriting its markup — no image library required.
5
+ #
6
+ # A monochrome mark (one color, or currentColor, or an implicit default) has
7
+ # its fill/stroke swapped for the tint. A multicolor mark is wrapped in a
8
+ # group carrying a luminance-preserving <feColorMatrix> duotone filter, so the
9
+ # artwork keeps its shading while shifting to the tint hue.
10
+ class SvgTinter
11
+ CONTENT_TYPE = "image/svg+xml"
12
+ FILTER_ID = "env-style-tint"
13
+
14
+ # Values that are not real colors and must never be swapped.
15
+ NON_COLORS = ["none", "transparent", "inherit", "currentcolor", ""].freeze
16
+
17
+ # Common CSS named colors mapped to canonical 6-digit hex, so that a mark
18
+ # written as "black" and "#000" is recognised as one color when counting
19
+ # distinctness. Used only for the count — never for the output markup.
20
+ CSS_COLORS = {
21
+ "aqua" => "#00ffff",
22
+ "black" => "#000000",
23
+ "blue" => "#0000ff",
24
+ "cyan" => "#00ffff",
25
+ "fuchsia" => "#ff00ff",
26
+ "gray" => "#808080",
27
+ "green" => "#008000",
28
+ "grey" => "#808080",
29
+ "lime" => "#00ff00",
30
+ "magenta" => "#ff00ff",
31
+ "maroon" => "#800000",
32
+ "navy" => "#000080",
33
+ "olive" => "#808000",
34
+ "orange" => "#ffa500",
35
+ "purple" => "#800080",
36
+ "red" => "#ff0000",
37
+ "silver" => "#c0c0c0",
38
+ "teal" => "#008080",
39
+ "white" => "#ffffff",
40
+ "yellow" => "#ffff00"
41
+ }.freeze
42
+
43
+ # Luminance weights (Rec. 709) used to preserve shading in the duotone.
44
+ LUMINANCE = [0.2126, 0.7152, 0.0722].freeze
45
+
46
+ # A CSS color value inside a style declaration: a hex, a functional notation
47
+ # (which may contain spaces/commas), or a plain token. Anchored so it never
48
+ # runs past the declaration's ; or the attribute's closing quote.
49
+ STYLE_VALUE = /#[0-9a-fA-F]+|(?:rgb|hsl)a?\([^)]*\)|[^;"'\s)]+/
50
+
51
+ def initialize(color:)
52
+ @color = color.to_s
53
+ end
54
+
55
+ def content_type
56
+ CONTENT_TYPE
57
+ end
58
+
59
+ def call(svg)
60
+ svg = svg.to_s
61
+ colors = distinct_colors(svg)
62
+ if colors.size >= 2
63
+ duotone(svg, colored: colors.any?)
64
+ else
65
+ monochrome(svg, colored: colors.any?)
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def monochrome(svg, colored:)
72
+ had_color = colored || svg.match?(/currentColor/i)
73
+
74
+ out = svg.gsub(/currentColor/i, @color)
75
+ out = swap_attributes(out)
76
+ out = swap_style(out)
77
+ out = inject_root_fill(out) unless had_color
78
+ out
79
+ end
80
+
81
+ def duotone(svg, colored:)
82
+ rgb = rgb_floats(@color)
83
+ return monochrome(svg, colored: colored) if rgb.nil?
84
+
85
+ filter = %(<feColorMatrix type="matrix" values="#{color_matrix(rgb)}"/>)
86
+ defs = %(<defs><filter id="#{FILTER_ID}" color-interpolation-filters="sRGB">#{filter}</filter></defs>)
87
+
88
+ svg.sub(%r{(<svg\b[^>]*>)(.*)(</svg>)}mi) do
89
+ group = %(<g filter="url(##{FILTER_ID})">#{Regexp.last_match(2)}</g>)
90
+ "#{Regexp.last_match(1)}#{defs}#{group}#{Regexp.last_match(3)}"
91
+ end
92
+ end
93
+
94
+ def swap_attributes(svg)
95
+ svg.gsub(/((?:fill|stroke|stop-color)\s*=\s*)(["'])(.*?)\2/i) do
96
+ prefix = Regexp.last_match(1)
97
+ quote = Regexp.last_match(2)
98
+ value = Regexp.last_match(3)
99
+ paint = keep?(value) ? value : @color
100
+ "#{prefix}#{quote}#{paint}#{quote}"
101
+ end
102
+ end
103
+
104
+ def swap_style(svg)
105
+ svg.gsub(/\b(fill|stroke|stop-color)\s*:\s*(#{STYLE_VALUE})/i) do
106
+ property = Regexp.last_match(1)
107
+ value = Regexp.last_match(2)
108
+ keep?(value) ? Regexp.last_match(0) : "#{property}:#{@color}"
109
+ end
110
+ end
111
+
112
+ def inject_root_fill(svg)
113
+ svg.sub(/<svg\b/i) { |match| %(#{match} fill="#{@color}") }
114
+ end
115
+
116
+ def distinct_colors(svg)
117
+ colors = svg.scan(/(?:fill|stroke|stop-color)\s*=\s*["']([^"']+)["']/i).flatten
118
+ colors += svg.scan(/(?:fill|stroke|stop-color)\s*:\s*(#{STYLE_VALUE})/i).flatten
119
+ colors.map { |color| color.strip.downcase }.reject { |color| keep?(color) }.map { |color| canonical(color) }.uniq
120
+ end
121
+
122
+ # Canonicalizes an already-stripped, downcased color token so that
123
+ # equivalent spellings collapse: named colors become hex and 3-digit hex
124
+ # expands to 6 digits. Anything else (functional notations, unknown names)
125
+ # is returned unchanged.
126
+ def canonical(color)
127
+ color = CSS_COLORS.fetch(color, color)
128
+ return color unless color.start_with?("#")
129
+
130
+ hex = color.delete_prefix("#")
131
+ hex = hex.chars.map { |char| char * 2 }.join if hex.length == 3
132
+ "##{hex}"
133
+ end
134
+
135
+ def keep?(value)
136
+ normalized = value.strip.downcase
137
+ NON_COLORS.include?(normalized) || normalized.start_with?("url(")
138
+ end
139
+
140
+ def color_matrix(rgb)
141
+ rows = rgb.map do |channel|
142
+ weights = LUMINANCE.map { |weight| format("%.4f", weight * channel) }.join(" ")
143
+ "#{weights} 0 0"
144
+ end
145
+ (rows + ["0 0 0 1 0"]).join(" ")
146
+ end
147
+
148
+ def rgb_floats(color)
149
+ hex = color.delete_prefix("#")
150
+ hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
151
+ return nil unless hex.match?(/\A[0-9a-fA-F]{6}\z/)
152
+
153
+ hex.scan(/../).map { |pair| pair.to_i(16) / 255.0 }
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnvStyle
4
+ VERSION = "0.1.0"
5
+ end
data/lib/env_style.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+
5
+ require_relative "env_style/version"
6
+ require_relative "env_style/configuration"
7
+ require_relative "env_style/environment"
8
+ require_relative "env_style/source"
9
+ require_relative "env_style/svg_tinter"
10
+ require_relative "env_style/raster_tinter"
11
+ require_relative "env_style/favicon"
12
+
13
+ # EnvStyle tints an application's favicon a distinct color per environment, so
14
+ # side-by-side browser tabs are visually distinguishable. Production is left
15
+ # untouched, and setting {Configuration#enabled} to false stops the helper from
16
+ # adding the favicon link.
17
+ module EnvStyle
18
+ # Base class for every error EnvStyle raises.
19
+ class Error < StandardError; end
20
+
21
+ # Raised when the gem is misconfigured (bad color, unreadable source, ...).
22
+ class ConfigurationError < Error; end
23
+
24
+ class << self
25
+ # The process-wide configuration. Memoized; reset with {reset_configuration!}.
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+ alias config configuration
30
+
31
+ # Yields the configuration for block-style setup.
32
+ #
33
+ # EnvStyle.configure do |c|
34
+ # c.colors = { "staging" => "#f59e0b" }
35
+ # end
36
+ def configure
37
+ yield(configuration)
38
+ end
39
+
40
+ # Rebuilds the configuration from defaults. Primarily used by tests.
41
+ def reset_configuration!
42
+ @configuration = Configuration.new
43
+ end
44
+
45
+ # The logger EnvStyle writes warnings to: the configured logger, else
46
+ # Rails' logger when Rails is loaded, else a plain STDOUT logger.
47
+ def logger
48
+ configuration.logger || (Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)) ||
49
+ Logger.new($stdout)
50
+ end
51
+ end
52
+ end
53
+
54
+ require_relative "env_style/engine" if defined?(Rails::Engine)
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: env_style
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ali Hamdi Ali Fadel
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ruby-vips
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.2'
68
+ description: EnvStyle is a mountable Rails engine that tints your app's favicon a
69
+ distinct color per environment at runtime, so side-by-side browser tabs are visually
70
+ distinguishable. It adds a route and a <link> tag to your layout, works with any
71
+ front-end (Hotwire, Inertia, or plain Rails), and leaves production untouched.
72
+ email:
73
+ - aliosm1997@gmail.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE.txt
80
+ - README.md
81
+ - app/controllers/env_style/application_controller.rb
82
+ - app/controllers/env_style/favicons_controller.rb
83
+ - app/helpers/env_style/favicon_helper.rb
84
+ - config/routes.rb
85
+ - lib/env_style.rb
86
+ - lib/env_style/configuration.rb
87
+ - lib/env_style/engine.rb
88
+ - lib/env_style/environment.rb
89
+ - lib/env_style/favicon.rb
90
+ - lib/env_style/raster_tinter.rb
91
+ - lib/env_style/source.rb
92
+ - lib/env_style/svg_tinter.rb
93
+ - lib/env_style/version.rb
94
+ homepage: https://github.com/milkstrawai/env_style
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ allowed_push_host: https://rubygems.org
99
+ homepage_uri: https://github.com/milkstrawai/env_style
100
+ source_code_uri: https://github.com/milkstrawai/env_style
101
+ changelog_uri: https://github.com/milkstrawai/env_style/blob/main/CHANGELOG.md
102
+ rubygems_mfa_required: 'true'
103
+ post_install_message: |
104
+ env_style tints raster favicons (PNG/ICO) with libvips through ruby-vips.
105
+ Install libvips if it is not already present:
106
+
107
+ macOS: brew install vips
108
+ Debian: apt-get install -y libvips
109
+
110
+ SVG favicons need no system dependency, and libvips already ships in the
111
+ default Rails 8 Dockerfile, so most modern apps have it already.
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: 3.2.0
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 3.6.9
127
+ specification_version: 4
128
+ summary: Per-environment favicon tinting for Rails.
129
+ test_files: []