legible 0.1.0__tar.gz
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.
- legible-0.1.0/PKG-INFO +135 -0
- legible-0.1.0/README.md +115 -0
- legible-0.1.0/pyproject.toml +42 -0
- legible-0.1.0/setup.cfg +4 -0
- legible-0.1.0/src/gatekit/contrast.json +5 -0
- legible-0.1.0/src/gatekit/contrast.py +264 -0
- legible-0.1.0/src/gatekit/deadname_gate.json +13 -0
- legible-0.1.0/src/gatekit/deadname_gate.py +223 -0
- legible-0.1.0/src/gatekit/typefloor.json +5 -0
- legible-0.1.0/src/gatekit/typefloor.py +245 -0
- legible-0.1.0/src/legible.egg-info/PKG-INFO +135 -0
- legible-0.1.0/src/legible.egg-info/SOURCES.txt +14 -0
- legible-0.1.0/src/legible.egg-info/dependency_links.txt +1 -0
- legible-0.1.0/src/legible.egg-info/entry_points.txt +4 -0
- legible-0.1.0/src/legible.egg-info/top_level.txt +1 -0
- legible-0.1.0/tests/test_gates.py +149 -0
legible-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: legible
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Three build gates for things a linter will not catch: type below a readable floor, colour that cannot be read on the surface it sits on, and a retired name reaching anything a human reads.
|
|
5
|
+
Author-email: Vincent Gonzalez <hello@f-keys.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://f-keys.com/legible/
|
|
8
|
+
Project-URL: Source, https://github.com/vince-gonzalez/f-keys/tree/main/gatekit
|
|
9
|
+
Project-URL: Issues, https://github.com/vince-gonzalez/f-keys/issues
|
|
10
|
+
Keywords: accessibility,a11y,wcag,contrast,font-size,ci,build-gate,linter,deadname,rename
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
╔════════════════════════════════════════════════════════════════════════════════════════════╗
|
|
23
|
+
║ ║
|
|
24
|
+
║ ██████╗ █████╗ ████████╗███████╗██╗ ██╗██╗████████╗ ║
|
|
25
|
+
║ ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝██║ ██╔╝██║╚══██╔══╝ ║
|
|
26
|
+
║ ██║ ███╗███████║ ██║ █████╗ █████╔╝ ██║ ██║ ║
|
|
27
|
+
║ ██║ ██║██╔══██║ ██║ ██╔══╝ ██╔═██╗ ██║ ██║ ║
|
|
28
|
+
║ ╚██████╔╝██║ ██║ ██║ ███████╗██║ ██╗██║ ██║ ║
|
|
29
|
+
║ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ║
|
|
30
|
+
║ ║
|
|
31
|
+
║ three gates a linter will not give you ║
|
|
32
|
+
║ ║
|
|
33
|
+
╚════════════════════════════════════════════════════════════════════════════════════════════╝
|
|
34
|
+
```
|
|
35
|
+
Three gates for defects a linter does not have an opinion about, because
|
|
36
|
+
none of them is a syntax error. Each one exists because a real person read
|
|
37
|
+
something and could not.
|
|
38
|
+
|
|
39
|
+
| Gate | Fails the build when |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `typefloor` | Type is declared below a readable floor (13px by default) |
|
|
42
|
+
| `contrast-gate` | A declared colour cannot reach 4.5:1 on any surface the file also declares |
|
|
43
|
+
| `deadname-gate` | A retired name reaches anything a human reads |
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
pip install legible
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Use
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
typefloor ./src # every CSS and inline declaration
|
|
55
|
+
contrast-gate ./src # WCAG 4.5:1 against declared surfaces
|
|
56
|
+
deadname-gate ./src # a name that should be gone
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each exits non-zero on a finding and prints the file and line. `--list`
|
|
60
|
+
shows every offender rather than the summary.
|
|
61
|
+
|
|
62
|
+
## Why these three
|
|
63
|
+
|
|
64
|
+
**typefloor.** Six-point type is not a style choice. This was written after
|
|
65
|
+
the same reader reported the same thing more than once, and the first time it
|
|
66
|
+
was pointed at a folder it found every violation immediately — what was
|
|
67
|
+
missing was never the tool, it was anything that pulled it.
|
|
68
|
+
|
|
69
|
+
**contrast-gate.** It reads CSS custom properties — `--ink: #hex` against
|
|
70
|
+
`--bg: #hex` — and reports the best surface each ink can reach. Dark grey on
|
|
71
|
+
navy measured 2.64:1 where body text needs 4.5, and a person found that, not
|
|
72
|
+
a build.
|
|
73
|
+
|
|
74
|
+
It does **not** read literal `color:` declarations. A stylesheet using
|
|
75
|
+
neither has nothing to compare, and it says so and exits 0 — which in CI
|
|
76
|
+
reads as a pass on a site nobody checked. Pass `--require-pairs` to fail
|
|
77
|
+
instead when nothing was found. The Action sets it.
|
|
78
|
+
|
|
79
|
+
**deadname-gate.** Fails a build when a name that should be gone reaches a
|
|
80
|
+
title, a footer, a byline, alt text or a comment — while leaving machine
|
|
81
|
+
identifiers alone, because renaming a repository path or a published
|
|
82
|
+
artifact breaks working links or contradicts a deposited record. Both halves
|
|
83
|
+
matter: a gate that cannot tell those apart gets switched off.
|
|
84
|
+
|
|
85
|
+
Written for a company rename. It is the same shape as the problem of a person
|
|
86
|
+
changing their name and finding it in three hundred places, which is the
|
|
87
|
+
harder version and the one worth getting right.
|
|
88
|
+
|
|
89
|
+
## Exit codes
|
|
90
|
+
|
|
91
|
+
| Code | Means |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `0` | Clean |
|
|
94
|
+
| `1` | It found something. The file and line are printed. |
|
|
95
|
+
| `2` | Misconfigured — it could not check, and will not report clean |
|
|
96
|
+
|
|
97
|
+
The third one matters. `deadname-gate` forbids nothing out of the box, so a
|
|
98
|
+
fresh install exits **2** with an explanation rather than **0**. A gate that
|
|
99
|
+
reports a pass while checking nothing is worse than no gate, and every one of
|
|
100
|
+
these three shipped a version that did exactly that before it was caught.
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
Each gate reads a JSON file of exceptions, so a deliberate choice is
|
|
105
|
+
recorded with a reason rather than silently re-flagged.
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
typefloor ./src --config typefloor.json
|
|
109
|
+
contrast-gate ./src --config contrast.json
|
|
110
|
+
deadname-gate ./src --config deadname_gate.json
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`TYPEFLOOR_CONFIG`, `CONTRAST_CONFIG` and `DEADNAME_CONFIG` do the same.
|
|
114
|
+
|
|
115
|
+
## In CI
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
- run: pip install legible
|
|
119
|
+
- run: typefloor ./src
|
|
120
|
+
- run: contrast-gate ./src --require-pairs
|
|
121
|
+
- run: deadname-gate ./src
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
There are Actions too, in `.github/actions/` of the source repository.
|
|
125
|
+
|
|
126
|
+
## A note on the argument
|
|
127
|
+
|
|
128
|
+
An earlier version ignored a bare path and scanned its own directory
|
|
129
|
+
instead, then printed `OK`. A gate that reports a pass on a tree it never
|
|
130
|
+
opened is worse than no gate, so a positional path is now the root. If you
|
|
131
|
+
pinned an earlier build, check what it is actually reading.
|
|
132
|
+
|
|
133
|
+
## Licence
|
|
134
|
+
|
|
135
|
+
MIT.
|
legible-0.1.0/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
```
|
|
2
|
+
╔════════════════════════════════════════════════════════════════════════════════════════════╗
|
|
3
|
+
║ ║
|
|
4
|
+
║ ██████╗ █████╗ ████████╗███████╗██╗ ██╗██╗████████╗ ║
|
|
5
|
+
║ ██╔════╝ ██╔══██╗╚══██╔══╝██╔════╝██║ ██╔╝██║╚══██╔══╝ ║
|
|
6
|
+
║ ██║ ███╗███████║ ██║ █████╗ █████╔╝ ██║ ██║ ║
|
|
7
|
+
║ ██║ ██║██╔══██║ ██║ ██╔══╝ ██╔═██╗ ██║ ██║ ║
|
|
8
|
+
║ ╚██████╔╝██║ ██║ ██║ ███████╗██║ ██╗██║ ██║ ║
|
|
9
|
+
║ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ║
|
|
10
|
+
║ ║
|
|
11
|
+
║ three gates a linter will not give you ║
|
|
12
|
+
║ ║
|
|
13
|
+
╚════════════════════════════════════════════════════════════════════════════════════════════╝
|
|
14
|
+
```
|
|
15
|
+
Three gates for defects a linter does not have an opinion about, because
|
|
16
|
+
none of them is a syntax error. Each one exists because a real person read
|
|
17
|
+
something and could not.
|
|
18
|
+
|
|
19
|
+
| Gate | Fails the build when |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `typefloor` | Type is declared below a readable floor (13px by default) |
|
|
22
|
+
| `contrast-gate` | A declared colour cannot reach 4.5:1 on any surface the file also declares |
|
|
23
|
+
| `deadname-gate` | A retired name reaches anything a human reads |
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install legible
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Use
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
typefloor ./src # every CSS and inline declaration
|
|
35
|
+
contrast-gate ./src # WCAG 4.5:1 against declared surfaces
|
|
36
|
+
deadname-gate ./src # a name that should be gone
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each exits non-zero on a finding and prints the file and line. `--list`
|
|
40
|
+
shows every offender rather than the summary.
|
|
41
|
+
|
|
42
|
+
## Why these three
|
|
43
|
+
|
|
44
|
+
**typefloor.** Six-point type is not a style choice. This was written after
|
|
45
|
+
the same reader reported the same thing more than once, and the first time it
|
|
46
|
+
was pointed at a folder it found every violation immediately — what was
|
|
47
|
+
missing was never the tool, it was anything that pulled it.
|
|
48
|
+
|
|
49
|
+
**contrast-gate.** It reads CSS custom properties — `--ink: #hex` against
|
|
50
|
+
`--bg: #hex` — and reports the best surface each ink can reach. Dark grey on
|
|
51
|
+
navy measured 2.64:1 where body text needs 4.5, and a person found that, not
|
|
52
|
+
a build.
|
|
53
|
+
|
|
54
|
+
It does **not** read literal `color:` declarations. A stylesheet using
|
|
55
|
+
neither has nothing to compare, and it says so and exits 0 — which in CI
|
|
56
|
+
reads as a pass on a site nobody checked. Pass `--require-pairs` to fail
|
|
57
|
+
instead when nothing was found. The Action sets it.
|
|
58
|
+
|
|
59
|
+
**deadname-gate.** Fails a build when a name that should be gone reaches a
|
|
60
|
+
title, a footer, a byline, alt text or a comment — while leaving machine
|
|
61
|
+
identifiers alone, because renaming a repository path or a published
|
|
62
|
+
artifact breaks working links or contradicts a deposited record. Both halves
|
|
63
|
+
matter: a gate that cannot tell those apart gets switched off.
|
|
64
|
+
|
|
65
|
+
Written for a company rename. It is the same shape as the problem of a person
|
|
66
|
+
changing their name and finding it in three hundred places, which is the
|
|
67
|
+
harder version and the one worth getting right.
|
|
68
|
+
|
|
69
|
+
## Exit codes
|
|
70
|
+
|
|
71
|
+
| Code | Means |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `0` | Clean |
|
|
74
|
+
| `1` | It found something. The file and line are printed. |
|
|
75
|
+
| `2` | Misconfigured — it could not check, and will not report clean |
|
|
76
|
+
|
|
77
|
+
The third one matters. `deadname-gate` forbids nothing out of the box, so a
|
|
78
|
+
fresh install exits **2** with an explanation rather than **0**. A gate that
|
|
79
|
+
reports a pass while checking nothing is worse than no gate, and every one of
|
|
80
|
+
these three shipped a version that did exactly that before it was caught.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Each gate reads a JSON file of exceptions, so a deliberate choice is
|
|
85
|
+
recorded with a reason rather than silently re-flagged.
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
typefloor ./src --config typefloor.json
|
|
89
|
+
contrast-gate ./src --config contrast.json
|
|
90
|
+
deadname-gate ./src --config deadname_gate.json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`TYPEFLOOR_CONFIG`, `CONTRAST_CONFIG` and `DEADNAME_CONFIG` do the same.
|
|
94
|
+
|
|
95
|
+
## In CI
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
- run: pip install legible
|
|
99
|
+
- run: typefloor ./src
|
|
100
|
+
- run: contrast-gate ./src --require-pairs
|
|
101
|
+
- run: deadname-gate ./src
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
There are Actions too, in `.github/actions/` of the source repository.
|
|
105
|
+
|
|
106
|
+
## A note on the argument
|
|
107
|
+
|
|
108
|
+
An earlier version ignored a bare path and scanned its own directory
|
|
109
|
+
instead, then printed `OK`. A gate that reports a pass on a tree it never
|
|
110
|
+
opened is worse than no gate, so a positional path is now the root. If you
|
|
111
|
+
pinned an earlier build, check what it is actually reading.
|
|
112
|
+
|
|
113
|
+
## Licence
|
|
114
|
+
|
|
115
|
+
MIT.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "legible"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Three build gates for things a linter will not catch: type below a readable floor, colour that cannot be read on the surface it sits on, and a retired name reaching anything a human reads."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Vincent Gonzalez", email = "hello@f-keys.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"accessibility", "a11y", "wcag", "contrast", "font-size",
|
|
15
|
+
"ci", "build-gate", "linter", "deadname", "rename",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
24
|
+
"Topic :: Software Development :: Testing",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://f-keys.com/legible/"
|
|
30
|
+
Source = "https://github.com/vince-gonzalez/f-keys/tree/main/gatekit"
|
|
31
|
+
Issues = "https://github.com/vince-gonzalez/f-keys/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
typefloor = "gatekit.typefloor:main"
|
|
35
|
+
contrast-gate = "gatekit.contrast:main"
|
|
36
|
+
deadname-gate = "gatekit.deadname_gate:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
gatekit = ["*.json"]
|
legible-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
============================================================
|
|
4
|
+
contrast - nothing on a page may be unreadable
|
|
5
|
+
F-Keys | www.f-keys.com
|
|
6
|
+
------------------------------------------------------------
|
|
7
|
+
WHY THIS EXISTS
|
|
8
|
+
|
|
9
|
+
"Dark grey on navy" has been reported by the person who has to
|
|
10
|
+
read it more than once, which makes it a class of defect
|
|
11
|
+
rather than a mistake. typefloor.py already stops type being
|
|
12
|
+
too small; this stops it being the wrong colour, by the same
|
|
13
|
+
method: measure it, fail the build, and stop relying on
|
|
14
|
+
anybody noticing.
|
|
15
|
+
|
|
16
|
+
WHAT IT MEASURES
|
|
17
|
+
|
|
18
|
+
The WCAG 2.1 contrast ratio between every colour a stylesheet
|
|
19
|
+
declares and the surface it is drawn on. A ratio is a number
|
|
20
|
+
between 1 (invisible) and 21 (black on white).
|
|
21
|
+
|
|
22
|
+
4.5 the floor for body text
|
|
23
|
+
3.0 large text, 24px or 19px bold
|
|
24
|
+
|
|
25
|
+
THE RULE, AND WHY IT IS THIS ONE
|
|
26
|
+
|
|
27
|
+
Comparing every ink against every surface flags pairs that
|
|
28
|
+
never occur together - a green meant for a black readout is
|
|
29
|
+
not a defect because it would be illegible on chrome. So each
|
|
30
|
+
ink has to reach the floor on at least ONE declared surface.
|
|
31
|
+
An ink that fails against all of them has nowhere it can
|
|
32
|
+
legitimately be used, which is exactly the "dark grey on
|
|
33
|
+
navy" case this exists for.
|
|
34
|
+
|
|
35
|
+
WHAT IT CANNOT SEE
|
|
36
|
+
|
|
37
|
+
A colour applied by script at runtime, and a colour whose
|
|
38
|
+
surface is decided by a parent this cannot resolve. Pairings
|
|
39
|
+
are read from the custom properties a file declares, which is
|
|
40
|
+
how these stylesheets are written; anything set inline in JS
|
|
41
|
+
is out of reach and stays that way rather than being guessed
|
|
42
|
+
at.
|
|
43
|
+
|
|
44
|
+
python tools/contrast.py report; exit 1 if any pair fails
|
|
45
|
+
python tools/contrast.py --list every pair, passing or not
|
|
46
|
+
python tools/contrast.py --root remapwrap
|
|
47
|
+
|
|
48
|
+
Pairs that must stay below the floor go in tools/contrast.json
|
|
49
|
+
with a reason, the same as typefloor.
|
|
50
|
+
============================================================
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import re
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
60
|
+
except (AttributeError, ValueError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
64
|
+
def _config():
|
|
65
|
+
if "--config" in sys.argv:
|
|
66
|
+
i = sys.argv.index("--config")
|
|
67
|
+
if i + 1 < len(sys.argv):
|
|
68
|
+
return os.path.abspath(sys.argv[i + 1])
|
|
69
|
+
return os.environ.get("CONTRAST_CONFIG") or os.path.join(HERE, "contrast.json")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
CONFIG = _config()
|
|
73
|
+
|
|
74
|
+
#: Which variables name a surface something is drawn on. Everything else
|
|
75
|
+
#: that looks like a colour is treated as ink.
|
|
76
|
+
SURFACES = ("bg", "background", "panel", "surface", "paper", "card",
|
|
77
|
+
"field", "sel", "face", "desktop", "title-b")
|
|
78
|
+
|
|
79
|
+
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "__pycache__",
|
|
80
|
+
"vendor", ".venv", "fonts", "assets"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_config():
|
|
84
|
+
if not os.path.exists(CONFIG):
|
|
85
|
+
return {"floor": 4.5, "exceptions": []}
|
|
86
|
+
with open(CONFIG, encoding="utf-8") as fh:
|
|
87
|
+
cfg = json.load(fh)
|
|
88
|
+
cfg.setdefault("floor", 4.5)
|
|
89
|
+
cfg.setdefault("exceptions", [])
|
|
90
|
+
return cfg
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def to_rgb(value):
|
|
94
|
+
v = value.strip().lower()
|
|
95
|
+
m = re.match(r"^#([0-9a-f]{3,8})$", v)
|
|
96
|
+
if m:
|
|
97
|
+
h = m.group(1)
|
|
98
|
+
if len(h) == 3:
|
|
99
|
+
h = "".join(c * 2 for c in h)
|
|
100
|
+
if len(h) < 6:
|
|
101
|
+
return None
|
|
102
|
+
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
103
|
+
m = re.match(r"^rgba?\(([^)]+)\)$", v)
|
|
104
|
+
if m:
|
|
105
|
+
parts = [p.strip() for p in m.group(1).replace("/", ",").split(",")]
|
|
106
|
+
try:
|
|
107
|
+
return tuple(int(float(p)) for p in parts[:3])
|
|
108
|
+
except ValueError:
|
|
109
|
+
return None
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def luminance(rgb):
|
|
114
|
+
out = []
|
|
115
|
+
for c in rgb:
|
|
116
|
+
c = c / 255.0
|
|
117
|
+
out.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
|
|
118
|
+
return 0.2126 * out[0] + 0.7152 * out[1] + 0.0722 * out[2]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def ratio(a, b):
|
|
122
|
+
la, lb = luminance(a), luminance(b)
|
|
123
|
+
hi, lo = max(la, lb), min(la, lb)
|
|
124
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def variables(text):
|
|
128
|
+
"""Every --name: value the file declares."""
|
|
129
|
+
out = {}
|
|
130
|
+
for name, value in re.findall(r"--([\w-]+)\s*:\s*([^;}]+)[;}]", text):
|
|
131
|
+
rgb = to_rgb(value)
|
|
132
|
+
if rgb:
|
|
133
|
+
out[name] = rgb
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def walk(root):
|
|
138
|
+
for base, dirs, files in os.walk(root):
|
|
139
|
+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
|
|
140
|
+
for name in files:
|
|
141
|
+
if name.lower().endswith((".html", ".css")):
|
|
142
|
+
yield os.path.join(base, name)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check(root, cfg):
|
|
146
|
+
floor = cfg["floor"]
|
|
147
|
+
allowed = {(e.get("ink"), e.get("on")) for e in cfg["exceptions"]}
|
|
148
|
+
rows = []
|
|
149
|
+
|
|
150
|
+
for path in walk(root):
|
|
151
|
+
try:
|
|
152
|
+
text = open(path, encoding="utf-8", errors="replace").read()
|
|
153
|
+
except OSError:
|
|
154
|
+
continue
|
|
155
|
+
vars_ = variables(text)
|
|
156
|
+
if not vars_:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
surfaces = {n: c for n, c in vars_.items()
|
|
160
|
+
if any(s in n for s in SURFACES)}
|
|
161
|
+
inks = {n: c for n, c in vars_.items()
|
|
162
|
+
if n not in surfaces and not n.startswith("glow")}
|
|
163
|
+
if not surfaces or not inks:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
rel = os.path.relpath(path, root).replace("\\", "/")
|
|
167
|
+
for ink_name, ink in sorted(inks.items()):
|
|
168
|
+
# The best surface this ink has. An ink that reaches the floor
|
|
169
|
+
# somewhere has a place it belongs; one that reaches it nowhere
|
|
170
|
+
# cannot be read against anything the file declares.
|
|
171
|
+
best_name, best = None, 0.0
|
|
172
|
+
for surf_name, surf in sorted(surfaces.items()):
|
|
173
|
+
r = ratio(ink, surf)
|
|
174
|
+
if r > best:
|
|
175
|
+
best_name, best = surf_name, r
|
|
176
|
+
declared = any((ink_name, s) in allowed for s in surfaces)
|
|
177
|
+
rows.append({"file": rel, "ink": ink_name, "on": best_name,
|
|
178
|
+
"ratio": best,
|
|
179
|
+
"ok": best >= floor or declared,
|
|
180
|
+
"declared": declared})
|
|
181
|
+
return rows
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _help():
|
|
185
|
+
print("""contrast-gate - Fail a build when a declared colour cannot be read on any surface the file declares.
|
|
186
|
+
|
|
187
|
+
usage:
|
|
188
|
+
contrast-gate [PATH] [--config FILE] [--list] [--require-pairs]
|
|
189
|
+
|
|
190
|
+
PATH directory to check (default: the working directory)
|
|
191
|
+
--config JSON file of recorded exceptions
|
|
192
|
+
--list every pair rather than the summary
|
|
193
|
+
--require-pairs fail if no colour pair was found at all, instead of
|
|
194
|
+
passing vacuously on a stylesheet with no variables
|
|
195
|
+
--root DIR same as PATH, kept for existing callers
|
|
196
|
+
|
|
197
|
+
This reads CSS custom properties (--name: #hex), not literal
|
|
198
|
+
color/background declarations.
|
|
199
|
+
|
|
200
|
+
Exits 0 when clean, 1 on a finding. Part of readable-gates.
|
|
201
|
+
https://f-keys.com/gatekit/""")
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def main():
|
|
206
|
+
if "--help" in sys.argv or "-h" in sys.argv:
|
|
207
|
+
return _help()
|
|
208
|
+
args = sys.argv[1:]
|
|
209
|
+
# Packaged, the directory above this file is site-packages,
|
|
210
|
+
# not the project being checked. The working directory is
|
|
211
|
+
# what someone running this in their repo means.
|
|
212
|
+
root = os.environ.get("CONTRAST_ROOT") or os.getcwd()
|
|
213
|
+
if "--root" in args:
|
|
214
|
+
root = os.path.abspath(args[args.index("--root") + 1])
|
|
215
|
+
else:
|
|
216
|
+
# a bare path was silently ignored, which meant this scanned its
|
|
217
|
+
# own repository and reported OK on a tree nobody asked about
|
|
218
|
+
positional = [a for a in args if not a.startswith("-")]
|
|
219
|
+
if positional:
|
|
220
|
+
root = os.path.abspath(positional[0])
|
|
221
|
+
|
|
222
|
+
cfg = load_config()
|
|
223
|
+
rows = check(root, cfg)
|
|
224
|
+
if not rows:
|
|
225
|
+
# This reads CSS custom properties - `--ink: #hex` against
|
|
226
|
+
# `--bg: #hex` - not literal `color:` declarations. A stylesheet
|
|
227
|
+
# that uses neither has nothing here to compare, which is a true
|
|
228
|
+
# statement and a dangerous one: in CI it exits 0 and reads as a
|
|
229
|
+
# pass on a site nobody checked. --require-pairs is how you say
|
|
230
|
+
# "I expect this to find something", and the Action sets it.
|
|
231
|
+
print(" contrast: nothing with declared colours to check.")
|
|
232
|
+
print(" This audits CSS custom properties (--name: #hex),")
|
|
233
|
+
print(" not literal color/background declarations.")
|
|
234
|
+
if "--require-pairs" in args:
|
|
235
|
+
print(" contrast: FAILED - --require-pairs was set and no pair "
|
|
236
|
+
"was found.")
|
|
237
|
+
return 1
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
if "--list" in args:
|
|
241
|
+
for r in sorted(rows, key=lambda x: x["ratio"]):
|
|
242
|
+
mark = "ok " if r["ok"] else "FAIL"
|
|
243
|
+
print(" {} {:>5.2f} --{} at best on --{} {}".format(
|
|
244
|
+
mark, r["ratio"], r["ink"], r["on"], r["file"]))
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
bad = [r for r in rows if not r["ok"]]
|
|
248
|
+
if not bad:
|
|
249
|
+
print(" contrast: OK - every declared pair reaches {}:1"
|
|
250
|
+
.format(cfg["floor"]))
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
print(" contrast: {} pair(s) below {}:1 and not declared\n"
|
|
254
|
+
.format(len(bad), cfg["floor"]))
|
|
255
|
+
for r in sorted(bad, key=lambda x: x["ratio"])[:40]:
|
|
256
|
+
print(" {:>5.2f} --{} has no surface it reads on (best is --{}) {}".format(
|
|
257
|
+
r["ratio"], r["ink"], r["on"], r["file"]))
|
|
258
|
+
print("\n Either change the colours, or add the pair to tools/"
|
|
259
|
+
"contrast.json with a reason.")
|
|
260
|
+
return 1
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": [
|
|
3
|
+
"Names that must not reach anything a human reads.",
|
|
4
|
+
"'forbidden' is a list of case-insensitive words.",
|
|
5
|
+
"'allowed' exempts machine identifiers - repo paths, package names,",
|
|
6
|
+
"live URLs - because renaming those breaks working links or",
|
|
7
|
+
"contradicts a published record. Each needs a reason.",
|
|
8
|
+
"Nothing is forbidden by default: say what yours is."
|
|
9
|
+
],
|
|
10
|
+
"forbidden": [],
|
|
11
|
+
"allowed": [],
|
|
12
|
+
"skip_paths": []
|
|
13
|
+
}
|