favicon-env 0.2.0 → 0.3.1

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.
package/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  Tint your favicon per environment so you can tell instances apart at a glance — no more staring at three identical tabs wondering which one is production.
4
4
 
5
- ![favicon-env — the same base icon per environment: prod, dev (hue-shift), dev (exact-colour tint), staging (dot), a "#344" preview cover, and a custom image](docs/hero.png)
5
+ ![favicon-env — the same base icon per environment: prod, dev (hue-shift), dev (exact-colour tint), dev (invert), staging (dot), a "#344" preview cover, and a custom image](docs/hero.png)
6
6
 
7
7
  **[▶ Live, clickable demo](https://amir-abushanab.github.io/favicon-env/)**
8
8
 
9
- - **Runtime mode** — one import, any framework, any deploy. Detects the environment in the browser and re-tints the favicon on a `<canvas>`, so it works with whatever favicon you already have (svg / png / ico).
10
- - **Build-time mode** — a tiny SVG helper that bakes the tint in at build/SSR time, for zero first-paint flash.
11
- - **Zero dependencies.** ~2.3 kB min+gzip (the build-time SSR helper alone is ~1.7 kB).
9
+ - **Runtime mode** — tint an existing SVG, PNG, or ICO in the browser.
10
+ - **Build-time mode** — bake changes into an SVG during build or SSR.
11
+ - **Zero dependencies.** ~2.3 kB min+gzip and [tree-shakes to zero bytes in prod](#zero-bytes-in-prod).
12
12
 
13
13
  ## Install
14
14
 
@@ -16,42 +16,102 @@ Tint your favicon per environment so you can tell instances apart at a glance
16
16
  pnpm add favicon-env
17
17
  ```
18
18
 
19
- > **Using an AI coding agent?** favicon-env ships an [Agent Skill](https://tanstack.com/intent) (via TanStack Intent) run `npx @tanstack/intent@latest install` and your agent picks up the correct patterns (framework placement, badges, runtime vs SSR) straight from the package, versioned with it.
19
+ > **`dependencies` or `devDependencies`?** Either works — every mode below resolves the import during your build, so nothing looks `favicon-env` up at runtime on the deployed host, staging included. `dependencies` is the safer default: npm reads `NODE_ENV=production` as `--omit=dev`, so a CI or PaaS builder with that set skips a devDependency and fails the build on `Cannot find module 'favicon-env'` (pnpm doesn't couple the two). `-D` is defensible if you only use [build-time mode](#build-time--ssr-mode) and want a clean production audit. Neither costs you bundle size — [it tree-shakes to zero bytes in prod](#zero-bytes-in-prod).
20
+
21
+ > **Using an AI coding agent?** Install the bundled [Agent Skill](https://tanstack.com/intent) with `pnpm dlx @tanstack/intent@latest install`.
20
22
 
21
23
  ## Runtime mode
22
24
 
23
25
  ```js
24
- import { envFavicon } from 'favicon-env'
26
+ import { envFavicon } from 'favicon-env';
25
27
 
26
28
  envFavicon({
27
29
  environments: {
28
- dev: { hue: 130 }, // hue-rotate degrees
29
- staging: { badge: '#f59e0b' }, // …or a corner dot that keeps the logo intact
30
- // prod omitted → left untouched
30
+ dev: { tint: '#22c55e' },
31
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
31
32
  },
32
- })
33
+ });
33
34
  ```
34
35
 
35
- Call it once on the client, as early as you can — it reads the current `<link rel="icon">`, redraws it tinted, and swaps it in. It's a no-op during SSR (it guards on `document`), so it's safe to import anywhere. Where it goes in the common setups:
36
+ ### Zero bytes in prod
37
+
38
+ An unconditional call stays bundled even when `prod` is absent from `environments`. To remove the runtime, put `import('favicon-env')` behind a compile-time environment check. The examples below use this pattern, and the framework matrix scans their emitted prod assets to verify exclusion.
39
+
40
+ Call it on the client after the favicon link exists:
36
41
 
37
42
  <details>
38
43
  <summary><b>Next.js</b> — App Router</summary>
39
44
 
40
45
  ```tsx
41
46
  // app/favicon-env.tsx — a client component
42
- 'use client'
43
- import { useEffect } from 'react'
44
- import { envFavicon } from 'favicon-env'
47
+ 'use client';
48
+ import { useEffect } from 'react';
49
+
50
+ const appEnv = process.env.NEXT_PUBLIC_APP_ENV ?? 'prod';
45
51
 
