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.
@@ -1,7 +1,13 @@
1
1
  /** A tint / decoration to apply to the favicon for a given environment. */
2
- interface EnvTint {
2
+ export interface EnvTint {
3
3
  /** Hue rotation in degrees applied to the whole icon (e.g. `120`). */
4
4
  hue?: number;
5
+ /**
6
+ * Invert the icon's colours — `true` for a full invert, or a number `0`–`1`
7
+ * for a partial one (maps to CSS `invert()`). Composes *with* `hue` (both are
8
+ * applied); an explicit `filter` beats it, and `tint` (a duotone) wins over it.
9
+ */
10
+ invert?: boolean | number;
5
11
  /**
6
12
  * Colourise the icon to this exact colour (any CSS colour), preserving the
7
13
  * artwork's shape and relative shading — a duotone from black up to `tint`.
@@ -27,7 +33,7 @@ interface EnvTint {
27
33
  badge?: string | Badge;
28
34
  }
29
35
  /** A badge drawn on top of the icon. */
30
- interface Badge {
36
+ export interface Badge {
31
37
  /** Text to render, e.g. a PR number (`344`, `'#344'`). Omit for a plain dot. */
32
38
  text?: string | number;
33
39
  /** Background colour. Default `'#ef4444'`. */
@@ -47,17 +53,17 @@ interface Badge {
47
53
  /** Badge opacity, 0–1 (default `1`). With `shape: 'cover'`, below `1` lets the icon show through. */
48
54
  opacity?: number;
49
55
  }
50
- type BadgeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
56
+ export type BadgeCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
51
57
  /** Per-environment config. `false` / `null` / omitted leaves the favicon untouched. */
52
- type EnvConfig = EnvTint | false | null | undefined;
58
+ export type EnvConfig = EnvTint | false | null | undefined;
53
59
  /** Computes badge text from a regex match (if any) and the current URL. */
54
- type BadgeTextFn = (match: RegExpMatchArray | null, url: URL) => string | number;
60
+ export type BadgeTextFn = (match: RegExpMatchArray | null, url: URL) => string | number;
55
61
  /**
56
62
  * A `Badge` for use in a `rule`, whose `text` may additionally be a function, or
57
63
  * a template with `$1` / `$<name>` placeholders filled from the rule's regex
58
64
  * captures — e.g. `'#$1'` against `/^pr-(\d+)\./`.
59
65
  */
60
- type RuleBadge = Omit<Badge, 'text'> & {
66
+ export type RuleBadge = Omit<Badge, 'text'> & {
61
67
  text?: string | number | BadgeTextFn;
62
68
  };
63
69
  /**
@@ -65,7 +71,7 @@ type RuleBadge = Omit<Badge, 'text'> & {
65
71
  * later rule is tried). Rules are checked in order, before `auto` and
66
72
  * `environments`/`detect`.
67
73
  */
68
- type EnvRule = Omit<EnvTint, 'badge'> & {
74
+ export type EnvRule = Omit<EnvTint, 'badge'> & {
69
75
  /**
70
76
  * A `RegExp` (tested against `location.host`, i.e. `hostname:port`) or a
71
77
  * function receiving the full `URL`. Regex captures feed `badge.text`.
@@ -75,7 +81,7 @@ type EnvRule = Omit<EnvTint, 'badge'> & {
75
81
  badge?: string | RuleBadge;
76
82
  };
77
83
  /** Options for `envFavicon`. */
78
- interface EnvFaviconOptions {
84
+ export interface EnvFaviconOptions {
79
85
  /** Map of environment name → tint. A missing entry (or `false`) means "leave as-is". */
80
86
  environments?: Record<string, EnvConfig>;
81
87
  /**
@@ -101,29 +107,7 @@ interface EnvFaviconOptions {
101
107
  size?: number;
102
108
  }
103
109
  /** Tuning for `EnvFaviconOptions.auto`. */
104
- interface AutoOptions {
110
+ export interface AutoOptions {
105
111
  /** Extra hue offset (deg) added to the derived hue — shifts the whole palette. */
106
112
  offset?: number;
107
113
  }
108
-
109
- /**
110
- * Return `svg` (an SVG *string*) with the environment's tint and/or badge baked
111
- * in — the form that survives being rendered as an `<img>` / favicon, with no
112
- * first-paint flash. The tint is a CSS `filter` on a wrapping group; the badge
113
- * is an appended `<g>` positioned via the SVG's `viewBox` (a badge is skipped if
114
- * no `viewBox` or `width`/`height` can be read).
115
- *
116
- * Returns the SVG unchanged when `tint` is falsy, has nothing to apply, or the
117
- * input isn't a recognisable `<svg>…</svg>` document.
118
- */
119
- declare function tintSvg(svg: string, tint: EnvConfig): string;
120
- /** Percent-encode an SVG string as a `data:` URI suitable for a favicon `href`. */
121
- declare function svgToDataUri(svg: string): string;
122
- /**
123
- * Convenience: `tintSvg` + `svgToDataUri`. Give it your favicon SVG and the
124
- * config for the current build's environment; get back a ready-to-use
125
- * `<link rel="icon" href="…">` value with no first-paint flash.
126
- */
127
- declare function faviconDataUri(svg: string, tint: EnvConfig): string;
128
-
129
- export { type AutoOptions as A, type Badge as B, type EnvFaviconOptions as E, type RuleBadge as R, type EnvRule as a, type EnvTint as b, type BadgeCorner as c, type BadgeTextFn as d, type EnvConfig as e, faviconDataUri as f, svgToDataUri as s, tintSvg as t };
package/package.json CHANGED
@@ -1,30 +1,39 @@
1
1
  {
2
2
  "name": "favicon-env",
3
- "version": "0.2.0",
4
- "type": "module",
3
+ "version": "0.3.1",
5
4
  "description": "Tint your favicon per environment (dev/staging/prod) so you can tell instances apart at a glance. Runtime canvas mode + build-time SVG mode. Zero dependencies.",
6
- "license": "MIT",
7
- "author": "Amir Abushanab",
8
5
  "keywords": [
9
- "favicon",
10
- "environment",
11
- "env",
12
- "dev",
13
- "staging",
14
- "hue",
15
- "tint",
16
6
  "canvas",
7
+ "dev",
17
8
  "developer-experience",
18
9
  "dx",
19
- "tanstack-intent"
10
+ "env",
11
+ "environment",
12
+ "favicon",
13
+ "hue",
14
+ "staging",
15
+ "tanstack-intent",
16
+ "tint"
20
17
  ],
18
+ "homepage": "https://github.com/Amir-Abushanab/favicon-env#readme",
19
+ "bugs": "https://github.com/Amir-Abushanab/favicon-env/issues",
20
+ "license": "MIT",
21
+ "author": "Amir Abushanab",
21
22
  "repository": {
22
23
  "type": "git",
23
24
  "url": "git+https://github.com/Amir-Abushanab/favicon-env.git"
24
25
  },
25
- "homepage": "https://github.com/Amir-Abushanab/favicon-env#readme",
26
- "bugs": "https://github.com/Amir-Abushanab/favicon-env/issues",
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE",
30
+ "skills",
31
+ "!skills/_artifacts"
32
+ ],
33
+ "type": "module",
27
34
  "sideEffects": false,
35
+ "unpkg": "./dist/favicon-env.global.js",
36
+ "jsdelivr": "./dist/favicon-env.global.js",
28
37
  "exports": {
29
38
  ".": {
30
39
  "types": "./dist/index.d.ts",
@@ -36,36 +45,30 @@
36
45
  },
37
46
  "./global": "./dist/favicon-env.global.js"
38
47
  },
39
- "unpkg": "./dist/favicon-env.global.js",
40
- "jsdelivr": "./dist/favicon-env.global.js",
41
- "files": [
42
- "dist",
43
- "README.md",
44
- "LICENSE",
45
- "skills",
46
- "!skills/_artifacts"
47
- ],
48
48
  "publishConfig": {
49
49
  "access": "public"
50
50
  },
51
- "simple-git-hooks": {
52
- "pre-commit": "pnpm check"
53
- },
54
51
  "devDependencies": {
55
- "@changesets/cli": "^2.31.0",
56
- "@tanstack/intent": "^0.3.5",
57
- "dependency-cruiser": "^18.0.0",
58
- "knip": "^6.24.0",
59
- "oxfmt": "^0.57.0",
60
- "oxlint": "^1.72.0",
61
- "publint": "^0.3.21",
52
+ "@changesets/cli": "^3.0.1",
53
+ "@playwright/test": "^1.62.1",
54
+ "@swc/core": "^1.16.1",
55
+ "@tanstack/intent": "^0.3.7",
56
+ "dependency-cruiser": "^18.2.0",
57
+ "knip": "^6.32.3",
58
+ "oxfmt": "^0.65.0",
59
+ "oxlint": "^1.80.0",
60
+ "publint": "^0.3.24",
62
61
  "simple-git-hooks": "^2.13.1",
63
62
  "tsup": "^8.5.1",
64
- "typescript": "^6.0.3"
63
+ "typescript": "^7.0.2"
64
+ },
65
+ "simple-git-hooks": {
66
+ "pre-commit": "pnpm check"
65
67
  },
66
68
  "scripts": {
67
- "build": "tsup",
69
+ "build": "tsup && tsc -p tsconfig.build.json",
68
70
  "dev": "node scripts/serve.mjs --watch",
71
+ "hero": "pnpm build && node scripts/hero.mjs",
69
72
  "typecheck": "tsc --noEmit",
70
73
  "lint": "oxlint src",
71
74
  "format": "oxfmt src",
@@ -74,11 +77,22 @@
74
77
  "check": "oxfmt --check src && oxlint src && knip && depcruise src",
75
78
  "publint": "publint --strict",
76
79
  "intent:validate": "intent validate skills",
80
+ "intent:stale": "intent stale skills",
81
+ "skill:sync": "node scripts/skill-version.mjs",
82
+ "skill:check": "node scripts/skill-version.mjs --check",
83
+ "skill:stale": "node scripts/skill-stale.mjs",
77
84
  "pretest": "pnpm build",
78
- "test": "node --test",
85
+ "test": "node --test test/pure.test.mjs test/skill.test.mjs",
86
+ "test:skill": "node --test test/skill.test.mjs",
87
+ "test:frameworks": "node test/frameworks/run.mjs",
88
+ "test:frameworks:firefox": "node test/frameworks/run.mjs --browser firefox",
89
+ "test:frameworks:webkit": "node test/frameworks/run.mjs --browser webkit",
90
+ "test:frameworks:browsers": "pnpm test:frameworks && pnpm test:frameworks:firefox && pnpm test:frameworks:webkit",
91
+ "dev:frameworks": "node test/frameworks/serve-all.mjs",
92
+ "test:all": "pnpm test && pnpm test:frameworks",
79
93
  "ncu": "pnpm dlx npm-check-updates",
80
94
  "changeset": "changeset",
81
- "version": "changeset version",
95
+ "version": "changeset version && pnpm run skill:sync",
82
96
  "release": "changeset publish"
83
97
  }
84
98
  }
@@ -3,13 +3,14 @@ name: core
3
3
  description: >
4
4
  favicon-env tints the browser favicon per environment (dev/staging/prod) so
5
5
  identical tabs are distinguishable. Load when calling envFavicon, choosing
6
- runtime (canvas) vs build-time (SSR) mode, adding hue/filter tints or
7
- badges/PR numbers, wiring it into Next.js/TanStack/Astro/Vite, or configuring
8
- detect, environments, rules, or auto mode.
6
+ runtime (canvas) vs build-time (SSR) mode, adding hue/invert/filter tints or
7
+ badges/PR numbers, wiring it into Next.js/TanStack/Astro/SvelteKit/SolidStart/
8
+ Angular/Nuxt/Vite/plain HTML, or configuring detect, environments, rules, or
9
+ auto mode.
9
10
  metadata:
10
11
  type: core
11
12
  library: favicon-env
12
- library_version: '0.1.0'
13
+ library_version: '0.3.1'
13
14
  sources:
14
15
  - 'Amir-Abushanab/favicon-env:README.md'
15
16
  - 'Amir-Abushanab/favicon-env:src/tint.ts'
@@ -27,24 +28,65 @@ when you control the favicon SVG.
27
28
 
28
29
  ## Setup
29
30
 
30
- Runtime — call once on the client, as early as possible:
31
+ Runtime — gate the import with a compile-time public environment value:
31
32
 
32
33
  ```js
33
- import { envFavicon } from 'favicon-env'
34
-
35
- void envFavicon({
36
- environments: {
37
- dev: { hue: 130 }, // hue-rotate degrees
38
- staging: { badge: '#f59e0b' }, // a corner dot; keeps the logo intact
39
- // prod omitted favicon left untouched
40
- },
41
- })
34
+ const appEnv = import.meta.env.VITE_APP_ENV ?? 'prod';
35
+
36
+ if (appEnv === 'dev' || appEnv === 'staging') {
37
+ void import('favicon-env').then(({ envFavicon }) =>
38
+ envFavicon({
39
+ environments: {
40
+ dev: { tint: '#22c55e' },
41
+ staging: { badge: '#f59e0b' },
42
+ },
43
+ detect: () => appEnv,
44
+ }),
45
+ );
46
+ }
42
47
  ```
43
48
 
49
+ With a literal prod value, the bundler can remove the branch and package runtime.
50
+ An unconditional call remains bundled even when `prod` is absent from `environments`.
51
+
44
52
  The environment name defaults to a `location.hostname` heuristic (`defaultDetect`):
45
53
  `localhost`/`*.local`/raw IPs → `dev`; a `staging`/`preview`/`qa`/`uat`/… segment →
46
54
  `staging`; everything else → `prod`.
47
55
 
56
+ ### Framework placement
57
+
58
+ | Runtime | Put `envFavicon` here |
59
+ | -------------- | ------------------------------------------------------------------------------ |
60
+ | Next.js | Client component `useEffect`; render once from the root layout |
61
+ | TanStack Start | Optional `src/client.tsx`, before `hydrateRoot` |
62
+ | Astro | Bundled client `<script>` in the root layout |
63
+ | SvelteKit | `onMount` in persistent `src/routes/+layout.svelte` |
64
+ | SolidStart | `onMount` in `src/app.tsx`; use `onCleanup` for the observer |
65
+ | Angular SSR | `afterNextRender` in the standalone root component; clean up with `DestroyRef` |
66
+ | Nuxt | `app/plugins/favicon-env.client.ts`, using an explicit Vite build constant |
67
+ | Vite SPA | Client entry module (`src/main.ts`, etc.) |
68
+ | Plain HTML | Native module or the global build (`window.faviconEnv`) |
69
+
70
+ For any SSR router/head manager that declares the favicon itself, wrap the call in
71
+ the head-observer pattern below. Hydration or navigation can otherwise restore its
72
+ unmanaged `<link rel="icon">` after an early call. Apply only when an unmanaged icon
73
+ exists, queue one microtask at a time, and disconnect through the framework's native
74
+ cleanup hook when the integration is component-scoped.
75
+
76
+ ### Zero runtime bytes in prod
77
+
78
+ Put `import('favicon-env')` behind a value the client bundler replaces at build time.
79
+ Use `NEXT_PUBLIC_*` in Next and `import.meta.env.VITE_*`/`PUBLIC_*` where those values
80
+ are folded to literals. SvelteKit and Nuxt may preserve their normal public runtime
81
+ configuration, so define a dedicated constant through Vite's `define` option and use
82
+ that in the guard.
83
+
84
+ Angular's application builder can emit a lazy chunk even behind a false `define`
85
+ guard. Put the dynamic import in a local loader module and replace that module with a
86
+ typed no-op in the prod build using Angular `fileReplacements`. For plain HTML, omit
87
+ the ESM/global script from the generated prod page. Do not use `enabled: false`: the
88
+ runtime must already be imported to read that option.
89
+
48
90
  ## Core Patterns
49
91
 
50
92
  ### Custom environments — any name, but supply a matching `detect`
@@ -53,11 +95,11 @@ The environment name defaults to a `location.hostname` heuristic (`defaultDetect
53
95
  void envFavicon({
54
96
  environments: { canary: { hue: 280 }, demo: { badge: '#22c55e' } },
55
97
  detect: () => {
56
- if (location.hostname.startsWith('canary.')) return 'canary'
57
- if (location.hostname.endsWith('.demo.acme.com')) return 'demo'
58
- return 'prod' // not in the map → untouched
98
+ if (location.hostname.startsWith('canary.')) return 'canary';
99
+ if (location.hostname.endsWith('.demo.acme.com')) return 'demo';
100
+ return 'prod'; // not in the map → untouched
59
101
  },
60
- })
102
+ });
61
103
  ```
62
104
 
63
105
  `environments` keys are arbitrary strings, but `defaultDetect` only ever returns
@@ -72,7 +114,7 @@ void envFavicon({
72
114
  { match: /^pr-(\d+)\./, badge: { text: '#$1', color: '#8b5cf6', shape: 'cover' } },
73
115
  { match: /staging\./, hue: 45 },
74
116
  ],
75
- })
117
+ });
76
118
  ```
77
119
 
78
120
  `match` is a `RegExp` tested against `location.host` (includes `:port`) or a
@@ -82,7 +124,7 @@ it falls through to `auto`/`environments`.
82
124
  ### Auto mode — one stable colour per host, zero config
83
125
 
84
126
  ```js
85
- void envFavicon({ auto: true })
127
+ void envFavicon({ auto: true });
86
128
  ```
87
129
 
88
130
  Derives a deterministic hue from `location.host`, so every origin and port gets its
@@ -91,11 +133,11 @@ own colour — handy for telling several dev servers apart.
91
133
  ### Build-time SSR — no first-paint flash
92
134
 
93
135
  ```js
94
- import { faviconDataUri } from 'favicon-env/ssr'
95
- import favicon from './favicon.svg?raw'
136
+ import { faviconDataUri } from 'favicon-env/ssr';
137
+ import favicon from './favicon.svg?raw';
96
138
 
97
139
  // during an Astro/Vite build; pick the tint for the current env
98
- const href = faviconDataUri(favicon, { hue: 130 })
140
+ const href = faviconDataUri(favicon, { hue: 130 });
99
141
  // → render into <link rel="icon" type="image/svg+xml" href={href}>
100
142
  ```
101
143
 
@@ -109,7 +151,7 @@ CSS `filter` and/or badge into the SVG using its `viewBox`.
109
151
  Wrong:
110
152
 
111
153
  ```js
112
- void envFavicon({ environments: { canary: { hue: 280 } } })
154
+ void envFavicon({ environments: { canary: { hue: 280 } } });
113
155
  ```
114
156
 
115
157
  Correct:
@@ -118,7 +160,7 @@ Correct:
118
160
  void envFavicon({
119
161
  environments: { canary: { hue: 280 } },
120
162
  detect: () => (location.hostname.startsWith('canary.') ? 'canary' : 'prod'),
121
- })
163
+ });
122
164
  ```
123
165
 
124
166
  `defaultDetect` only returns `dev`/`staging`/`prod`, so `environments.canary` is
@@ -132,28 +174,74 @@ Wrong:
132
174
 
133
175
  ```jsx
134
176
  // app/page.tsx — a Next.js App Router Server Component
135
- import { envFavicon } from 'favicon-env'
136
- envFavicon({ environments: { dev: { hue: 130 } } }) // runs on the server
177
+ import { envFavicon } from 'favicon-env';
178
+ envFavicon({ environments: { dev: { hue: 130 } } }); // runs on the server
137
179
  ```
138
180
 
139
181
  Correct:
140
182
 
141
183
  ```jsx
142
- 'use client'
143
- import { useEffect } from 'react'
144
- import { envFavicon } from 'favicon-env'
184
+ 'use client';
185
+ import { useEffect } from 'react';
186
+
187
+ const appEnv = process.env.NEXT_PUBLIC_APP_ENV ?? 'prod';
145
188
 
146
189
  export function FaviconEnv() {
147
190
  useEffect(() => {
148
- void envFavicon({ environments: { dev: { hue: 130 } } })
149
- }, [])
150
- return null
191
+ if (appEnv !== 'dev' && appEnv !== 'staging') return;
192
+
193
+ let queued = false;
194
+ const apply = async () => {
195
+ queued = false;
196
+ const { envFavicon } = await import('favicon-env');
197
+ await envFavicon({
198
+ environments: { dev: { tint: '#22c55e' }, staging: { badge: '#f59e0b' } },
199
+ detect: () => appEnv,
200
+ });
201
+ };
202
+ const schedule = () => {
203
+ if (queued) return;
204
+ queued = true;
205
+ queueMicrotask(apply);
206
+ };
207
+ const observer = new MutationObserver(() => {
208
+ if (document.head.querySelector('link[rel~="icon"]:not([data-favicon-env])')) schedule();
209
+ });
210
+ observer.observe(document.head, {
211
+ childList: true,
212
+ subtree: true,
213
+ attributes: true,
214
+ attributeFilter: ['href', 'rel'],
215
+ });
216
+ schedule();
217
+ return () => observer.disconnect();
218
+ }, []);
219
+ return null;
151
220
  }
152
221
  ```
153
222
 
154
223
  Runtime mode needs `document`; on the server it is a no-op and never tints the
155
224
  client. Use `useEffect` in a client component (Next App Router), or call it at a
156
- client-entry module (Vite/TanStack `src/main.ts`, or an Astro `<script>`).
225
+ client-entry module (Vite/TanStack Router `src/main.ts`, TanStack Start's optional
226
+ `src/client.tsx`, an Astro `<script>`, SvelteKit/Solid `onMount`, Angular
227
+ `afterNextRender`, or a Nuxt `.client.ts` plugin). In TanStack Start, preserve the standard
228
+ `hydrateRoot(document, <StartClient />)` code. Install the same head observer shown
229
+ above before hydration because TanStack's head hydration can restore its route-managed
230
+ icon after an early `envFavicon` call; no React effect cleanup is needed for this
231
+ page-lifetime client entry.
232
+ Do not put the Next.js integration in `instrumentation-client.ts`: it runs before
233
+ hydration, so Next's metadata reconciliation can restore the original icon after
234
+ `envFavicon` replaces it. In the client component, observe `document.head` and
235
+ reapply only when an unmanaged icon appears; disconnect the observer in the Next effect
236
+ cleanup for React StrictMode. This also handles route-level metadata changes.
237
+
238
+ In SvelteKit put the observer in a persistent `+layout.svelte` and return cleanup
239
+ from `onMount`. In SolidStart register cleanup with `onCleanup` inside `onMount`.
240
+ In Angular register it with `DestroyRef.onDestroy` inside `afterNextRender`. In Nuxt
241
+ use a `.client.ts` plugin; a page-lifetime plugin observer does not require component
242
+ cleanup. Use public build constants (`NEXT_PUBLIC_*`, `PUBLIC_*`, `VITE_*`, or a Vite
243
+ `define`) rather than changing `NODE_ENV` to `staging`. Use Angular file replacement
244
+ and explicit Vite constants for SvelteKit/Nuxt when prod must emit no runtime chunk.
157
245
 
158
246
  Source: src/tint.ts (`typeof document` guard), README "Runtime mode"
159
247
 
@@ -163,27 +251,29 @@ Wrong:
163
251
 
164
252
  ```js
165
253
  // trying to make dev green — but hue-rotate is relative to the base icon
166
- void envFavicon({ environments: { dev: { hue: 130 } } })
254
+ void envFavicon({ environments: { dev: { hue: 130 } } });
167
255
  ```
168
256
 
169
257
  Correct:
170
258
 
171
259
  ```js
172
- void envFavicon({ environments: { dev: { tint: '#22c55e' } } })
260
+ void envFavicon({ environments: { dev: { tint: '#22c55e' } } });
173
261
  ```
174
262
 
175
- `hue` *rotates* the existing colours (the result depends on the base icon, and it
176
- barely moves white/black/grey). `tint` colourises to an *exact* colour while
177
- preserving shape and shading. For a flat block, use a text-less `cover` badge.
263
+ `hue` _rotates_ the existing colours (the result depends on the base icon, and it
264
+ barely moves white/black/grey). `tint` colourises to an _exact_ colour while
265
+ preserving shape and shading. For a flat block, use a text-less `cover` badge. For a
266
+ quick high-contrast flip with no colour to choose, use `invert: true` (or a `0`–`1`
267
+ amount); it composes with `hue`, while `tint` and an explicit `filter` take precedence.
178
268
 
179
- Source: src/types.ts (EnvTint.tint), src/tint.ts (colorize path)
269
+ Source: src/types.ts (EnvTint.tint / EnvTint.invert), src/tint.ts (colorize path), src/filter.ts
180
270
 
181
271
  ### MEDIUM — Multi-digit badge as a corner pill
182
272
 
183
273
  Wrong:
184
274
 
185
275
  ```js
186
- void envFavicon({ environments: { preview: { badge: { text: '#344' } } } })
276
+ void envFavicon({ environments: { preview: { badge: { text: '#344' } } } });
187
277
  ```
188
278
 
189
279
  Correct:
@@ -191,7 +281,7 @@ Correct:
191
281
  ```js
192
282
  void envFavicon({
193
283
  environments: { preview: { badge: { text: '#344', shape: 'cover' } } },
194
- })
284
+ });
195
285
  ```
196
286
 
197
287
  The default `pill` badge sits in a corner at ~half the icon, so a 3–4 digit number
@@ -207,14 +297,14 @@ Wrong:
207
297
  void envFavicon({
208
298
  source: 'https://cdn.example.com/favicon.png', // served without CORS headers
209
299
  environments: { dev: { hue: 130 } },
210
- })
300
+ });
211
301
  ```
212
302
 
213
303
  Correct:
214
304
 
215
305
  ```js
216
306
  // serve the favicon same-origin, or with Access-Control-Allow-Origin
217
- void envFavicon({ source: '/favicon.png', environments: { dev: { hue: 130 } } })
307
+ void envFavicon({ source: '/favicon.png', environments: { dev: { hue: 130 } } });
218
308
  ```
219
309
 
220
310
  Tinting draws to a canvas; a cross-origin image without CORS taints it, so
@@ -228,16 +318,16 @@ Wrong:
228
318
 
229
319
  ```js
230
320
  // runtime mode always shows the untinted icon until JS runs
231
- import { envFavicon } from 'favicon-env'
232
- void envFavicon({ environments: { dev: { hue: 130 } } })
321
+ import { envFavicon } from 'favicon-env';
322
+ void envFavicon({ environments: { dev: { hue: 130 } } });
233
323
  ```
234
324
 
235
325
  Correct:
236
326
 
237
327
  ```js
238
328
  // bake the tint into the initial HTML at build time — no flash
239
- import { faviconDataUri } from 'favicon-env/ssr'
240
- const href = faviconDataUri(faviconSvg, { hue: 130 })
329
+ import { faviconDataUri } from 'favicon-env/ssr';
330
+ const href = faviconDataUri(faviconSvg, { hue: 130 });
241
331
  ```
242
332
 
243
333
  `envFavicon` runs after first paint, so the original icon flashes briefly. When
@@ -250,15 +340,15 @@ Source: README "First-paint flash", src/ssr.ts
250
340
  Wrong:
251
341
 
252
342
  ```js
253
- envFavicon({ environments: { dev: { hue: 130 } } })
254
- const href = document.querySelector('link[rel~="icon"]').href // old icon — not swapped yet
343
+ envFavicon({ environments: { dev: { hue: 130 } } });
344
+ const href = document.querySelector('link[rel~="icon"]').href; // old icon — not swapped yet
255
345
  ```
256
346
 
257
347
  Correct:
258
348
 
259
349
  ```js
260
- await envFavicon({ environments: { dev: { hue: 130 } } })
261
- const href = document.querySelector('link[rel~="icon"]').href
350
+ await envFavicon({ environments: { dev: { hue: 130 } } });
351
+ const href = document.querySelector('link[rel~="icon"]').href;
262
352
  ```
263
353
 
264
354
  `envFavicon` returns a `Promise` and loads the base image asynchronously before
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/badge.ts","../src/color.ts","../src/filter.ts","../src/ssr.ts"],"names":[],"mappings":"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","file":"chunk-3JXNCGZF.js","sourcesContent":["import type { Badge, BadgeCorner } from './types';\n\n/** Default badge background — the red dot/pill you get when no `color` is set. */\nexport const DEFAULT_BADGE_COLOR = '#ef4444';\n\n/** Normalise the `badge` shorthand (a `string` is a dot of that colour) to a `Badge`. */\nexport function normalizeBadge(badge: string | Badge): Badge {\n return typeof badge === 'string' ? { color: badge } : badge;\n}\n\n/** A badge's text as a string (`''` when omitted) — so a `0` renders and `undefined` doesn't. */\nexport function badgeText(badge: Badge): string {\n return badge.text == null ? '' : String(badge.text);\n}\n\n/**\n * Top-left corner for a `bw`×`bh` badge inside a `cw`×`ch` box, per `corner`\n * (relative to the box origin; the caller adds any offset). Shared by the canvas\n * and SVG renderers so placement stays identical across runtime and build-time.\n */\nexport function placeBadge(\n corner: BadgeCorner,\n cw: number,\n ch: number,\n bw: number,\n bh: number,\n margin: number,\n): [number, number] {\n if (corner === 'center') return [(cw - bw) / 2, (ch - bh) / 2];\n return [\n corner.endsWith('left') ? margin : cw - bw - margin,\n corner.startsWith('top') ? margin : ch - bh - margin,\n ];\n}\n","/** Parse `#rgb` / `#rrggbb` / `rgb(…)` / `rgba(…)` into `[r, g, b]` (0–255), or `null`. */\nfunction parseRgb(color: string): [number, number, number] | null {\n const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color);\n if (hex) {\n const h = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1];\n const n = Number.parseInt(h, 16);\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n }\n const rgb = /^rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i.exec(color);\n if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];\n return null;\n}\n\n/**\n * Perceived lightness (0–1) of `color` for the black/white text decision:\n * rec601 luma for `#hex` / `rgb()`, or the `L` channel of CSS Color 4 colours —\n * `oklch()`/`oklab()` (0–1) and `lab()`/`lch()` (0–100). `null` if unreadable.\n */\nfunction lightness(color: string): number | null {\n const rgb = parseRgb(color);\n if (rgb) return (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;\n const lch = /^(okl(?:ch|ab)|l(?:ch|ab))\\(\\s*([\\d.]+)(%?)/i.exec(color);\n if (lch) {\n const l = Number(lch[2]);\n if (lch[3]) return l / 100; // a percentage L, in any of these spaces\n return /^ok/i.test(lch[1]) ? l : l / 100; // oklab/oklch L is 0–1, lab/lch is 0–100\n }\n return null;\n}\n\n/**\n * Black or white — whichever reads better on `color` — by perceived lightness.\n * Handles `#hex`, `rgb()`, and CSS Color 4 `oklch()`/`oklab()`/`lab()`/`lch()`;\n * falls back to white for anything else (named colours, `hsl()`, `color()`, …).\n */\nexport function contrastColor(color: string): string {\n const l = lightness(color);\n if (l == null) return '#fff';\n return l > 0.6 ? '#000' : '#fff';\n}\n","import type { EnvTint } from './types';\n\n/** The CSS `filter` for a tint — an explicit `filter` beats `hue`; `null` if neither is set. */\nexport function cssFilter(tint: EnvTint): string | null {\n if (tint.filter) return tint.filter;\n if (typeof tint.hue === 'number') return `hue-rotate(${tint.hue}deg)`;\n return null;\n}\n","import { badgeText, DEFAULT_BADGE_COLOR, normalizeBadge, placeBadge } from './badge';\nimport { contrastColor } from './color';\nimport { cssFilter } from './filter';\nimport type { Badge, EnvConfig } from './types';\n\nconst XML_ESCAPES: Record<string, string> = {\n '&': '&amp;',\n '<': '&lt;',\n '>': '&gt;',\n '\"': '&quot;',\n \"'\": '&apos;',\n};\n\nfunction escapeXml(value: string): string {\n return value.replace(/[&<>\"']/g, (c) => XML_ESCAPES[c] ?? c);\n}\n\nconst round = (n: number): number => Math.round(n * 100) / 100;\n\n/** ` opacity=\"…\"` attribute for a translucent badge group, else `''`. */\nfunction opacityAttr(badge: Badge): string {\n return (badge.opacity ?? 1) < 1 ? ` opacity=\"${round(badge.opacity ?? 1)}\"` : '';\n}\n\n/** Attributes that squeeze the glyphs into `len` when the natural text would overflow. */\nfunction fitText(len: number): string {\n return ` textLength=\"${round(len)}\" lengthAdjust=\"spacingAndGlyphs\"`;\n}\n\n/** A centred, bold `<text>` element at (`cx`, `cy`) — the one badge-label form. */\nfunction svgText(\n cx: number,\n cy: number,\n fill: string,\n fontSize: number,\n fit: string,\n text: string,\n): string {\n return (\n `<text x=\"${round(cx)}\" y=\"${round(cy)}\" fill=\"${escapeXml(fill)}\" ` +\n `font-family=\"system-ui, sans-serif\" font-size=\"${round(fontSize)}\" font-weight=\"700\" ` +\n `text-anchor=\"middle\" dominant-baseline=\"central\"${fit}>${escapeXml(text)}</text>`\n );\n}\n\n/** Read the drawable box from a root `<svg>` tag: its `viewBox`, else `width`/`height`. */\nfunction parseViewBox(openTag: string): [number, number, number, number] | null {\n const vb = /viewBox\\s*=\\s*[\"']([^\"']+)[\"']/i.exec(openTag);\n if (vb) {\n const parts = vb[1]\n .trim()\n .split(/[\\s,]+/)\n .map(Number);\n if (parts.length === 4 && parts.every((n) => !Number.isNaN(n))) {\n return [parts[0], parts[1], parts[2], parts[3]];\n }\n }\n const w = /\\bwidth\\s*=\\s*[\"']?([\\d.]+)/i.exec(openTag);\n const h = /\\bheight\\s*=\\s*[\"']?([\\d.]+)/i.exec(openTag);\n if (w && h) return [0, 0, Number(w[1]), Number(h[1])];\n return null;\n}\n\n/** Build an SVG `<g>` badge (dot, or a pill with text) sized to the viewBox. */\nfunction svgBadge([minX, minY, w, h]: [number, number, number, number], badge: Badge): string {\n const text = badgeText(badge);\n const color = badge.color ?? DEFAULT_BADGE_COLOR;\n const corner = badge.corner ?? 'bottom-right';\n const bh = h * (badge.size ?? 0.5);\n const fontSize = bh * 0.62;\n const margin = w * 0.02;\n // SVG has no text metrics at build time; approximate glyph width at ~0.62em,\n // then clamp to the icon so a long label (e.g. a big PR number) can't overflow.\n const natural = text ? Math.max(bh, text.length * fontSize * 0.62 + bh * 0.5) : bh;\n const bw = text ? Math.min(natural, w - margin * 2) : bh;\n const [px, py] = placeBadge(corner, w, h, bw, bh, margin);\n const x = minX + px;\n const y = minY + py;\n const rx = text ? Math.min(bh / 2, w * 0.24) : bh / 2;\n // If clamped, force the glyphs to fit the pill width.\n const fit = bw < natural ? fitText(bw - bh * 0.4) : '';\n const label = text\n ? svgText(x + bw / 2, y + bh / 2, badge.textColor ?? contrastColor(color), fontSize, fit, text)\n : '';\n return (\n `<g${opacityAttr(badge)}><rect x=\"${round(x)}\" y=\"${round(y)}\" width=\"${round(bw)}\" height=\"${round(bh)}\" ` +\n `rx=\"${round(rx)}\" fill=\"${escapeXml(color)}\" stroke=\"rgba(0,0,0,0.35)\" ` +\n `stroke-width=\"${round(h * 0.015)}\"/>${label}</g>`\n );\n}\n\n/** Build a full-icon \"cover\" tile — a background rect plus a big centred number. */\nfunction svgCover([minX, minY, w, h]: [number, number, number, number], badge: Badge): string {\n const color = badge.color ?? DEFAULT_BADGE_COLOR;\n const text = badgeText(badge);\n const side = Math.min(w, h);\n const op = opacityAttr(badge);\n const rect =\n `<rect x=\"${round(minX)}\" y=\"${round(minY)}\" width=\"${round(w)}\" height=\"${round(h)}\" ` +\n `rx=\"${round(side * 0.2)}\" fill=\"${escapeXml(color)}\"/>`;\n if (!text) return `<g${op}>${rect}</g>`;\n const fontSize = side * 0.62;\n const maxLen = w * 0.84;\n const fit = text.length * fontSize * 0.62 > maxLen ? fitText(maxLen) : '';\n const label = svgText(\n minX + w / 2,\n minY + h / 2,\n badge.textColor ?? contrastColor(color),\n fontSize,\n fit,\n text,\n );\n return `<g${op}>${rect}${label}</g>`;\n}\n\n/**\n * An SVG `<filter>` that colourises the icon to `color`, mirroring the runtime\n * canvas path: desaturate → multiply the flood colour → re-mask the original\n * alpha. `color-interpolation-filters=\"sRGB\"` keeps it visually matching canvas.\n */\nfunction svgColorize(color: string): string {\n return (\n `<filter id=\"__favenv_c\" color-interpolation-filters=\"sRGB\">` +\n `<feColorMatrix type=\"saturate\" values=\"0\" result=\"g\"/>` +\n `<feFlood flood-color=\"${escapeXml(color)}\" result=\"f\"/>` +\n `<feBlend in=\"f\" in2=\"g\" mode=\"multiply\" result=\"b\"/>` +\n `<feComposite in=\"b\" in2=\"SourceGraphic\" operator=\"in\"/>` +\n `</filter>`\n );\n}\n\n/**\n * Return `svg` (an SVG *string*) with the environment's tint and/or badge baked\n * in — the form that survives being rendered as an `<img>` / favicon, with no\n * first-paint flash. The tint is a CSS `filter` on a wrapping group; the badge\n * is an appended `<g>` positioned via the SVG's `viewBox` (a badge is skipped if\n * no `viewBox` or `width`/`height` can be read).\n *\n * Returns the SVG unchanged when `tint` is falsy, has nothing to apply, or the\n * input isn't a recognisable `<svg>…</svg>` document.\n */\nexport function tintSvg(svg: string, tint: EnvConfig): string {\n if (!tint) return svg;\n const filter = cssFilter(tint);\n const colorize = tint.filter ? undefined : tint.tint; // explicit `filter` beats `tint`\n const badge = tint.badge ? normalizeBadge(tint.badge) : null;\n if (!filter && !colorize && !badge) return svg;\n const open = /<svg\\b[^>]*>/i.exec(svg);\n if (!open) return svg;\n const close = svg.lastIndexOf('</svg>');\n if (close === -1) return svg;\n const openEnd = open.index + open[0].length;\n\n // `shape: 'cover'` replaces the icon's content with a full-bleed number tile.\n // A translucent cover (opacity < 1) keeps the base showing through instead.\n if (badge?.shape === 'cover') {\n const vb = parseViewBox(open[0]);\n if (!vb) return svg;\n const base = (badge.opacity ?? 1) < 1 ? svg.slice(openEnd, close) : '';\n return `${svg.slice(0, openEnd)}${base}${svgCover(vb, badge)}${svg.slice(close)}`;\n }\n\n const inner = svg.slice(openEnd, close);\n // No XML comments injected here — XML comments may not contain `--`, which\n // every `--custom-property` does, and that silently breaks favicon SVGs.\n // `tint` colourises via an SVG `<filter>`; else `hue`/`filter` is a CSS filter.\n let defs = '';\n let body = inner;\n if (colorize) {\n defs = svgColorize(colorize);\n body = `<g filter=\"url(#__favenv_c)\">${inner}</g>`;\n } else if (filter) {\n defs = `<style>.__favenv{filter:${filter}}</style>`;\n body = `<g class=\"__favenv\">${inner}</g>`;\n }\n const viewBox = badge ? parseViewBox(open[0]) : null;\n const badgeSvg = badge && viewBox ? svgBadge(viewBox, badge) : '';\n return `${svg.slice(0, openEnd)}${defs}${body}${badgeSvg}${svg.slice(close)}`;\n}\n\n/** Percent-encode an SVG string as a `data:` URI suitable for a favicon `href`. */\nexport function svgToDataUri(svg: string): string {\n return `data:image/svg+xml,${encodeURIComponent(svg)}`;\n}\n\n/**\n * Convenience: `tintSvg` + `svgToDataUri`. Give it your favicon SVG and the\n * config for the current build's environment; get back a ready-to-use\n * `<link rel=\"icon\" href=\"…\">` value with no first-paint flash.\n */\nexport function faviconDataUri(svg: string, tint: EnvConfig): string {\n return svgToDataUri(tintSvg(svg, tint));\n}\n"]}