@readium/helpers 1.0.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.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @readium/helpers
2
+
3
+ General-purpose browser helpers for Readium packages: color parsing/contrast utilities and OS/browser detection.
4
+
5
+ ## Installation
6
+
7
+ ```ts
8
+ import { colorToRgba, checkContrast, isDarkColor, isLightColor, getContrastingTextColor, adjustColorForContrast, getLuminance, sML, sMLWithRequest } from "@readium/helpers";
9
+ ```
10
+
11
+ ## Color utilities
12
+
13
+ ```ts
14
+ // Parse any CSS color value (including custom properties resolved by the browser) to RGBA
15
+ colorToRgba("rebeccapurple"): { r: number; g: number; b: number; a: number }
16
+
17
+ // Relative luminance per WCAG 2.2
18
+ getLuminance({ r: 255, g: 255, b: 255 }): number
19
+
20
+ // WCAG 2.2 contrast ratio between two colors (string or RGBA)
21
+ checkContrast("#000", "#fff"): number
22
+
23
+ // Whether a color reads as dark/light against an optional background it's blended with
24
+ isDarkColor("#333", "#fff"): boolean
25
+ isLightColor("#333", "#fff"): boolean
26
+
27
+ // "black" or "white", whichever contrasts best against the given color
28
+ getContrastingTextColor("#333"): "black" | "white"
29
+
30
+ // Lightens/darkens baseColor against backgroundColor until targetContrast (default 3) is met
31
+ adjustColorForContrast("#888", "#fff", 3): string
32
+ ```
33
+
34
+ `colorToRgba` uses an offscreen canvas to resolve any value the browser's CSS engine understands (named colors, `hsl()`, `oklch()`, etc.) and caches results. Unparseable or special values (`transparent`, `currentColor`, gradients, CSS variables) log a warning and fall back to opaque white.
35
+
36
+ ## OS / browser detection
37
+
38
+ ```ts
39
+ sML.OS // { iOS, macOS, iPadOS, Windows, Android, ChromeOS, Linux, ... } — each a version array, e.g. [17, 4]
40
+ sML.UA // { Chrome, Safari, Firefox, Edge, WebKit, Blink, ... } — each a version array
41
+ sML.Env // string[] of all detected OS/UA flag names
42
+
43
+ sMLWithRequest.iOSRequest // "mobile" | "desktop" | undefined — iPadOS "Request Desktop Site" state
44
+ ```
45
+
46
+ `sML` is a bundled subset of [sML.js](https://github.com/satorumurmur/sML) by Satoru Matsushima, MIT licensed. Falls back safely when `navigator` is unavailable (SSR).
47
+
48
+ ## Third-party licenses
49
+
50
+ This package is BSD-3-Clause, except for `src/sML.ts`, which vendors code from [sML.js](https://github.com/satorumurmur/sML), Copyright (c) Satoru Matsushima, licensed under the MIT license (see the header of that file for the full notice).
package/dist/color.js ADDED
@@ -0,0 +1 @@
1
+ let m=null,x=null,g=0;const l={r:255,g:255,b:255,a:1},i=new Map,R=()=>{if(!m)if(typeof OffscreenCanvas<"u")m=new OffscreenCanvas(5,5),x=m.getContext("2d",{willReadFrequently:!0,desynchronized:!0});else{const t=document.createElement("canvas");t.width=5,t.height=5,m=t,x=t.getContext("2d",{willReadFrequently:!0,desynchronized:!0})}return x},$=t=>{if(!t)return!0;const e=t.trim().toLowerCase();return e.startsWith("var(")||["transparent","currentcolor","inherit","initial","revert","unset","revert-layer"].includes(e)?!0:["linear-gradient","radial-gradient","conic-gradient","repeating-linear-gradient","repeating-radial-gradient","repeating-conic-gradient"].some(n=>e.includes(n))},p=(t,e)=>{console.warn(`[Decorator] Could not parse color: "${t}". ${e} Falling back to ${JSON.stringify(l)} to compute contrast. Please use a CSS color value that can be computed to RGB(A).`)},h=(t,e=null)=>{const r=e?`${t}|${e}`:t,s=i.get(r);if(s!==void 0)return s??l;if($(t))return p(t,"Unsupported color format or special value."),i.set(r,null),l;const n=R();if(!n)return p(t,"Could not get canvas context."),i.set(r,null),l;try{g===0&&n.clearRect(0,0,5,5);const a=g%5,c=Math.floor(g/5);n.clearRect(a,c,1,1),e&&(n.fillStyle=e,n.fillRect(a,c,1,1)),n.fillStyle=t,n.fillRect(a,c,1,1);const u=n.getImageData(a,c,1,1);g=(g+1)%25;const[o,y,f,d]=u.data;if(d===0)return p(t,"Fully transparent color."),i.set(r,null),l;const b={r:o,g:y,b:f,a:d/255};return i.set(r,b),b}catch(a){return p(t,`Error: ${a instanceof Error?a.message:String(a)}`),i.set(r,null),l}},v=t=>{const e=t/255;return e<=.03928?e/12.92:Math.pow((e+.055)/1.055,2.4)},M=t=>{const e=v(t.r),r=v(t.g),s=v(t.b);return .2126*e+.7152*r+.0722*s},C=(t,e)=>{const r=typeof t=="string"?h(t):t,s=typeof e=="string"?h(e):e,n=M(r),a=M(s),c=Math.max(n,a),u=Math.min(n,a);return(c+.05)/(u+.05)},w=(t,e=null)=>{const r=h(t,e),s=C(r,{r:255,g:255,b:255,a:1}),n=C(r,{r:0,g:0,b:0,a:1});return s>n},F=(t,e=null)=>!w(t,e),T=(t,e=null)=>w(t,e)?"white":"black",L=t=>{const e=t.a!==void 0?t.a:1;return`rgba(${Math.round(t.r)}, ${Math.round(t.g)}, ${Math.round(t.b)}, ${e})`},S=(t,e)=>({r:Math.min(255,t.r+(255-t.r)*e),g:Math.min(255,t.g+(255-t.g)*e),b:Math.min(255,t.b+(255-t.b)*e),a:t.a??1}),D=(t,e)=>({r:Math.max(0,t.r*(1-e)),g:Math.max(0,t.g*(1-e)),b:Math.max(0,t.b*(1-e)),a:t.a??1}),k=(t,e=null,r=3)=>{const s=h(t),n=e?h(e):{r:255,g:255,b:255,a:1};let a=C(s,n);if(a>=r)return t;const u=M(n)<.5;let o={...s,a:s.a??1};const y=20,f=.1;for(let d=0;d<y&&(u?o=S(o,f):o=D(o,f),a=C(o,n),!(a>=r));d++);return L(o)};export{k as adjustColorForContrast,C as checkContrast,h as colorToRgba,T as getContrastingTextColor,M as getLuminance,w as isDarkColor,F as isLightColor};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{adjustColorForContrast as r,checkContrast as e,colorToRgba as s,getContrastingTextColor as a,getLuminance as C,isDarkColor as i,isLightColor as n}from"./color.js";import{sML as l,sMLWithRequest as c}from"./sML.js";export{r as adjustColorForContrast,e as checkContrast,s as colorToRgba,a as getContrastingTextColor,C as getLuminance,i as isDarkColor,n as isLightColor,l as sML,c as sMLWithRequest};
package/dist/sML.js ADDED
@@ -0,0 +1 @@
1
+ const a=()=>typeof navigator>"u"?"":navigator.userAgent||"",l=()=>typeof navigator>"u"?void 0:navigator.userAgentData||void 0;class u{constructor(){const n=l(),s=a(),d=e=>(typeof e=="string"||typeof e=="number")&&e?String(e).replace(/_/g,".").split(".").map(r=>parseInt(r)||0):[],i=(e="")=>{if(!e)return[];const r=new RegExp("^.*"+e+"[ :\\/]?(\\d+([\\._]\\d+)*).*$");return r.test(s)?d(s.replace(r,"$1")):[]};this.OS=(e=>(/(macOS|Mac OS X)/.test(s)?(/\(iP(hone|od touch);/.test(s)&&(e.iOS=i("CPU (?:iPhone )?OS ")),/\(iPad;/.test(s)?e.iOS=e.iPadOS=i("CPU (?:iPhone )?OS "):/(macOS|Mac OS X) \d/.test(s)&&(document.ontouchend!==void 0?e.iOS=e.iPadOS=i():e.macOS=i("(?:macOS|Mac OS X) "))):/Windows( NT)? \d/.test(s)?e.Windows=(r=>r[0]!==6||!r[1]?r:r[1]===1?[7]:r[1]===2?[8]:[8,1])(i("Windows(?: NT)?")):/Android \d/.test(s)?e.Android=i("Android"):/CrOS/.test(s)?e.ChromeOS=i():/X11;/.test(s)&&(e.Linux=i()),e))({}),n&&n.getHighEntropyValues(["architecture","model","platform","platformVersion","uaFullVersion"]).then(e=>(r=>{const t=e.platform,o=e.platformVersion;if(!(!t||!o)){if(/^i(OS|P(hone|od touch))$/.test(t))r.iOS=d(o);else if(/^iPad(OS)?$/.test(t))r.iOS=r.iPadOS=d(o);else if(/^(macOS|(Mac )?OS X|Mac(Intel)?)$/.test(t))document.ontouchend!==void 0?r.iOS=r.iPadOS=d():r.macOS=d(o);else if(/^(Microsoft )?Windows$/.test(t))r.Windows=d(o);else if(/^(Google )?Android$/.test(t))r.Android=d(o);else if(/^((Google )?Chrome OS|CrOS)$/.test(t))r.ChromeOS=d(o);else if(/^(Linux|Ubuntu|X11)$/.test(t))r.Linux=d(o);else return;Object.keys(this.OS).forEach(f=>delete this.OS[f]),Object.assign(this.OS,r)}})({})),this.UA=(e=>{let r=!1;if(n&&Array.isArray(n.brands)){const t=n.brands.reduce((o,f)=>(o[f.brand]=[f.version*1],o),{});t["Google Chrome"]?(r=!0,e.Blink=e.Chromium=t.Chromium||[],e.Chrome=t["Google Chrome"]):t["Microsoft Edge"]?(r=!0,e.Blink=e.Chromium=t.Chromium||[],e.Edge=t["Microsoft Edge"]):t.Opera&&(r=!0,e.Blink=e.Chromium=t.Chromium||[],e.Opera=t.Opera)}return r||(/ Gecko\/\d/.test(s)?(e.Gecko=i("rv"),/ Waterfox\/\d/.test(s)?e.Waterfox=i("Waterfox"):/ Firefox\/\d/.test(s)&&(e.Firefox=i("Firefox"))):/ Edge\/\d/.test(s)?(e.EdgeHTML=i("Edge"),e.Edge=e.EdgeHTML):/ Chrom(ium|e)\/\d/.test(s)?(e.Blink=e.Chromium=(t=>t[0]?t:i("Chrome"))(i("Chromium")),/ EdgA?\/\d/.test(s)?e.Edge=(t=>t[0]?t:i("Edg"))(i("EdgA")):/ OPR\/\d/.test(s)?e.Opera=i("OPR"):/ Vivaldi\/\d/.test(s)?e.Vivaldi=i("Vivaldi"):/ Silk\/\d/.test(s)?e.Silk=i("Silk"):/ UCBrowser\/\d/.test(s)?e.UCBrowser=i("UCBrowser"):/ Phoebe\/\d/.test(s)?e.Phoebe=i("Phoebe"):e.Chrome=(t=>t[0]?t:e.Chromium)(i("Chrome"))):/ AppleWebKit\/\d/.test(s)?(e.WebKit=i("AppleWebKit"),/ CriOS \d/.test(s)?e.Chrome=i("CriOS"):/ FxiOS \d/.test(s)?e.Firefox=i("FxiOS"):/ EdgiOS\/\d/.test(s)?e.Edge=i("EdgiOS"):/ Version\/\d/.test(s)&&(e.Safari=i("Version"))):/ Trident\/\d/.test(s)&&(e.Trident=i("Trident"),e.InternetExplorer=(t=>t[0]?t:i("MSIE"))(i("rv")))),/[\[; ]FB(AN|_IAB)\//.test(s)&&(e.Facebook=i("FBAV")),/ Line\/\d/.test(s)&&(e.LINE=i("Line")),e})({}),this.Env={get:()=>[this.OS,this.UA].reduce((e,r)=>{for(const t in r)r[t]&&e.push(t);return e},[])}}}class h extends u{get iOSRequest(){const n=l(),s=a();if(this.OS.iOS&&!this.OS.iPadOS)return"mobile";if(this.OS.iPadOS)return/\(iPad;/.test(s)||n&&/^iPad(OS)?$/.test(n.platform)?"mobile":"desktop"}}const m=new u,g=new h;export{m as sML,g as sMLWithRequest};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@readium/helpers",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "General-purpose browser helpers for Readium packages",
6
+ "author": "readium",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/readium/ts-toolkit.git",
10
+ "directory": "helpers"
11
+ },
12
+ "license": "BSD-3-Clause",
13
+ "bugs": {
14
+ "url": "https://github.com/readium/ts-toolkit/issues"
15
+ },
16
+ "homepage": "https://github.com/readium/ts-toolkit",
17
+ "types": "./types/src/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "ts-toolkit-source": "./src/index.ts",
21
+ "types": "./types/src/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "sideEffects": false,
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "types"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "scripts": {
35
+ "clean": "rimraf types dist",
36
+ "build": "pnpm clean && tsc && vite build"
37
+ },
38
+ "dependencies": {
39
+ "user-agent-data-types": "^0.4.2"
40
+ },
41
+ "devDependencies": {
42
+ "rimraf": "^6.1.2",
43
+ "tslib": "^2.8.1",
44
+ "typescript": "^5.9.3",
45
+ "vite": "^7.3.1"
46
+ }
47
+ }
package/src/color.ts ADDED
@@ -0,0 +1,268 @@
1
+ // Lazy canvas/offscreen canvas initialization for color conversion
2
+ let canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
3
+ let ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null;
4
+
5
+ // Track which pixel to use next for color sampling (0-24 for 5x5 grid)
6
+ let currentPixelIndex = 0;
7
+
8
+ // Default color for failed conversions
9
+ const DEFAULT_COLOR = { r: 255, g: 255, b: 255, a: 1 };
10
+
11
+ // Cache for computed color conversions
12
+ const colorCache = new Map<string, { r: number; g: number; b: number; a: number; } | null>();
13
+
14
+ const getCanvasContext = (): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null => {
15
+ if (!canvas) {
16
+ // Try to use OffscreenCanvas if available
17
+ if (typeof OffscreenCanvas !== "undefined") {
18
+ canvas = new OffscreenCanvas(5, 5);
19
+ ctx = canvas.getContext("2d", {
20
+ willReadFrequently: true,
21
+ desynchronized: true
22
+ });
23
+ } else {
24
+ // Fall back to regular canvas
25
+ const htmlCanvas = document.createElement("canvas");
26
+ htmlCanvas.width = 5;
27
+ htmlCanvas.height = 5;
28
+ canvas = htmlCanvas;
29
+ ctx = htmlCanvas.getContext("2d", {
30
+ willReadFrequently: true,
31
+ desynchronized: true
32
+ });
33
+ }
34
+ }
35
+ return ctx;
36
+ };
37
+
38
+ const isSpecialColorValue = (color: string): boolean => {
39
+ if (!color) return true;
40
+
41
+ const normalizedColor = color.trim().toLowerCase();
42
+
43
+ // Check for CSS variables
44
+ if (normalizedColor.startsWith("var(")) {
45
+ return true;
46
+ }
47
+
48
+ // Check for CSS color keywords
49
+ const cssKeywords = [
50
+ "transparent",
51
+ "currentcolor",
52
+ "inherit",
53
+ "initial",
54
+ "revert",
55
+ "unset",
56
+ "revert-layer"
57
+ ];
58
+
59
+ if (cssKeywords.includes(normalizedColor)) {
60
+ return true;
61
+ }
62
+
63
+ // Check for gradients
64
+ const gradientTypes = [
65
+ "linear-gradient",
66
+ "radial-gradient",
67
+ "conic-gradient",
68
+ "repeating-linear-gradient",
69
+ "repeating-radial-gradient",
70
+ "repeating-conic-gradient"
71
+ ];
72
+
73
+ return gradientTypes.some(grad => normalizedColor.includes(grad));
74
+ };
75
+
76
+ const warnAboutInvalidColor = (color: string, reason: string): void => {
77
+ console.warn(
78
+ `[Decorator] Could not parse color: "${color}". ${reason} Falling back to ${JSON.stringify(DEFAULT_COLOR)} to compute contrast. Please use a CSS color value that can be computed to RGB(A).`
79
+ );
80
+ };
81
+
82
+ export const colorToRgba = (
83
+ color: string,
84
+ backgroundColor: string | null = null
85
+ ): { r: number; g: number; b: number; a: number } => {
86
+ // Check cache with background key if provided
87
+ const cacheKey = backgroundColor ? `${color}|${backgroundColor}` : color;
88
+ const cached = colorCache.get(cacheKey);
89
+ if (cached !== undefined) {
90
+ return cached ?? DEFAULT_COLOR;
91
+ }
92
+
93
+ // Check for special color values
94
+ if (isSpecialColorValue(color)) {
95
+ warnAboutInvalidColor(color, "Unsupported color format or special value.");
96
+ colorCache.set(cacheKey, null);
97
+ return DEFAULT_COLOR;
98
+ }
99
+
100
+ const context = getCanvasContext();
101
+ if (!context) {
102
+ warnAboutInvalidColor(color, "Could not get canvas context.");
103
+ colorCache.set(cacheKey, null);
104
+ return DEFAULT_COLOR;
105
+ }
106
+
107
+ try {
108
+ // Clear and initialize canvas at the start of each cycle
109
+ if (currentPixelIndex === 0) {
110
+ context.clearRect(0, 0, 5, 5);
111
+ }
112
+
113
+ // Calculate which pixel to use for this operation
114
+ const x = currentPixelIndex % 5;
115
+ const y = Math.floor(currentPixelIndex / 5);
116
+
117
+ // Clear just this pixel to ensure clean state
118
+ context.clearRect(x, y, 1, 1);
119
+
120
+ // Fill background color if provided
121
+ if (backgroundColor) {
122
+ context.fillStyle = backgroundColor;
123
+ context.fillRect(x, y, 1, 1);
124
+ }
125
+
126
+ // Draw the color at the current pixel
127
+ context.fillStyle = color;
128
+ context.fillRect(x, y, 1, 1);
129
+
130
+ // Get the pixel data for this specific pixel
131
+ const imageData = context.getImageData(x, y, 1, 1);
132
+
133
+ // Move to next pixel for next call
134
+ currentPixelIndex = (currentPixelIndex + 1) % 25;
135
+
136
+ // Get the pixel data for the pixel we just sampled
137
+ const [r, g, b, a] = imageData.data;
138
+
139
+ // If the color is completely transparent, return default
140
+ if (a === 0) {
141
+ warnAboutInvalidColor(color, "Fully transparent color.");
142
+ colorCache.set(cacheKey, null);
143
+ return DEFAULT_COLOR;
144
+ }
145
+
146
+ const result = { r, g, b, a: a / 255 };
147
+ colorCache.set(cacheKey, result);
148
+ return result;
149
+ } catch (error) {
150
+ warnAboutInvalidColor(color, `Error: ${error instanceof Error ? error.message : String(error)}`);
151
+ colorCache.set(cacheKey, null);
152
+ return DEFAULT_COLOR;
153
+ }
154
+ };
155
+
156
+ const toLinear = (c: number): number => {
157
+ const normalized = c / 255;
158
+ return normalized <= 0.03928
159
+ ? normalized / 12.92
160
+ : Math.pow((normalized + 0.055) / 1.055, 2.4);
161
+ };
162
+
163
+ export const getLuminance = (color: { r: number; g: number; b: number; a?: number }): number => {
164
+ // Convert sRGB to linear RGB and apply WCAG 2.2 formula
165
+ const r = toLinear(color.r);
166
+ const g = toLinear(color.g);
167
+ const b = toLinear(color.b);
168
+
169
+ // WCAG 2.2 relative luminance formula (returns 0-1)
170
+ // Note: Alpha is ignored for contrast calculations. WCAG 2.2 only defines contrast for opaque colors,
171
+ // and semi-transparent colors have a range of possible contrast ratios depending on background.
172
+ // For text readability decisions, we use the base color as the most conservative approach.
173
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
174
+ return luminance;
175
+ };
176
+
177
+ export const checkContrast = (
178
+ color1: string | { r: number; g: number; b: number; a?: number },
179
+ color2: string | { r: number; g: number; b: number; a?: number }
180
+ ): number => {
181
+ const rgba1 = typeof color1 === "string" ? colorToRgba(color1) : color1;
182
+ const rgba2 = typeof color2 === "string" ? colorToRgba(color2) : color2;
183
+
184
+ const l1 = getLuminance(rgba1);
185
+ const l2 = getLuminance(rgba2);
186
+
187
+ const lighter = Math.max(l1, l2);
188
+ const darker = Math.min(l1, l2);
189
+ return (lighter + 0.05) / (darker + 0.05);
190
+ };
191
+
192
+ export const isDarkColor = (color: string, blendedWith: string | null = null): boolean => {
193
+ const blended = colorToRgba(color, blendedWith);
194
+ const contrastWithWhite = checkContrast(blended, { r: 255, g: 255, b: 255, a: 1 });
195
+ const contrastWithBlack = checkContrast(blended, { r: 0, g: 0, b: 0, a: 1 });
196
+ return contrastWithWhite > contrastWithBlack;
197
+ };
198
+
199
+ export const isLightColor = (color: string, blendedWith: string | null = null): boolean => {
200
+ return !isDarkColor(color, blendedWith);
201
+ };
202
+
203
+ export const getContrastingTextColor = (color: string, blendedWith: string | null = null): "black" | "white" => {
204
+ return isDarkColor(color, blendedWith) ? "white" : "black";
205
+ };
206
+
207
+ const rgbaToCss = (rgba: { r: number; g: number; b: number; a?: number }): string => {
208
+ const a = rgba.a !== undefined ? rgba.a : 1;
209
+ return `rgba(${Math.round(rgba.r)}, ${Math.round(rgba.g)}, ${Math.round(rgba.b)}, ${a})`;
210
+ };
211
+
212
+ const lightenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
213
+ return {
214
+ r: Math.min(255, rgba.r + (255 - rgba.r) * factor),
215
+ g: Math.min(255, rgba.g + (255 - rgba.g) * factor),
216
+ b: Math.min(255, rgba.b + (255 - rgba.b) * factor),
217
+ a: rgba.a ?? 1
218
+ };
219
+ };
220
+
221
+ const darkenColor = (rgba: { r: number; g: number; b: number; a?: number }, factor: number): { r: number; g: number; b: number; a: number } => {
222
+ return {
223
+ r: Math.max(0, rgba.r * (1 - factor)),
224
+ g: Math.max(0, rgba.g * (1 - factor)),
225
+ b: Math.max(0, rgba.b * (1 - factor)),
226
+ a: rgba.a ?? 1
227
+ };
228
+ };
229
+
230
+ export const adjustColorForContrast = (
231
+ baseColor: string,
232
+ backgroundColor: string | null = null,
233
+ targetContrast: number = 3
234
+ ): string => {
235
+ const baseRgba = colorToRgba(baseColor);
236
+ const bgRgba = backgroundColor ? colorToRgba(backgroundColor) : { r: 255, g: 255, b: 255, a: 1 };
237
+
238
+ let currentContrast = checkContrast(baseRgba, bgRgba);
239
+
240
+ // If already meets target, return as-is
241
+ if (currentContrast >= targetContrast) {
242
+ return baseColor;
243
+ }
244
+
245
+ const bgLuminance = getLuminance(bgRgba);
246
+ const isBgDark = bgLuminance < 0.5;
247
+
248
+ let adjustedRgba = { ...baseRgba, a: baseRgba.a ?? 1 };
249
+ const maxIterations = 20;
250
+ const step = 0.1;
251
+
252
+ for (let i = 0; i < maxIterations; i++) {
253
+ if (isBgDark) {
254
+ // Dark background: lighten the base color
255
+ adjustedRgba = lightenColor(adjustedRgba, step);
256
+ } else {
257
+ // Light background: darken the base color
258
+ adjustedRgba = darkenColor(adjustedRgba, step);
259
+ }
260
+
261
+ currentContrast = checkContrast(adjustedRgba, bgRgba);
262
+ if (currentContrast >= targetContrast) {
263
+ break;
264
+ }
265
+ }
266
+
267
+ return rgbaToCss(adjustedRgba);
268
+ };
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./color.ts";
2
+ export * from "./sML.ts";
package/src/sML.ts ADDED
@@ -0,0 +1,143 @@
1
+ /*!
2
+ * (℠)
3
+ * # sML.js | I'm a Simple and Middling Library.
4
+ *
5
+ * * Copyright (c) Satoru MATSUSHIMA - https://github.com/satorumurmur/sML
6
+ * * Licensed under the MIT license. - http://www.opensource.org/licenses/mit-license.php
7
+ *
8
+ * Portions of this code come from the sML library
9
+ * Current version: 1.0.36
10
+ */
11
+ /// <reference types="user-agent-data-types" />
12
+
13
+ declare interface OSFlags {
14
+ iOS: number[];
15
+ macOS: number[];
16
+ iPadOS: number[];
17
+ WindowsPhone: number[];
18
+ ChromeOS: number[];
19
+ Windows: number[];
20
+ Android: number[];
21
+ Linux: number[];
22
+ Firefox: boolean;
23
+ }
24
+
25
+ declare interface UAFlags {
26
+ Gecko: number[];
27
+ Firefox: number[];
28
+ Waterfox: number[];
29
+ Opera: number[];
30
+ Silk: number[];
31
+ Blink: number[];
32
+ EdgeHTML: number[];
33
+ Chrome: number[];
34
+ Chromium: number[];
35
+ Phoebe: number[];
36
+ UCBrowser: number[];
37
+ Vivaldi: number[];
38
+ Safari: number[];
39
+ Edge: number[];
40
+ WebKit: number[];
41
+ Trident: number[];
42
+ InternetExplorer: number[];
43
+ Flash: number[];
44
+ Facebook: number[];
45
+ LINE: number[];
46
+ }
47
+
48
+ declare type iOSRequest = "mobile" | "desktop" | undefined;
49
+
50
+ // Fallback when global 'navigator' is not available, such as in SSR environments.
51
+ const userAgent = () => typeof navigator === "undefined" ? "" : (navigator.userAgent || "");
52
+ const userAgentData = () => typeof navigator === "undefined" ? undefined : (navigator.userAgentData || undefined);
53
+
54
+ class sMLFactory {
55
+ OS: OSFlags;
56
+ UA: UAFlags;
57
+ Env!: string[];
58
+
59
+ constructor() {
60
+ const NUAD = userAgentData(), NUA = userAgent();
61
+
62
+ const _sV = (V?: string | number) => (typeof V === "string" || typeof V === "number") && V ? String(V).replace(/_/g, ".").split(".").map(I => parseInt(I) || 0) : [];
63
+ const _dV = (Pre="") => {
64
+ if(!Pre) return [];
65
+ const RE = new RegExp("^.*" + Pre + "[ :\\/]?(\\d+([\\._]\\d+)*).*$");
66
+ if(!RE.test(NUA)) return [];
67
+ return _sV(NUA.replace(RE, "$1"));
68
+ };
69
+
70
+ this.OS = ((OS: OSFlags) => {
71
+ if( /(macOS|Mac OS X)/.test(NUA)) {
72
+ if(/\(iP(hone|od touch);/.test(NUA)) OS.iOS = _dV("CPU (?:iPhone )?OS ");
73
+ if( /\(iPad;/.test(NUA)) OS.iOS = OS.iPadOS = _dV("CPU (?:iPhone )?OS ");
74
+ else if( /(macOS|Mac OS X) \d/.test(NUA)) document.ontouchend !== undefined ? OS.iOS = OS.iPadOS = _dV() : OS.macOS = _dV("(?:macOS|Mac OS X) ");
75
+ } else if( /Windows( NT)? \d/.test(NUA)) OS.Windows = (V => V[0] !== 6 || !V[1] ? V : V[1] === 1 ? [7] : V[1] === 2 ? [8] : [8, 1])(_dV("Windows(?: NT)?"));
76
+ else if( /Android \d/.test(NUA)) OS.Android = _dV("Android");
77
+ else if( /CrOS/.test(NUA)) OS.ChromeOS = _dV();
78
+ else if( /X11;/.test(NUA)) OS.Linux = _dV();
79
+ return OS;
80
+ })({} as OSFlags); if(NUAD) NUAD.getHighEntropyValues(["architecture", "model", "platform", "platformVersion", "uaFullVersion"]).then((HEUAD: any) => (OS => { const Pf = HEUAD.platform, PfV = HEUAD.platformVersion; if(!Pf || !PfV) return;
81
+ if( /^i(OS|P(hone|od touch))$/.test(Pf)) OS.iOS = _sV(PfV);
82
+ else if( /^iPad(OS)?$/.test(Pf)) OS.iOS = OS.iPadOS = _sV(PfV);
83
+ else if(/^(macOS|(Mac )?OS X|Mac(Intel)?)$/.test(Pf)) document.ontouchend !== undefined ? OS.iOS = OS.iPadOS = _sV() : OS.macOS = _sV(PfV);
84
+ else if( /^(Microsoft )?Windows$/.test(Pf)) OS.Windows = _sV(PfV);
85
+ else if( /^(Google )?Android$/.test(Pf)) OS.Android = _sV(PfV);
86
+ else if( /^((Google )?Chrome OS|CrOS)$/.test(Pf)) OS.ChromeOS = _sV(PfV);
87
+ else if( /^(Linux|Ubuntu|X11)$/.test(Pf)) OS.Linux = _sV(PfV);
88
+ else return; /**/ Object.keys(this.OS).forEach(Key => delete (this.OS as any)[Key]), Object.assign(this.OS, OS);
89
+ })({} as OSFlags));
90
+
91
+ this.UA = ((UA: UAFlags) => { let _OK = false;
92
+ if(NUAD && Array.isArray(NUAD.brands)) { const BnV = NUAD.brands.reduce((BnV: Record<string, number[]>, _: NavigatorUABrandVersion) => { BnV[_.brand] = [(_.version as any) * 1]; return BnV; }, {});
93
+ if(BnV["Google Chrome"]) _OK = true, UA.Blink = UA.Chromium = BnV["Chromium"] || [], UA.Chrome = BnV["Google Chrome"];
94
+ else if(BnV["Microsoft Edge"]) _OK = true, UA.Blink = UA.Chromium = BnV["Chromium"] || [], UA.Edge = BnV["Microsoft Edge"];
95
+ else if(BnV["Opera"]) _OK = true, UA.Blink = UA.Chromium = BnV["Chromium"] || [], UA.Opera = BnV["Opera"];
96
+ } if(!_OK) {
97
+ if( / Gecko\/\d/.test(NUA)) { UA.Gecko = _dV("rv");
98
+ if( / Waterfox\/\d/.test(NUA)) UA.Waterfox = _dV("Waterfox");
99
+ else if( / Firefox\/\d/.test(NUA)) UA.Firefox = _dV("Firefox");
100
+ } else if( / Edge\/\d/.test(NUA)) { UA.EdgeHTML = _dV("Edge");
101
+ UA.Edge = UA.EdgeHTML;
102
+ } else if(/ Chrom(ium|e)\/\d/.test(NUA)) { UA.Blink = UA.Chromium = (V => V[0] ? V : _dV("Chrome"))(_dV("Chromium"));
103
+ if( / EdgA?\/\d/.test(NUA)) UA.Edge = (V => V[0] ? V : _dV("Edg"))(_dV("EdgA"));
104
+ else if( / OPR\/\d/.test(NUA)) UA.Opera = _dV("OPR");
105
+ else if( / Vivaldi\/\d/.test(NUA)) UA.Vivaldi = _dV("Vivaldi");
106
+ else if( / Silk\/\d/.test(NUA)) UA.Silk = _dV("Silk");
107
+ else if( / UCBrowser\/\d/.test(NUA)) UA.UCBrowser = _dV("UCBrowser");
108
+ else if( / Phoebe\/\d/.test(NUA)) UA.Phoebe = _dV("Phoebe");
109
+ else UA.Chrome = (V => V[0] ? V : UA.Chromium)(_dV("Chrome"));
110
+ } else if( / AppleWebKit\/\d/.test(NUA)) { UA.WebKit = _dV("AppleWebKit");
111
+ if( / CriOS \d/.test(NUA)) UA.Chrome = _dV("CriOS");
112
+ else if( / FxiOS \d/.test(NUA)) UA.Firefox = _dV("FxiOS");
113
+ else if( / EdgiOS\/\d/.test(NUA)) UA.Edge = _dV("EdgiOS");
114
+ else if( / Version\/\d/.test(NUA)) UA.Safari = _dV("Version");
115
+ } else if( / Trident\/\d/.test(NUA)) { UA.Trident = _dV("Trident");
116
+ UA.InternetExplorer = (V => V[0] ? V : _dV("MSIE"))(_dV("rv"));
117
+ }
118
+ } /*+*/ if( /[\[; ]FB(AN|_IAB)\//.test(NUA)) UA.Facebook = _dV("FBAV");
119
+ /*+*/ if( / Line\/\d/.test(NUA)) UA.LINE = _dV("Line");
120
+ return UA;
121
+ })({} as UAFlags);
122
+
123
+ (this.Env as any) = { get: () => [this.OS, this.UA].reduce((Env: string[], OS_UA) => { for(const Par in OS_UA) if((OS_UA as any)[Par]) Env.push(Par); return Env; }, []) };
124
+ }
125
+ }
126
+
127
+ class sMLFactoryWithRequest extends sMLFactory {
128
+ get iOSRequest(): iOSRequest {
129
+ const NUAD = userAgentData(), NUA = userAgent();
130
+
131
+ if (this.OS.iOS && !this.OS.iPadOS) {
132
+ return "mobile";
133
+ } else if (this.OS.iPadOS) {
134
+ return (/\(iPad;/.test(NUA) || (NUAD && /^iPad(OS)?$/.test(NUAD.platform))) ? "mobile" : "desktop"
135
+ }
136
+
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ const sML = new sMLFactory();
142
+ const sMLWithRequest = new sMLFactoryWithRequest();
143
+ export { sML, sMLWithRequest };
@@ -0,0 +1,27 @@
1
+ export declare const colorToRgba: (color: string, backgroundColor?: string | null) => {
2
+ r: number;
3
+ g: number;
4
+ b: number;
5
+ a: number;
6
+ };
7
+ export declare const getLuminance: (color: {
8
+ r: number;
9
+ g: number;
10
+ b: number;
11
+ a?: number;
12
+ }) => number;
13
+ export declare const checkContrast: (color1: string | {
14
+ r: number;
15
+ g: number;
16
+ b: number;
17
+ a?: number;
18
+ }, color2: string | {
19
+ r: number;
20
+ g: number;
21
+ b: number;
22
+ a?: number;
23
+ }) => number;
24
+ export declare const isDarkColor: (color: string, blendedWith?: string | null) => boolean;
25
+ export declare const isLightColor: (color: string, blendedWith?: string | null) => boolean;
26
+ export declare const getContrastingTextColor: (color: string, blendedWith?: string | null) => "black" | "white";
27
+ export declare const adjustColorForContrast: (baseColor: string, backgroundColor?: string | null, targetContrast?: number) => string;
@@ -0,0 +1,2 @@
1
+ export * from "./color.ts";
2
+ export * from "./sML.ts";
@@ -0,0 +1,56 @@
1
+ /*!
2
+ * (℠)
3
+ * # sML.js | I'm a Simple and Middling Library.
4
+ *
5
+ * * Copyright (c) Satoru MATSUSHIMA - https://github.com/satorumurmur/sML
6
+ * * Licensed under the MIT license. - http://www.opensource.org/licenses/mit-license.php
7
+ *
8
+ * Portions of this code come from the sML library
9
+ * Current version: 1.0.36
10
+ */
11
+ declare interface OSFlags {
12
+ iOS: number[];
13
+ macOS: number[];
14
+ iPadOS: number[];
15
+ WindowsPhone: number[];
16
+ ChromeOS: number[];
17
+ Windows: number[];
18
+ Android: number[];
19
+ Linux: number[];
20
+ Firefox: boolean;
21
+ }
22
+ declare interface UAFlags {
23
+ Gecko: number[];
24
+ Firefox: number[];
25
+ Waterfox: number[];
26
+ Opera: number[];
27
+ Silk: number[];
28
+ Blink: number[];
29
+ EdgeHTML: number[];
30
+ Chrome: number[];
31
+ Chromium: number[];
32
+ Phoebe: number[];
33
+ UCBrowser: number[];
34
+ Vivaldi: number[];
35
+ Safari: number[];
36
+ Edge: number[];
37
+ WebKit: number[];
38
+ Trident: number[];
39
+ InternetExplorer: number[];
40
+ Flash: number[];
41
+ Facebook: number[];
42
+ LINE: number[];
43
+ }
44
+ declare type iOSRequest = "mobile" | "desktop" | undefined;
45
+ declare class sMLFactory {
46
+ OS: OSFlags;
47
+ UA: UAFlags;
48
+ Env: string[];
49
+ constructor();
50
+ }
51
+ declare class sMLFactoryWithRequest extends sMLFactory {
52
+ get iOSRequest(): iOSRequest;
53
+ }
54
+ declare const sML: sMLFactory;
55
+ declare const sMLWithRequest: sMLFactoryWithRequest;
56
+ export { sML, sMLWithRequest };