46
52
  export function FaviconEnv() {
47
53
  useEffect(() => {
48
- void envFavicon({ /* …environments, as above… */ })
49
- }, [])
50
- return null
54
+ if (appEnv !== 'dev' && appEnv !== 'staging') return;
55
+
56
+ let queued = false;
57
+ const apply = async () => {
58
+ queued = false;
59
+ const { envFavicon } = await import('favicon-env');
60
+ await envFavicon({
61
+ environments: {
62
+ dev: { tint: '#22c55e' },
63
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
64
+ },
65
+ detect: () => appEnv,
66
+ });
67
+ };
68
+ const schedule = () => {
69
+ if (queued) return;
70
+ queued = true;
71
+ queueMicrotask(apply);
72
+ };
73
+ // Next can restore metadata-managed icons during hydration or navigation.
74
+ const observer = new MutationObserver(() => {
75
+ if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
76
+ schedule();
77
+ }
78
+ });
79
+ observer.observe(document.head, {
80
+ childList: true,
81
+ subtree: true,
82
+ attributes: true,
83
+ attributeFilter: ['href', 'rel'],
84
+ });
85
+ schedule();
86
+ return () => observer.disconnect();
87
+ }, []);
88
+ return null;
51
89
  }
52
90
  ```
53
91
 
54
- Then render `<FaviconEnv />` once inside `<body>` in `app/layout.tsx`. `useEffect` is the right hook here — a run-once, client-only side effect, safe under React StrictMode's double-invoke (re-tinting is idempotent). (Next ≥ 15.3: drop the call into `instrumentation-client.ts` and skip the component entirely. Pages Router: put the `useEffect` in `pages/_app.tsx`.)
92
+ ```tsx
93
+ // app/layout.tsx
94
+ import type { Metadata } from 'next';
95
+ import type { ReactNode } from 'react';
96
+ import { FaviconEnv } from './favicon-env';
97
+
98
+ export const metadata: Metadata = {
99
+ icons: { icon: '/favicon.svg' },
100
+ };
101
+
102
+ export default function RootLayout({ children }: { children: ReactNode }) {
103
+ return (
104
+ <html lang="en">
105
+ <body>
106
+ <FaviconEnv />
107
+ {children}
108
+ </body>
109
+ </html>
110
+ );
111
+ }
112
+ ```
113
+
114
+ The observer handles metadata reconciliation during hydration and navigation. Pages Router: render the component from `pages/_app.tsx`.
55
115
 
56
116
  </details>
57
117
 
@@ -59,13 +119,54 @@ Then render `<FaviconEnv />` once inside `<body>` in `app/layout.tsx`. `useEffec
59
119
  <summary><b>TanStack Router / Start</b></summary>
60
120
 
61
121
  ```tsx
62
- // src/main.tsx — your client entry, before you render the router
63
- import { envFavicon } from 'favicon-env'
122
+ // src/client.tsx
123
+ import { StartClient } from '@tanstack/react-start/client';
124
+ import { StrictMode } from 'react';
125
+ import { hydrateRoot } from 'react-dom/client';
126
+
127
+ const appEnv = import.meta.env.VITE_APP_ENV ?? 'prod';
128
+
129
+ if (appEnv === 'dev' || appEnv === 'staging') {
130
+ let queued = false;
131
+ const apply = async () => {
132
+ queued = false;
133
+ const { envFavicon } = await import('favicon-env');
134
+ await envFavicon({
135
+ environments: {
136
+ dev: { tint: '#22c55e' },
137
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
138
+ },
139
+ detect: () => appEnv,
140
+ });
141
+ };
142
+ const schedule = () => {
143
+ if (queued) return;
144
+ queued = true;
145
+ queueMicrotask(apply);
146
+ };
147
+ const observer = new MutationObserver(() => {
148
+ if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
149
+ schedule();
150
+ }
151
+ });
152
+ observer.observe(document.head, {
153
+ childList: true,
154
+ subtree: true,
155
+ attributes: true,
156
+ attributeFilter: ['href', 'rel'],
157
+ });
158
+ schedule();
159
+ }
64
160
 
65
- void envFavicon({ /* …environments, as above… */ })
161
+ hydrateRoot(
162
+ document,
163
+ <StrictMode>
164
+ <StartClient />
165
+ </StrictMode>,
166
+ );
66
167
  ```
67
168
 
68
- The entry runs on the client, so no `useEffect` is needed. (TanStack Start / SSR: the same call in your client entry is a no-op on the server, thanks to the `document` guard.)
169
+ Create the optional `src/client.tsx` entry. For a TanStack Router SPA, use the same observer in `src/main.tsx` before rendering the app.
69
170
 
70
171
  </details>
71
172
 
