├── .gitignore
├── README.md
├── index.js
├── now.json
├── package.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 | public
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | [![][hero]][hero]
3 |
4 | [hero]: https://contrast.now.sh/cff/40f?size=256
5 |
6 | # Contrast Swatch
7 |
8 | Image microservice for color contrast information
9 |
10 | [![][a]][a]
11 | [![][b]][b]
12 | [![][c]][c]
13 |
14 | [a]: https://contrast.now.sh/bcf/409
15 | [b]: https://contrast.now.sh/98f/206
16 | [c]: https://contrast.now.sh/fff/40f
17 |
18 | ## Usage
19 |
20 | Contrast swatch images can be used in any place an image is rendered.
21 | The URL accepts a foreground and background color.
22 |
23 | [`https://contrast.now.sh/cff/40f`][example]
24 |
25 | [example]: https://contrast.now.sh/cff/40f
26 |
27 | **HTML**
28 |
29 | ```html
30 |
31 | ```
32 |
33 | **Markdown**
34 |
35 | ```md
36 | 
37 | ```
38 |
39 | ## React
40 |
41 | You can wrap the image in a React component (or any templating engine) for generating documentation.
42 |
43 | ```js
44 | import React from 'react'
45 |
46 | export default ({
47 | foreground,
48 | background,
49 | ...props
50 | }) =>
51 |
56 | ```
57 |
58 | ## RGB
59 |
60 | Compare two `rgb` values, or an `rgb` and a hex value:
61 |
62 | ```
63 | https://contrast.now.sh/rgb(204,255,255)/40f
64 | ```
65 |
66 | [![][rgb]][rgb]
67 |
68 | [rgb]: https://contrast.now.sh/rgb(204,255,255)/40f
69 |
70 | ## Customization
71 |
72 | Use URL queries to customize the styles.
73 |
74 | ```
75 | https://contrast.now.sh/cff/40f?width=256&height=96&fontSize=1.25
76 | ```
77 |
78 | **Pass/Fail Label**
79 |
80 | [![][pass]][pass]
81 | [![][fail]][fail]
82 |
83 | [pass]: https://contrast.now.sh/cff/40f?width=256&height=128&label=1
84 | [fail]: https://contrast.now.sh/a6f/40f?width=256&height=128&label=1
85 |
86 | **Font Size**
87 |
88 | [![][smallfont]][smallfont]
89 | [![][largefont]][largefont]
90 |
91 | [smallfont]: https://contrast.now.sh/cff/40f?width=256&height=128&fontSize=0.5
92 | [largefont]: https://contrast.now.sh/cff/40f?width=256&height=128&fontSize=2
93 |
94 | **Size**
95 |
96 | [![][large]][large]
97 | [![][small]][small]
98 |
99 | [large]: https://contrast.now.sh/cff/40f?size=320
100 | [small]: https://contrast.now.sh/cff/40f?size=48
101 |
102 | **Width & Height**
103 |
104 | [![][wide]][wide]
105 | [![][tall]][tall]
106 |
107 | [wide]: https://contrast.now.sh/cff/40f?width=256&height=48
108 | [tall]: https://contrast.now.sh/cff/40f?width=32&height=48
109 |
110 | **Custom Text**
111 |
112 | [![][text]][text]
113 |
114 | [text]: https://contrast.now.sh/cff/40f?width=256&text=Aa
115 |
116 | **Font Weight**
117 |
118 | [![][weight]][weight]
119 |
120 | [weight]: https://contrast.now.sh/cff/40f?fontWeight=900&width=256
121 |
122 | **Radius**
123 |
124 | [![][rounded]][rounded]
125 | [![][circle]][circle]
126 |
127 | [rounded]: https://contrast.now.sh/cff/40f?radius=8
128 | [circle]: https://contrast.now.sh/cff/40f?radius=48
129 |
130 | ## Options
131 |
132 |
133 | Option | Description
134 | ---|---
135 | `size` | Width & height in pixels
136 | `width` | Width of image in pixels
137 | `height` | Height of image in pixels (font size will scale based on height)
138 | `fontSize` | Relative font size (default 1)
139 | `fontWeight`| Font weight (default 1)
140 | `label` | Show a pass/fail label based on the [WCAG Criteria][wcag]
141 | `radius` | Border radius
142 | `baseline` | Shift text baseline down
143 | `text` | Render any custom text
144 |
145 | ## Metadata
146 |
147 | A JSON response with color contrast information can be fetched by adding the `type=json` query to the URL.
148 |
149 | ```
150 | https://contrast.now.sh/cff/40f?type=json
151 | ```
152 |
153 | **Note:** the returned JSON schema might change in a future version
154 |
155 | [wcag]: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
156 |
157 | ## Related
158 |
159 | - [Colorable](https://colorable.jxnblk.com)
160 | - [Use Contrast](https://usecontrast.com/)
161 |
162 | MIT License
163 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const Color = require('color')
2 | const { createElement: h } = require('react')
3 | const { renderToStaticMarkup } = require('react-dom/server')
4 |
5 | const homepage = 'https://github.com/jxnblk/contrast-swatch'
6 |
7 | const parseURL = (req) => {
8 | const { foreground, background, ...query } = req.query
9 |
10 | if (!foreground || !background) return null
11 |
12 | return {
13 | foreground,
14 | background,
15 | query,
16 | }
17 | }
18 |
19 | const HEX = /^[A-Fa-f0-9]{3,6}$/
20 |
21 | const getColor = (raw) => {
22 | if (HEX.test(raw)) raw = '#' + raw
23 | try {
24 | return Color(raw)
25 | } catch (e) {
26 | return null
27 | }
28 | }
29 |
30 | const getLabel = contrast => {
31 | if (contrast >= 7) return 'AAA'
32 | if (contrast >= 4.5) return 'AA'
33 | if (contrast >= 3) return 'lg'
34 | return 'Fail'
35 | }
36 |
37 | const parseColors = (data) => {
38 | const foreground = getColor(data.foreground)
39 | const background = getColor(data.background)
40 | const contrast = foreground.contrast(background)
41 | const label = getLabel(contrast)
42 |
43 | return {
44 | foreground,
45 | background,
46 | raw: data,
47 | hex: {
48 | foreground: foreground.hex(),
49 | background: background.hex(),
50 | },
51 | rgb: {
52 | foreground: foreground.rgb().array(),
53 | background: background.rgb().array(),
54 | },
55 | contrast,
56 | label,
57 | }
58 | }
59 |
60 | const round = n => Math.floor(100 * n) / 100
61 |
62 | const svg = req => {
63 | const data = parseURL(req)
64 | if (!data) return null
65 | const colors = parseColors(data)
66 |
67 | const opts = Object.assign({
68 | width: 128,
69 | height: 128,
70 | font: 'system-ui,sans-serif',
71 | fontSize: 1,
72 | fontWeight: 700,
73 | baseline: 0,
74 | label: false,
75 | radius: 0,
76 | }, data.query)
77 |
78 | const width = Number(opts.size || opts.width)
79 | const height = Number(opts.size || opts.height)
80 | const xwidth = 32 * (width / height)
81 | const fontSize = Number(opts.fontSize) * 8
82 |
83 | let text = [ round(colors.contrast) ]
84 | if (Boolean(opts.label)) {
85 | text.push(colors.label)
86 | }
87 | if (opts.text) text = [opts.text]
88 |
89 | const el = h('svg', {
90 | xmlns: 'http://www.w3.org/2000/svg',
91 | width,
92 | height,
93 | viewBox: `0 0 ${xwidth} 32`,
94 | fill: colors.hex.foreground,
95 | style: {
96 | fontFamily: opts.font,
97 | fontWeight: opts.fontWeight,
98 | fontSize,
99 | },
100 | },
101 | h('rect', {
102 | width: xwidth,
103 | height: 32,
104 | fill: colors.hex.background,
105 | rx: opts.radius,
106 | }),
107 | h('text', {
108 | textAnchor: 'middle',
109 | x: xwidth / 2,
110 | y: 16 + Number(opts.baseline),
111 | dominantBaseline: 'middle',
112 | },
113 | text.join(' ')
114 | )
115 | )
116 |
117 | const svg = renderToStaticMarkup(el)
118 |
119 | return {
120 | ...data,
121 | colors,
122 | svg,
123 | }
124 | }
125 |
126 | module.exports = async (req, res) => {
127 | const data = svg(req)
128 |
129 | if (!data) return
130 |
131 | switch (data.query.type) {
132 | case 'json':
133 | return res.send(data)
134 | }
135 |
136 | res.setHeader('Content-Type', 'image/svg+xml;charset=utf-8')
137 | res.send(data.svg)
138 | }
139 |
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "alias": "contrast.now.sh",
4 | "public": true,
5 | "builds": [
6 | {
7 | "src": "index.js",
8 | "use": "@now/node"
9 | }
10 | ],
11 | "routes": [
12 | {
13 | "src": "/",
14 | "status": 302,
15 | "headers": {
16 | "Location": "https://github.com/jxnblk/contrast-swatch"
17 | },
18 | "continue": true
19 | },
20 | {
21 | "src": "/(?[^/]+)/(?[^/]+)",
22 | "dest": "/index.js?foreground=$fg&background=$bg",
23 | "headers": {
24 | "Cache-Control": "max-age=604800"
25 | }
26 | }
27 | ]
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "server",
4 | "version": "0.0.1",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "now dev"
8 | },
9 | "dependencies": {
10 | "color": "^3.1.2",
11 | "react": "^16.8.6",
12 | "react-dom": "^16.8.6"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | color-convert@^1.9.1:
6 | version "1.9.3"
7 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
8 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
9 | dependencies:
10 | color-name "1.1.3"
11 |
12 | color-name@1.1.3:
13 | version "1.1.3"
14 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
15 | integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
16 |
17 | color-name@^1.0.0:
18 | version "1.1.4"
19 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
20 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
21 |
22 | color-string@^1.5.2:
23 | version "1.5.3"
24 | resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
25 | integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
26 | dependencies:
27 | color-name "^1.0.0"
28 | simple-swizzle "^0.2.2"
29 |
30 | color@^3.1.2:
31 | version "3.1.2"
32 | resolved "https://registry.yarnpkg.com/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10"
33 | integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg==
34 | dependencies:
35 | color-convert "^1.9.1"
36 | color-string "^1.5.2"
37 |
38 | is-arrayish@^0.3.1:
39 | version "0.3.2"
40 | resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
41 | integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
42 |
43 | "js-tokens@^3.0.0 || ^4.0.0":
44 | version "4.0.0"
45 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
46 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
47 |
48 | loose-envify@^1.1.0, loose-envify@^1.4.0:
49 | version "1.4.0"
50 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
51 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
52 | dependencies:
53 | js-tokens "^3.0.0 || ^4.0.0"
54 |
55 | object-assign@^4.1.1:
56 | version "4.1.1"
57 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
58 | integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
59 |
60 | prop-types@^15.6.2:
61 | version "15.7.2"
62 | resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
63 | integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
64 | dependencies:
65 | loose-envify "^1.4.0"
66 | object-assign "^4.1.1"
67 | react-is "^16.8.1"
68 |
69 | react-dom@^16.8.6:
70 | version "16.8.6"
71 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
72 | integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==
73 | dependencies:
74 | loose-envify "^1.1.0"
75 | object-assign "^4.1.1"
76 | prop-types "^15.6.2"
77 | scheduler "^0.13.6"
78 |
79 | react-is@^16.8.1:
80 | version "16.8.6"
81 | resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
82 | integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==
83 |
84 | react@^16.8.6:
85 | version "16.8.6"
86 | resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
87 | integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==
88 | dependencies:
89 | loose-envify "^1.1.0"
90 | object-assign "^4.1.1"
91 | prop-types "^15.6.2"
92 | scheduler "^0.13.6"
93 |
94 | scheduler@^0.13.6:
95 | version "0.13.6"
96 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889"
97 | integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==
98 | dependencies:
99 | loose-envify "^1.1.0"
100 | object-assign "^4.1.1"
101 |
102 | simple-swizzle@^0.2.2:
103 | version "0.2.2"
104 | resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
105 | integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
106 | dependencies:
107 | is-arrayish "^0.3.1"
108 |
--------------------------------------------------------------------------------