@@ -74,29 +175,373 @@ The entry runs on the client, so no `useEffect` is needed. (TanStack Start / SSR
74
175
 
75
176
  ```astro
76
177
  ---
77
- // src/layouts/Layout.astro — a client <script>, bundled and run in the browser
178
+ // src/layouts/Layout.astro
78
179
  ---
79
- <script>
80
- import { envFavicon } from 'favicon-env'
81
- void envFavicon({ /* …environments, as above… */ })
180
+ <html lang="en">
181
+ <head>
182
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
183
+ </head>
184
+ <body>
185
+ <slot />
186
+ <script>
187
+ const appEnv = import.meta.env.PUBLIC_APP_ENV ?? 'prod';
188
+ if (appEnv === 'dev' || appEnv === 'staging') {
189
+ void import('favicon-env').then(({ envFavicon }) =>
190
+ envFavicon({
191
+ environments: {
192
+ dev: { tint: '#22c55e' },
193
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
194
+ },
195
+ detect: () => appEnv,
196
+ }),
197
+ );
198
+ }
199
+ </script>
200
+ </body>
201
+ </html>
202
+ ```
203
+
204
+ For an SVG with no first-paint flash, use the build-time helper below.
205
+
206
+ </details>
207
+
208
+ <details>
209
+ <summary><b>SvelteKit</b></summary>
210
+
211
+ ```svelte
212
+ <!-- src/routes/+layout.svelte -->
213
+ <script lang="ts">
214
+ import { onMount } from 'svelte';
215
+
216
+ let { children } = $props();
217
+
218
+ onMount(() => {
219
+ const appEnv = __FAVICON_ENV_APP_ENV__;
220
+ if (appEnv !== 'dev' && appEnv !== 'staging') return;
221
+
222
+ let queued = false;
223
+ const apply = async () => {
224
+ queued = false;
225
+ const { envFavicon } = await import('favicon-env');
226
+ await envFavicon({
227
+ environments: {
228
+ dev: { tint: '#22c55e' },
229
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
230
+ },
231
+ detect: () => appEnv,
232
+ });
233
+ };
234
+ const schedule = () => {
235
+ if (queued) return;
236
+ queued = true;
237
+ queueMicrotask(apply);
238
+ };
239
+ const observer = new MutationObserver(() => {
240
+ if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
241
+ schedule();
242
+ }
243
+ });
244
+
245
+ observer.observe(document.head, {
246
+ childList: true,
247
+ subtree: true,
248
+ attributes: true,
249
+ attributeFilter: ['href', 'rel'],
250
+ });
251
+ schedule();
252
+
253
+ return () => observer.disconnect();
254
+ });
82
255
  </script>
256
+
257
+ <svelte:head>
258
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
259
+ </svelte:head>
260
+
261
+ {@render children()}
262
+ ```
263
+
264
+ ```ts
265
+ // vite.config.ts
266
+ import { sveltekit } from '@sveltejs/kit/vite';
267
+ import { defineConfig } from 'vite';
268
+
269
+ export default defineConfig({
270
+ define: {
271
+ __FAVICON_ENV_APP_ENV__: JSON.stringify(process.env.PUBLIC_APP_ENV ?? 'prod'),
272
+ },
273
+ plugins: [sveltekit()],
274
+ });
275
+ ```
276
+
277
+ ```ts
278
+ // src/app.d.ts
279
+ declare const __FAVICON_ENV_APP_ENV__: string;
83
280
  ```
84
281
 
85
- For Astro you can also bake the tint straight into the HTML for **zero first-paint flash** — see the *Build-time / SSR mode* section below, which is the recommended path when you control the favicon SVG.
282
+ The explicit build constant lets Vite remove the import from prod output.
86
283
 
87
284
  </details>
88
285
 
89
286
  <details>
90
- <summary><b>Vite SPA · Vue · Svelte · vanilla</b></summary>
287
+ <summary><b>SolidStart</b></summary>
91
288
 
92
- ```js
93
- // your client entry — src/main.ts, main.js, …
94
- import { envFavicon } from 'favicon-env'
289
+ ```tsx
290
+ // src/app.tsx
291
+ import { Link, MetaProvider } from '@solidjs/meta';
292
+ import { Router } from '@solidjs/router';
293
+ import { FileRoutes } from '@solidjs/start/router';
294
+ import { onCleanup, onMount, Suspense } from 'solid-js';
295
+
296
+ export default function App() {
297
+ onMount(() => {
298
+ const appEnv = import.meta.env.VITE_APP_ENV ?? 'prod';
299
+ if (appEnv !== 'dev' && appEnv !== 'staging') return;
300
+
301
+ let queued = false;
302
+ const apply = async () => {
303
+ queued = false;
304
+ const { envFavicon } = await import('favicon-env');
305
+ await envFavicon({
306
+ environments: {
307
+ dev: { tint: '#22c55e' },
308
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
309
+ },
310
+ detect: () => appEnv,
311
+ });
312
+ };
313
+ const schedule = () => {
314
+ if (queued) return;
315
+ queued = true;
316
+ queueMicrotask(apply);
317
+ };
318
+ const observer = new MutationObserver(() => {
319
+ if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
320
+ schedule();
321
+ }
322
+ });
323
+
324
+ observer.observe(document.head, {
325
+ childList: true,
326
+ subtree: true,
327
+ attributes: true,
328
+ attributeFilter: ['href', 'rel'],
329
+ });
330
+ schedule();
331
+ onCleanup(() => observer.disconnect());
332
+ });
333
+
334
+ return (
335
+ <Router
336
+ root={(props) => (
337
+ <MetaProvider>
338
+ <Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
339
+ <Suspense>{props.children}</Suspense>
340
+ </MetaProvider>
341
+ )}
342
+ >
343
+ <FileRoutes />
344
+ </Router>
345
+ );
346
+ }
347
+ ```
348
+
349
+ Keep the hook in the app root so it survives route changes.
350
+
351
+ </details>
352
+
353
+ <details>
354
+ <summary><b>Angular SSR</b> — standalone application</summary>
355
+
356
+ ```ts
357
+ // src/app/app.ts
358
+ import { Component, DestroyRef, afterNextRender, inject } from '@angular/core';
359
+ import { loadEnvFavicon } from './favicon-env-loader';
360
+
361
+ declare const APP_ENV: string;
362
+
363
+ @Component({ selector: 'app-root', template: '<main>App</main>', imports: [] })
364
+ export class App {
365
+ private readonly destroyRef = inject(DestroyRef);
366
+
367
+ constructor() {
368
+ afterNextRender(() => {
369
+ if (APP_ENV !== 'dev' && APP_ENV !== 'staging') return;
370
+
371
+ let queued = false;
372
+ const apply = async () => {
373
+ queued = false;
374
+ const envFavicon = await loadEnvFavicon();
375
+ if (!envFavicon) return;
376
+ await envFavicon({
377
+ environments: {
378
+ dev: { tint: '#22c55e' },
379
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
380
+ },
381
+ detect: () => APP_ENV,
382
+ });
383
+ };
384
+ const schedule = () => {
385
+ if (queued) return;
386
+ queued = true;
387
+ queueMicrotask(apply);
388
+ };
389
+ const observer = new MutationObserver(() => {
390
+ if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
391
+ schedule();
392
+ }
393
+ });
394
+
395
+ observer.observe(document.head, {
396
+ childList: true,
397
+ subtree: true,
398
+ attributes: true,
399
+ attributeFilter: ['href', 'rel'],
400
+ });
401
+ schedule();
402
+ this.destroyRef.onDestroy(() => observer.disconnect());
403
+ });
404
+ }
405
+ }
406
+ ```
407
+
408
+ ```ts
409
+ // src/app/favicon-env-loader.ts
410
+ export async function loadEnvFavicon() {
411
+ return (await import('favicon-env')).envFavicon;
412
+ }
413
+ ```
414
+
415
+ ```ts
416
+ // src/app/favicon-env-loader.prod.ts
417
+ import type { EnvFaviconOptions } from 'favicon-env';
418
+
419
+ type EnvFavicon = (options?: EnvFaviconOptions) => Promise<void>;
95
420
 
96
- void envFavicon({ /* …environments, as above… */ })
421
+ export async function loadEnvFavicon(): Promise<EnvFavicon | null> {
422
+ return null;
423
+ }
424
+ ```
425
+
426
+ In the production build configuration in `angular.json`:
427
+
428
+ ```json
429
+ {
430
+ "define": { "APP_ENV": "'prod'" },
431
+ "fileReplacements": [
432
+ {
433
+ "replace": "src/app/favicon-env-loader.ts",
434
+ "with": "src/app/favicon-env-loader.prod.ts"
435
+ }
436
+ ]
437
+ }
97
438
  ```
98
439
 
99
- The entry module already runs in the browser, so no framework hook is needed. (In Vue you could equally call it from `onMounted` in your root component.)
440
+ Use the real loader for dev/staging. Angular's file replacement prevents it from emitting an unused lazy chunk in prod.
441
+
442
+ </details>
443
+
444
+ <details>
445
+ <summary><b>Nuxt</b></summary>
446
+
447
+ ```ts
448
+ // app/plugins/favicon-env.client.ts
449
+ export default defineNuxtPlugin(() => {
450
+ const appEnv = __FAVICON_ENV_APP_ENV__;
451
+ if (appEnv !== 'dev' && appEnv !== 'staging') return;
452
+
453
+ let queued = false;
454
+ const apply = async () => {
455
+ queued = false;
456
+ const { envFavicon } = await import('favicon-env');
457
+ await envFavicon({
458
+ environments: {
459
+ dev: { tint: '#22c55e' },
460
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
461
+ },
462
+ detect: () => appEnv,
463
+ });
464
+ };
465
+ const schedule = () => {
466
+ if (queued) return;
467
+ queued = true;
468
+ queueMicrotask(apply);
469
+ };
470
+ const observer = new MutationObserver(() => {
471
+ if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) {
472
+ schedule();
473
+ }
474
+ });
475
+
476
+ observer.observe(document.head, {
477
+ childList: true,
478
+ subtree: true,
479
+ attributes: true,
480
+ attributeFilter: ['href', 'rel'],
481
+ });
482
+ schedule();
483
+ });
484
+ ```
485
+
486
+ ```ts
487
+ // nuxt.config.ts
488
+ export default defineNuxtConfig({
489
+ vite: {
490
+ define: {
491
+ __FAVICON_ENV_APP_ENV__: JSON.stringify(process.env.NUXT_PUBLIC_APP_ENV ?? 'prod'),
492
+ },
493
+ },
494
+ app: {
495
+ head: {
496
+ link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
497
+ },
498
+ },
499
+ });
500
+ ```
501
+
502
+ ```ts
503
+ // app/types.d.ts
504
+ declare const __FAVICON_ENV_APP_ENV__: string;
505
+ ```
506
+
507
+ The explicit build constant lets Vite remove the import from prod output.
508
+
509
+ </details>
510
+
511
+ <details>
512
+ <summary><b>Plain HTML</b> — ESM and global builds</summary>
513
+
514
+ ```html
515
+ <!-- Native ESM through an ESM-aware CDN -->
516
+ <link rel="icon" href="/favicon.svg" />
517
+ <script type="module">
518
+ import { envFavicon } from 'https://esm.sh/favicon-env';
519
+
520
+ void envFavicon({
521
+ environments: {
522
+ dev: { tint: '#22c55e' },
523
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
524
+ },
525
+ });
526
+ </script>
527
+ ```
528
+
529
+ ```html
530
+ <!-- Classic global build -->
531
+ <link rel="icon" href="/favicon.svg" />
532
+ <script src="https://unpkg.com/favicon-env/dist/favicon-env.global.js"></script>
533
+ <script>
534
+ void faviconEnv.envFavicon({
535
+ environments: {
536
+ dev: { tint: '#22c55e' },
537
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
538
+ },
539
+ });
540
+ </script>
541
+ ```
542
+
543
+ The global script can also auto-run with `data-auto` or `data-dev`/`data-staging` attributes.
544
+ For a zero-byte prod page, have the HTML build/template omit these scripts when the app environment is `prod`.
100
545
 
101
546
  </details>
102
547
 
@@ -104,69 +549,84 @@ By default the environment is guessed from the hostname (`localhost` / `*.local`
104
549
 
105
550
  ```js
106
551
  envFavicon({
107
- environments: { /* … */ },
552
+ environments: {
553
+ dev: { tint: '#22c55e' },
554
+ staging: { badge: { text: 'S', color: '#f59e0b', shape: 'cover' } },
555
+ },
108
556
  detect: () => (location.port === '4000' ? 'staging' : 'prod'),
109
- })
557
+ });
110
558
  ```
111
559
 
112
- Environment **names are arbitrary** they're just keys into `environments`, so you aren't limited to `dev`/`staging`/`prod`. Define your own and return them from `detect` (the built-in heuristic only emits the three defaults, so custom names need a custom `detect`):
560
+ Environment names are arbitrary. Custom names require a custom `detect`:
113
561
 
114
562
  ```js
115
563
  envFavicon({
116
564
  environments: {
117
565
  canary: { hue: 280 },
118
- demo: { badge: '#22c55e' },
566
+ demo: { badge: '#22c55e' },
119
567
  },
120
568
  detect: () => {
121
- if (location.hostname.startsWith('canary.')) return 'canary'
122
- if (location.hostname.endsWith('.demo.acme.com')) return 'demo'
123
- return 'prod' // not in the map → favicon left untouched
569
+ if (location.hostname.startsWith('canary.')) return 'canary';
570
+ if (location.hostname.endsWith('.demo.acme.com')) return 'demo';
571
+ return 'prod'; // not in the map → favicon left untouched
124
572
  },
125
- })
573
+ });
126
574
  ```
127
575
 
128
- `detect` can key off anything, not just the hostname — e.g. `detect: () => import.meta.env.MODE`.
576
+ `detect` can use any runtime value, such as `import.meta.env.MODE`.
129
577
 
130
578
  ### Exact colours
131
579
 
132
- `hue` *rotates* every colour by a fixed angle — a relative shift that keeps the icon's shading **and** saturation, so it can't target a specific colour (and it barely moves white/black/grey). For a **specific** colour, use `tint`: it recolours the icon to that exact colour as a duotone — shape and shading kept, the original colours replaced — so a white logo becomes solid `tint`:
580
+ Use `hue` for a relative colour shift or `tint` for a specific duotone colour:
133
581
 
134
582
  ```js
135
583
  envFavicon({
136
584
  environments: {
137
- dev: { tint: '#22c55e' }, // exact green (shape + shading kept)
138
- staging: { tint: '#f59e0b' }, // exact amber
585
+ dev: { tint: '#22c55e' },
586
+ staging: { tint: '#f59e0b' },
139
587
  },
140
- })
588
+ });
141
589
  ```
142
590
 
143
- Want a flat single-colour block instead of a duotone? Use a text-less `cover` badge (`{ badge: { color: '#22c55e', shape: 'cover' } }`). Want a different icon entirely? Use `src`.
591
+ Colour fields accept CSS colours, including `oklch()`, `lab()`, and `color(display-p3 …)` where supported.
592
+
593
+ ### Invert
594
+
595
+ Pass `true` for a full invert or `0`–`1` for a partial invert:
596
+
597
+ ```js
598
+ envFavicon({
599
+ environments: {
600
+ dev: { invert: true },
601
+ staging: { invert: 0.9 },
602
+ },
603
+ });
604
+ ```
144
605
 
145
- Any colour (`tint`, `badge.color`, `textColor`) can be **any CSS colour** — including CSS Color 4 spaces like `oklch()`, `oklab()`, `lab()`, `lch()`, and `color(display-p3 …)` on browsers that support them (they degrade gracefully elsewhere). Badge text auto-contrasts against `oklch`/`oklab`/`lab`/`lch` colours too.
606
+ `invert` composes with `hue`; `tint` takes precedence, and `filter` overrides both.
146
607
 
147
608
  ### Auto mode
148
609
 
149
- Don't want to name environments at all? Derive a **stable, unique hue from `location.host`**, so every origin *and port* automatically gets its own colour — perfect for telling several dev servers apart:
610
+ Derive a stable hue from `location.host`:
150
611
 
151
612
  ```js
152
- envFavicon({ auto: true })
613
+ envFavicon({ auto: true });
153
614
  ```
154
615
 
155
616
  ### Badges, PR numbers & URL rules
156
617
 
157
- A `badge` is either a colour (a dot) or an object with `text` — handy for preview deploys, where you want the **PR number** on the icon. The cleanest way is a `rules` list: match the URL with a `RegExp` and drop its captures straight into the text with `$1` / `$<name>`:
618
+ A `badge` can be a colour dot or an object with text. Rules can interpolate regex captures with `$1` or `$<name>`:
158
619
 
159
620
  ```js
160
621
  envFavicon({
161
622
  rules: [
162
- // e.g. a preview deploy at pr-344.myapp.dev → a "#344" pill
163
623
  { match: /^pr-(\d+)\./, badge: { text: '#$1', color: '#8b5cf6' } },
164
624
  { match: /staging\./, hue: 45 },
165
625
  ],
166
- })
626
+ });
167
627
  ```
168
628
 
169
- `match` is tested against `location.host` (so `:port` is included). Rules are tried in order — first match wins — then fall through to `auto` / `environments` if none match. Need more than the host? Use a function: it receives the full `URL`, and `text` can be a function too:
629
+ Regex rules test `location.host`, including the port. First match wins. Functions receive the full `URL`:
170
630
 
171
631
  ```js
172
632
  rules: [
@@ -174,12 +634,10 @@ rules: [
174
634
  match: (url) => url.searchParams.has('pr'),
175
635
  badge: { text: (match, url) => `#${url.searchParams.get('pr')}` },
176
636
  },
177
- ]
637
+ ];
178
638
  ```
179
639
 
180
- `textColor` defaults to auto (black/white by contrast with `color`).
181
-
182
- Multi-digit numbers get cramped in a corner at 16px. `shape: 'cover'` replaces the icon with a full-bleed number so it reads even in the tab (or keep the icon and just enlarge the pill with `size` + `corner: 'center'`):
640
+ Use `shape: 'cover'` to make multi-digit text readable at favicon size:
183
641
 
184
642
  ```js
185
643
  { badge: { text: '#344', color: '#8b5cf6', shape: 'cover' } }
@@ -187,7 +645,7 @@ Multi-digit numbers get cramped in a corner at 16px. `shape: 'cover'` replaces t
187
645
 
188
646
  ### A different image per environment
189
647
 
190
- Set `src` to swap the base image outright for an environment — e.g. a distinct staging logo. Any `hue` / `filter` / `badge` still composites on top:
648
+ Use `src` to replace the base image. Other effects still apply:
191
649
 
192
650
  ```js
193
651
  envFavicon({
@@ -195,30 +653,12 @@ envFavicon({
195
653
  staging: { src: '/favicon.staging.svg' },
196
654
  preview: { src: '/favicon.svg', badge: { text: '#344' } },
197
655
  },
198
- })
199
- ```
200
-
201
- A plain `src` with no recolour/badge is applied directly (no canvas), so cross-origin images and crisp vectors just work.
202
-
203
- ### No build step
204
-
205
- Drop in a `<script>` tag; it auto-runs from `data-*` attributes and also exposes `window.faviconEnv`:
206
-
207
- ```html
208
- <!-- unique colour per host, zero config -->
209
- <script src="https://unpkg.com/favicon-env/dist/favicon-env.global.js" data-auto></script>
210
-
211
- <!-- or name your environments (hue in degrees) -->
212
- <script
213
- src="https://unpkg.com/favicon-env/dist/favicon-env.global.js"
214
- data-dev="130"
215
- data-staging="45"
216
- ></script>
656
+ });
217
657
  ```
218
658
 
219
659
  ## Build-time / SSR mode
220
660
 
221
- If you control the favicon SVG and want **no flash**, bake the tint in at build time instead. `faviconDataUri` returns a ready `href`:
661
+ Use `faviconDataUri` to bake changes into an SVG:
222
662
 
223
663
  ```astro
224
664
  ---
@@ -232,78 +672,77 @@ const tint = { dev: { hue: 130 }, staging: { hue: 45 }, prod: false }[env]
232
672
  <link rel="icon" type="image/svg+xml" href={faviconDataUri(favicon, tint)} />
233
673
  ```
234
674
 
235
- `favicon-env/ssr` is pure string manipulation with no DOM dependency, so it's safe to run in Node during a build. Badges work here too — they're baked into the SVG (positioned via its `viewBox`), so you can stamp a PR number at build time with no flash:
675
+ The SSR entry has no DOM dependency and supports badges:
236
676
 
237
677
  ```js
238
- const pr = process.env.VERCEL_GIT_PULL_REQUEST_ID
239
- faviconDataUri(favicon, pr ? { badge: { text: `#${pr}` } } : { hue: 45 })
678
+ const pr = process.env.VERCEL_GIT_PULL_REQUEST_ID;
679
+ faviconDataUri(favicon, pr ? { badge: { text: `#${pr}` } } : { hue: 45 });
240
680
  ```
241
681
 
242
682
  ### Vite
243
683
 
244
- A plain Vite SPA has no template to bake the tint into — its `index.html` is static. Drop this small plugin into your `vite.config` to rewrite the `<link rel="icon">` at build time, choosing the tint from Vite's `mode`:
684
+ This Vite plugin rewrites an SVG favicon using the current mode:
245
685
 
246
686
  ```js
247
687
  // vite.config.js
248
- import { readFileSync } from 'node:fs'
249
- import path from 'node:path'
250
- import { defineConfig } from 'vite'
251
- import { faviconDataUri } from 'favicon-env/ssr'
688
+ import { readFileSync } from 'node:fs';
689
+ import path from 'node:path';
690
+ import { defineConfig } from 'vite';
691
+ import { faviconDataUri } from 'favicon-env/ssr';
252
692
 
253
- // keyed by Vite `mode` (e.g. `vite build --mode staging`); omit prod to leave it untouched
254
693
  const tints = {
255
694
  development: { hue: 130 },
256
695
  staging: { hue: 45 },
257
- }
696
+ };
258
697
 
259
698
  function faviconEnv() {
260
- let config
699
+ let config;
261
700
  return {
262
701
  name: 'favicon-env',
263
702
  configResolved(resolved) {
264
- config = resolved
703
+ config = resolved;
265
704
  },
266
705
  transformIndexHtml(html) {
267
- const tint = tints[config.mode]
268
- if (!tint) return // no rule for this mode → leave the icon alone
706
+ const tint = tints[config.mode];
707
+ if (!tint) return;
269
708
  return html.replace(/<link\b[^>]*\brel=["']icon["'][^>]*>/i, (tag) => {
270
- const href = tag.match(/\bhref=["']([^"']+)["']/i)?.[1]
271
- if (!href?.endsWith('.svg')) return tag // SVG only; skip png/ico
272
- let svg
709
+ const href = tag.match(/\bhref=["']([^"']+)["']/i)?.[1];
710
+ if (!href?.endsWith('.svg')) return tag;
711
+ let svg;
273
712
  try {
274
- svg = readFileSync(path.join(config.publicDir, href.replace(/^\//, '')), 'utf8')
713
+ svg = readFileSync(path.join(config.publicDir, href.replace(/^\//, '')), 'utf8');
275
714
  } catch {
276
- return tag // not in public/ (missing / bundled asset) → untouched
715
+ return tag;
277
716
  }
278
- return tag.replace(/\bhref=["'][^"']*["']/i, `href="${faviconDataUri(svg, tint)}"`)
279
- })
717
+ return tag.replace(/\bhref=["'][^"']*["']/i, `href="${faviconDataUri(svg, tint)}"`);
718
+ });
280
719
  },
281
- }
720
+ };
282
721
  }
283
722
 
284
723
  export default defineConfig({
285
724
  plugins: [faviconEnv()],
286
- })
725
+ });
287
726
  ```
288
727
 
289
- Now `vite dev` and `vite build` serve the tint baked into the initial HTML — no first-paint flash — reading your favicon from `public/` and falling through untouched for non-SVG icons or a missing file. Prefer env vars to `--mode`? Swap `tints[config.mode]` for a lookup keyed off `loadEnv(config.mode, config.root, 'PUBLIC_').PUBLIC_APP_ENV`.
728
+ The favicon must be an SVG in `public/`. Omit a mode to leave it unchanged.
290
729
 
291
730
  ## API
292
731
 
293
732
  ### `envFavicon(options?): Promise<void>` — runtime
294
733
 
295
- | option | type | default | description |
296
- | -------------- | ------------------------------------- | -------------------- | ------------------------------------------------------------------ |
297
- | `environments` | `Record<string, EnvTint \| false>` | — | Map of env name (any string) → tint. Missing/`false` = untouched. |
298
- | `rules` | `EnvRule[]` | — | URL-matched tints, checked first; regex captures fill `badge.text`.|
299
- | `detect` | `() => string \| undefined` | hostname heuristic | Return the current env name (a key of `environments`). |
300
- | `auto` | `boolean \| { offset?: number }` | `false` | Ignore `environments`; derive a unique hue from `location.host`. |
301
- | `source` | `string` | current icon / `.ico`| Favicon URL to tint. |
302
- | `size` | `number` | `64` | Canvas raster size in px. |
734
+ | option | type | default | description |
735
+ | -------------- | ---------------------------------- | --------------------- | ------------------------------------------------------------------- |
736
+ | `environments` | `Record<string, EnvTint \| false>` | — | Map of env name (any string) → tint. Missing/`false` = untouched. |
737
+ | `rules` | `EnvRule[]` | — | URL-matched tints, checked first; regex captures fill `badge.text`. |
738
+ | `detect` | `() => string \| undefined` | hostname heuristic | Return the current env name (a key of `environments`). |
739
+ | `auto` | `boolean \| { offset?: number }` | `false` | Ignore `environments`; derive a unique hue from `location.host`. |
740
+ | `source` | `string` | current icon / `.ico` | Favicon URL to tint. |
741
+ | `size` | `number` | `64` | Canvas raster size in px. |
303
742
 
304
- `EnvTint`: `{ hue?: number; tint?: string; filter?: string; src?: string; badge?: string | Badge }`. `hue` rotates the icon's hue (relative); `tint` colourises it to an *exact* colour (a duotone that keeps the artwork's shape + shading — a white logo becomes solid `tint`); `filter` (any CSS filter) beats both; `src` replaces the base image for that env; `badge` is a colour string (a dot) or a `Badge`. Everything composites in one pass.
743
+ `EnvTint`: `{ hue?: number; invert?: boolean | number; tint?: string; filter?: string; src?: string; badge?: string | Badge }`. Precedence is `filter` `tint` `hue`/`invert`; `src` replaces the base image.
305
744
 
306
- `Badge`: `{ text?: string | number; color?: string; textColor?: string; shape?: 'pill' | 'cover'; corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'; size?: number; opacity?: number }`. Omit `text` for a dot; include it for a pill. `color` sets the background and `textColor` the text (default: auto black/white by contrast). Two styles: the default `'pill'` sits on top of your icon (placed by `corner`/`size`); `'cover'` replaces the whole icon with the colour + number, best for a multi-digit number that must read at 16px. `opacity` (0–1) fades the badge — with `'cover'`, below `1` it lets your icon show *through* the number (a watermark). Everything composites in one pass, so you can combine `src` + `hue`/`filter` + `badge`.
745
+ `Badge`: `{ text?: string | number; color?: string; textColor?: string; shape?: 'pill' | 'cover'; corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'; size?: number; opacity?: number }`. Without text it renders a dot. `textColor` defaults to automatic black/white contrast.
307
746
 
308
747
  `EnvRule`: an `EnvTint` plus `match: RegExp | ((url: URL) => boolean)`. A `RegExp` is tested against `location.host` and its captures interpolate into `badge.text` (`$1`, `$<name>`); a function receives the `URL`, and in a rule `badge.text` may also be `(match, url) => string | number`.
309
748
 
@@ -319,12 +758,11 @@ Now `vite dev` and `vite build` serve the tint baked into the initial HTML — n
319
758
  - `defaultDetect(hostname?) => string` — the built-in `dev`/`staging`/`prod` heuristic.
320
759
  - `matchRules(rules, url) => EnvTint | null` — the pure rule matcher (first match wins, captures interpolated). Reuse it server-side with a request `URL` and feed the result to `favicon-env/ssr`'s `faviconDataUri`.
321
760
 
322
- ## How it works & caveats
761
+ ## Caveats
323
762
 
324
- - **Achromatic pixels barely move.** `hue-rotate` leaves white/black/grey roughly alone, so highlights and outlines survive; only the coloured parts shift.
325
- - **Runtime + cross-origin favicons.** Tinting draws to a canvas, so a cross-origin favicon served without CORS headers taints it — `envFavicon` catches that and leaves the icon untouched. Same-origin (the normal case) is fine.
326
- - **First-paint flash.** Runtime mode briefly shows the untinted icon before JS runs. Use the SSR helper if that matters.
327
- - **Browser support.** Runtime mode needs canvas `ctx.filter` (Baseline; unsupported browsers just get the untinted icon). SSR mode relies on SVG favicons honouring an embedded CSS `filter`, which all current evergreen browsers do.
763
+ - Cross-origin favicons need CORS permission for runtime canvas processing; failures leave the icon unchanged.
764
+ - Runtime mode may briefly show the original icon. Use the SSR helper to avoid this.
765
+ - Runtime mode requires canvas `ctx.filter`; unsupported browsers keep the original icon.
328
766
 
329
767
  ## License
330
768