feat: release Colour Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/*.zip
|
||||||
|
release/*.zip.sha256
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes are documented here.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-08-31
|
||||||
|
|
||||||
|
- Added a local CSS colour parser and converters for web, perceptual,
|
||||||
|
wide-gamut and developer colour formats, including bounded batch conversion.
|
||||||
|
- Added arbitrary RGBA layer compositing with Porter–Duff source-over,
|
||||||
|
per-layer opacity, standard blend modes, reordering, encoded-sRGB and
|
||||||
|
linear-light processing, and optional matte flattening.
|
||||||
|
- Added multi-stop colour ramps with positioned stops, nine interpolation
|
||||||
|
spaces, selectable hue routes, easing, premultiplied/straight alpha control
|
||||||
|
and CSS/JSON export.
|
||||||
|
- Added a large pointer and keyboard picker with alpha, exact CSS input,
|
||||||
|
browser-native colour input, local history and optional system EyeDropper.
|
||||||
|
- Added bounded local image decoding, coordinate and neighbourhood sampling,
|
||||||
|
deterministic perceptual palette extraction and Web Worker fallback.
|
||||||
|
- Added transparency-aware WCAG 2 contrast reporting and nearby passing colour
|
||||||
|
suggestions, Delta E comparison, gamut inspection/mapping and clearly
|
||||||
|
labelled colour-vision simulations.
|
||||||
|
- Added harmony, tint, shade and tone generation with local named palettes,
|
||||||
|
palette import and six design-token export formats.
|
||||||
|
- Added the shared Toolbox shell, system/light/dark themes, responsive layouts,
|
||||||
|
accessible navigation, nested-path deployment, offline application shell and
|
||||||
|
deterministic static release packaging.
|
||||||
|
- Added a GPL-3.0-or-later project licence and exact runtime dependency notices,
|
||||||
|
including Color.js 0.7.1 under MIT.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thank you for improving Colour Tools. Contributions should preserve its three
|
||||||
|
core properties: local-first processing, explicit colour semantics and a
|
||||||
|
relocatable static build.
|
||||||
|
|
||||||
|
## Set up
|
||||||
|
|
||||||
|
Use Node.js 22 or newer and npm 11 or newer.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
npm run test:browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `npm run dev` for the development server. Do not commit `dist/`, `release/`,
|
||||||
|
local images, browser traces or editor secrets.
|
||||||
|
|
||||||
|
## Expectations
|
||||||
|
|
||||||
|
- Keep calculations pure and serialisable where practical. Add unit tests for
|
||||||
|
numeric boundaries, invalid input, alpha and out-of-gamut behaviour.
|
||||||
|
- Never add telemetry, remote image processing, remote fonts or implicit
|
||||||
|
network lookups. A feature that needs network access requires prior design
|
||||||
|
discussion and a plainly visible, opt-in boundary.
|
||||||
|
- Preserve values outside a target gamut until mapping is explicitly requested.
|
||||||
|
Name the transfer curve or working light for operations where it changes the
|
||||||
|
result.
|
||||||
|
- Bound file bytes, decoded dimensions, worker jobs, list sizes and generated
|
||||||
|
output. Release object URLs, workers and large buffers after use.
|
||||||
|
- Keep all application URLs relative. Test from the nested path used by the
|
||||||
|
Playwright server, not only `/`.
|
||||||
|
- Ensure every pointer interaction has a keyboard route and every colour-only
|
||||||
|
signal has a textual or structural equivalent. Use semantic controls before
|
||||||
|
custom roles.
|
||||||
|
- Document third-party runtime code in `THIRD_PARTY_NOTICES.md`, retain its
|
||||||
|
exact licence in `LICENSES/`, and confirm compatibility with GPLv3 or later.
|
||||||
|
|
||||||
|
## Changes and review
|
||||||
|
|
||||||
|
Keep a change focused and update `CHANGELOG.md` for user-visible behaviour.
|
||||||
|
Include the reasoning behind colour-math choices and cite a primary standard
|
||||||
|
where the choice is normative. Screenshots can help explain UI changes but do
|
||||||
|
not replace browser tests.
|
||||||
|
|
||||||
|
Before proposing a change, run `npm run check` and `npm run test:browser`.
|
||||||
|
Tests must not contact external hosts. Security-sensitive reports should follow
|
||||||
|
[SECURITY.md](SECURITY.md) instead of a public issue.
|
||||||
|
|
||||||
|
Contributions are accepted under the repository's
|
||||||
|
`GPL-3.0-or-later` licence.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Licence inventory
|
||||||
|
|
||||||
|
Colour Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
|
||||||
|
|
||||||
|
`THIRD_PARTY_NOTICES.md` identifies shipped runtime packages. Release
|
||||||
|
preparation collates the exact installed licence files for every locked runtime
|
||||||
|
package into `npm-runtime-licenses.txt`.
|
||||||
|
|
||||||
|
`colorjs.io-MIT.txt` is retained separately because Color.js supplies the core
|
||||||
|
standards-aligned colour conversion primitives used by the application.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Lea Verou, Chris Lilley
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Colour Tools
|
||||||
|
|
||||||
|
Colour Tools is a standalone, local-first browser workbench for converting,
|
||||||
|
compositing, interpolating, sampling, comparing and organising colours. It is
|
||||||
|
part of the
|
||||||
|
[add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal), but every
|
||||||
|
release also runs independently from a static HTTPS host or local preview.
|
||||||
|
|
||||||
|
No colour, image, palette or usage event is sent to a server. Image decoding,
|
||||||
|
pixel sampling and palette extraction happen in the browser; the application
|
||||||
|
has no analytics, accounts, advertisements or remote lookup feature.
|
||||||
|
|
||||||
|
## Workspaces
|
||||||
|
|
||||||
|
- **Convert** parses CSS colours and presents copy-ready hex, RGB, HSL, HSV,
|
||||||
|
HWB, CMYK, Lab, LCH, OKLab, OKLCH, Display P3, Adobe RGB, ProPhoto RGB,
|
||||||
|
Rec. 2020 and XYZ representations. Wide-gamut coordinates remain intact
|
||||||
|
until an explicit display or gamut-mapping step. A batch converter isolates
|
||||||
|
invalid lines instead of silently dropping them.
|
||||||
|
- **Composite** calculates Porter–Duff source-over for an arbitrary ordered
|
||||||
|
stack of translucent colours. Each layer has its own alpha, opacity and
|
||||||
|
blend mode. The working-light choice makes encoded-sRGB and linear-light
|
||||||
|
results explicit, and an optional matte produces an opaque RGB result.
|
||||||
|
- **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical
|
||||||
|
colour spaces. It supports positioned stops, five hue routes, easing,
|
||||||
|
premultiplied-alpha interpolation by default with a straight-alpha option,
|
||||||
|
and copy-ready CSS and JSON output.
|
||||||
|
- **Pick** provides a large keyboard- and pointer-operable HSV picker, alpha
|
||||||
|
control, exact CSS entry, browser-native colour input, recent picks and the
|
||||||
|
system EyeDropper API where the browser exposes it.
|
||||||
|
- **Image** accepts one bounded local PNG, JPEG, WebP, GIF or AVIF. It samples
|
||||||
|
an exact coordinate or a configurable average/median neighbourhood and
|
||||||
|
extracts two to twelve perceptual palette colours in a worker when
|
||||||
|
available.
|
||||||
|
- **Contrast** flattens transparency against a stated canvas, reports WCAG 2
|
||||||
|
contrast thresholds, finds a nearby passing foreground, compares colours by
|
||||||
|
six Delta E methods, checks colour gamuts and previews four approximate
|
||||||
|
colour-vision deficiencies.
|
||||||
|
- **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named
|
||||||
|
palette in local browser storage; imports common text/JSON token forms; and
|
||||||
|
exports CSS, SCSS, JSON, Design Tokens JSON, JavaScript or CSV.
|
||||||
|
|
||||||
|
Adding a generated or sampled colour to the palette is always explicit. The
|
||||||
|
saved palette is device-local and can be cleared from the Palette workspace or
|
||||||
|
by clearing site storage.
|
||||||
|
|
||||||
|
## Colour behaviour
|
||||||
|
|
||||||
|
Colour Tools keeps source coordinates outside display gamuts so conversions do
|
||||||
|
not destructively clip them. Browser previews and hex exports are mapped to
|
||||||
|
sRGB with an explicit indicator when mapping was needed. Compositing is carried
|
||||||
|
out in the selected sRGB working light, while interpolation defaults to OKLCH
|
||||||
|
for perceptual spacing. See [docs/COLOUR-MATH.md](docs/COLOUR-MATH.md) for the
|
||||||
|
formulas, bounds and terminology.
|
||||||
|
|
||||||
|
Contrast pass/fail indicators follow WCAG 2 ratios: 3:1 for non-text UI and
|
||||||
|
large text, 4.5:1 for normal AA text and 7:1 for normal AAA text. They are an
|
||||||
|
engineering aid, not a complete accessibility audit; typography, state,
|
||||||
|
context and non-colour cues still need human review.
|
||||||
|
|
||||||
|
## Local image handling
|
||||||
|
|
||||||
|
Selecting an image decodes a bounded raster locally with `createImageBitmap`
|
||||||
|
where available. The image-element fallback uses a temporary in-memory object
|
||||||
|
URL. Decoded resources and any fallback URL are released when the image is
|
||||||
|
replaced, removed or the workspace is closed. Palette extraction uses a
|
||||||
|
deterministic OKLab clustering implementation and bounded sampling rather than
|
||||||
|
uploading the image or retaining a copy. Metadata outside the decoded raster
|
||||||
|
is not exposed or stored by the application.
|
||||||
|
|
||||||
|
## Browser support
|
||||||
|
|
||||||
|
Current evergreen Firefox, Chromium and Safari are the deployment target.
|
||||||
|
JavaScript modules, CSS custom properties, Canvas 2D and Web Workers are used.
|
||||||
|
The EyeDropper API is progressive enhancement and only appears where a browser
|
||||||
|
supports it. Clipboard writes, service workers and EyeDropper access may
|
||||||
|
require HTTPS and browser permission; all core calculations work without them.
|
||||||
|
|
||||||
|
The UI supports system, light and dark themes through the shared Toolbox shell.
|
||||||
|
Tabs, picker controls and image sampling are keyboard operable, status changes
|
||||||
|
use live regions, and swatches are accompanied by machine-readable colour
|
||||||
|
values rather than relying on colour alone.
|
||||||
|
|
||||||
|
## Offline application shell
|
||||||
|
|
||||||
|
Production builds include a relative-scope web app manifest and a
|
||||||
|
subpath-aware service worker. After one successful load, the worker caches the
|
||||||
|
application shell and same-origin built assets, uses network-first navigation
|
||||||
|
with a cached fallback, serves cached static resources where possible and
|
||||||
|
removes obsolete cache versions. Service-worker support is optional; colour
|
||||||
|
operations never depend on a network connection.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Requirements: Node.js 22 or newer and npm 11 or newer.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
npm run test:browser
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite uses `base: './'`, so `dist/` is relocatable to `/` or a nested Toolbox
|
||||||
|
path. `toolbox-check` validates standalone and contextual operation.
|
||||||
|
|
||||||
|
The main implementation layers are described in
|
||||||
|
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Contributions should follow
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md); please report security concerns according
|
||||||
|
to [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run release:artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
This command checks the manifest, types, lint, formatting, unit tests,
|
||||||
|
production build, Toolbox contract and browser workflows, then creates a
|
||||||
|
deterministic `release/colour-tools-0.1.0.zip` and matching SHA-256 sidecar.
|
||||||
|
The archive is a ready-to-host static application and contains project and
|
||||||
|
third-party licence notices.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Colour Tools is free software under `GPL-3.0-or-later`. See
|
||||||
|
[LICENSE](LICENSE). Runtime dependencies retain their respective licences; see
|
||||||
|
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Security fixes are made on the current release line. Users should update to the
|
||||||
|
latest published tag and verify the release ZIP against its `.sha256` sidecar.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please report a suspected vulnerability privately to the repository owner via
|
||||||
|
the contact channel shown on the
|
||||||
|
[Gitea profile](https://git.add-ideas.de/lotobo) or a private Gitea message. Do
|
||||||
|
not include private images, access tokens, unpublished exploit details or other
|
||||||
|
sensitive material in a public issue.
|
||||||
|
|
||||||
|
Include the affected version, browser and operating system; a minimal local
|
||||||
|
reproduction; the expected impact; and whether the issue requires a crafted
|
||||||
|
image, palette or colour string. You should receive acknowledgement within
|
||||||
|
seven days. Coordinated disclosure is appreciated while a fix and release are
|
||||||
|
prepared.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
Colour Tools is a static browser application. It has no application backend,
|
||||||
|
account system, analytics endpoint or image-upload endpoint. All selected image
|
||||||
|
bytes are decoded and processed locally. Saved palette entries use browser
|
||||||
|
storage for the current origin. Clipboard, file picker, download and EyeDropper
|
||||||
|
actions happen only after a user gesture and remain governed by browser
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
File processing is deliberately bounded by encoded byte size, decoded pixel
|
||||||
|
count and dimensions. Image palette extraction samples a bounded number of
|
||||||
|
pixels and can run in a disposable worker. Object URLs and worker jobs are
|
||||||
|
cancelled or released when replaced. Input is displayed as text or parsed by
|
||||||
|
typed colour/image APIs; it is not evaluated as script or inserted as trusted
|
||||||
|
HTML.
|
||||||
|
|
||||||
|
Production hosts should serve the headers in
|
||||||
|
[docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md), use HTTPS, keep MIME types
|
||||||
|
correct, disable content sniffing and avoid injecting third-party scripts. A
|
||||||
|
host can still observe ordinary HTTP metadata for the initial static-file
|
||||||
|
request; “local-first” describes application data processing, not anonymity
|
||||||
|
from the static host.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Colour rendering differences between conforming browser/OS colour-management
|
||||||
|
pipelines are not vulnerabilities by themselves. Likewise, an approximate
|
||||||
|
colour-vision simulation is an educational preview, not a medical diagnosis or
|
||||||
|
a complete accessibility guarantee.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Corresponding source and provenance
|
||||||
|
|
||||||
|
The corresponding source for Colour Tools 0.1.0 is:
|
||||||
|
|
||||||
|
https://git.add-ideas.de/lotobo/colour-tools/src/tag/v0.1.0
|
||||||
|
|
||||||
|
Build that tag with Node.js 22 and the exact dependency graph recorded in
|
||||||
|
`package-lock.json`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run release:artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated JavaScript and CSS bundles are not the preferred form for
|
||||||
|
modification. No runtime code is loaded from a CDN. The production ZIP contains
|
||||||
|
this source pointer, the application licence, the dependency licence inventory,
|
||||||
|
and the applicable third-party notices.
|
||||||
|
|
||||||
|
## Implementation provenance
|
||||||
|
|
||||||
|
- The application is original project code and uses the shared Toolbox SDK
|
||||||
|
packages at version 0.2.3 for its manifest contract, shell, and build checks.
|
||||||
|
- Colour conversion and adaptation use Color.js 0.7.1. Its MIT licence is
|
||||||
|
reproduced in `LICENSES/colorjs.io-MIT.txt`.
|
||||||
|
- The compositing, interpolation, palette, image-sampling, contrast, comparison,
|
||||||
|
gamut, and colour-vision utilities in `src/colour` and `src/palette` were
|
||||||
|
implemented for this project.
|
||||||
|
- CSS Color 4 and WCAG 2.2 were used as public technical specifications. No
|
||||||
|
source code or branded assets were copied from the example websites that
|
||||||
|
informed the feature list.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
Release preparation collates licence files for the exact locked runtime
|
||||||
|
packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact.
|
||||||
|
|
||||||
|
| Package | Version | Licence | Role | Source |
|
||||||
|
| -------------------------------- | ------- | ---------- | ------------------------------------ | ------------------------------------------- |
|
||||||
|
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk |
|
||||||
|
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
|
||||||
|
| `colorjs.io` | 0.7.1 | MIT | parsing, conversion and colour maths | https://github.com/color-js/color.js |
|
||||||
|
| `react` | 19.2.8 | MIT | application UI | https://github.com/facebook/react |
|
||||||
|
| `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react |
|
||||||
|
|
||||||
|
`scheduler` 0.27.0, distributed by the React project under MIT, is the only
|
||||||
|
transitive runtime package. Development-only packages build and test the
|
||||||
|
source but are not shipped as runtime modules.
|
||||||
|
|
||||||
|
The exact Color.js notice is retained at
|
||||||
|
`LICENSES/colorjs.io-MIT.txt`. Apache-2.0 and MIT texts from all installed
|
||||||
|
runtime packages are reproduced by the deterministic release-preparation step.
|
||||||
|
|
||||||
|
CSS Color, CSS Compositing and Blending, WCAG and the linked web utilities that
|
||||||
|
informed the product requirements are specification or research references
|
||||||
|
only. Their source code, artwork and documentation wording are not bundled or
|
||||||
|
copied into Colour Tools.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Accessibility notes
|
||||||
|
|
||||||
|
Colour Tools treats colour as data as well as appearance. Swatches are paired
|
||||||
|
with textual values, gamut/contrast status uses text and symbols in addition to
|
||||||
|
hue, and generated palettes remain usable without visually distinguishing
|
||||||
|
every swatch.
|
||||||
|
|
||||||
|
Workspace navigation uses tabs with selected state and arrow/Home/End keyboard
|
||||||
|
navigation. Native inputs and labelled controls are preferred. The large picker
|
||||||
|
supports arrow-key adjustment and announces its saturation and brightness;
|
||||||
|
image sampling supports numeric coordinates and keyboard movement in addition
|
||||||
|
to pointer selection. Calculation and file statuses use live regions without
|
||||||
|
clearing the last valid result while a user is typing.
|
||||||
|
|
||||||
|
The shared Toolbox shell provides system, light and dark themes. Focus rings,
|
||||||
|
touch targets, reflow and text contrast should be checked in all themes and at
|
||||||
|
400% zoom. A WCAG contrast result is not itself proof that the complete
|
||||||
|
interface conforms: test keyboard order, names/roles/values, error recovery,
|
||||||
|
high contrast modes, reduced motion and screen-reader output in context.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Colour Tools is a client-only React application built as relocatable static
|
||||||
|
files. The UI and calculation engine are intentionally separated so colour
|
||||||
|
operations can be tested without a browser and UI state can keep showing the
|
||||||
|
last valid result while an input is incomplete.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
1. `src/colour/` is the serialisable colour domain. It parses and formats
|
||||||
|
colours, converts gamuts, composites layers, interpolates stops, evaluates
|
||||||
|
contrast and Delta E, simulates colour-vision deficiencies, and builds or
|
||||||
|
exports palettes. Its public API is re-exported from `src/colour/index.ts`.
|
||||||
|
2. `src/palette/` contains browser-independent raster bounds, sampling and
|
||||||
|
deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the
|
||||||
|
expensive extraction path to a dedicated worker.
|
||||||
|
3. `src/components/` contains the seven workspaces and shared accessible
|
||||||
|
controls. Workspace selection is represented by a URL fragment, so a view
|
||||||
|
can be bookmarked without router or server support.
|
||||||
|
4. `src/hooks/` owns bounded device-local UI persistence. Image bytes are not
|
||||||
|
stored there.
|
||||||
|
5. `src/toolbox/` contains the checked manifest identity. The shared Toolbox
|
||||||
|
shell supplies navigation, contextual actions and theme behaviour without
|
||||||
|
changing the standalone application domain.
|
||||||
|
|
||||||
|
## Colour values
|
||||||
|
|
||||||
|
The engine's `ColourValue` is a plain object: colour-space identifier, three
|
||||||
|
numeric coordinates and alpha. It deliberately carries coordinates that may
|
||||||
|
sit outside a display gamut. Color.js 0.7.1 supplies standards-aligned parsing
|
||||||
|
and conversion primitives; Colour Tools wraps these in typed, bounded domain
|
||||||
|
operations and stable UI formats.
|
||||||
|
|
||||||
|
Browser preview is a separate operation. It maps a value to sRGB and reports
|
||||||
|
whether mapping occurred. This keeps display limitations from mutating the
|
||||||
|
source colour used by later conversions or comparisons.
|
||||||
|
|
||||||
|
## Raster pipeline
|
||||||
|
|
||||||
|
The image picker validates the selected file before decode, rejects excessive
|
||||||
|
bytes/dimensions/pixels, then draws the decoded image to a same-origin in-memory
|
||||||
|
Canvas 2D surface. Sampling reads a bounded neighbourhood. Palette extraction
|
||||||
|
subsamples deterministically, converts points to OKLab and clusters them with a
|
||||||
|
fixed initialization and iteration bound. The worker client can fall back to
|
||||||
|
the same pure function when workers are unavailable.
|
||||||
|
|
||||||
|
Temporary object URLs are revoked and outstanding extraction is aborted when an
|
||||||
|
image is replaced or removed. No image or extracted palette is transmitted.
|
||||||
|
|
||||||
|
## Build and deployment
|
||||||
|
|
||||||
|
Vite builds with a relative base. `scripts/generate-toolbox-manifest.mjs`
|
||||||
|
checks package, application and Toolbox versions plus immutable source
|
||||||
|
identity. `scripts/prepare-release-files.mjs` places project documentation and
|
||||||
|
the exact installed runtime licence inventory beside the executable app.
|
||||||
|
|
||||||
|
The service worker is relative-scope and deployment-subpath aware. It is
|
||||||
|
progressive enhancement: the app remains usable when registration is rejected.
|
||||||
|
The deterministic packager rejects symbolic links, source maps, secret-like
|
||||||
|
filenames and root-absolute asset references before creating the release ZIP
|
||||||
|
and SHA-256 sidecar.
|
||||||
|
|
||||||
|
## Testing boundaries
|
||||||
|
|
||||||
|
Unit tests cover parsing, numerical operations, invalid inputs, raster bounds,
|
||||||
|
sampling and palette determinism. Browser smoke tests run the production build
|
||||||
|
under a restrictive policy at `/deep/nested/colour/`, exercise all major
|
||||||
|
workspaces and fail on uncaught errors or external network requests. The
|
||||||
|
Toolbox contract checker validates the static artifact independently.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Colour mathematics and terminology
|
||||||
|
|
||||||
|
This document records the choices that materially change a result. It is not a
|
||||||
|
replacement for CSS Color, Compositing and Blending, or WCAG.
|
||||||
|
|
||||||
|
## Parsing and conversion
|
||||||
|
|
||||||
|
The parser accepts CSS colour syntax supported by the installed Color.js
|
||||||
|
version, including hex and named colours; `rgb()`, `hsl()`, `hwb()`, Lab/LCH
|
||||||
|
and OKLab/OKLCH functions; and registered `color()` spaces. Conversion keeps
|
||||||
|
finite coordinates outside a target gamut. Hex and ordinary browser sRGB
|
||||||
|
previews require mapping; the UI labels when this happened.
|
||||||
|
|
||||||
|
The converter exposes device-oriented and mathematical representations, but a
|
||||||
|
numeric tuple is meaningful only with its named colour space, white point,
|
||||||
|
transfer curve and alpha convention. CMYK output is a convenient mathematical
|
||||||
|
conversion, not a printer ICC separation or proof.
|
||||||
|
|
||||||
|
## Alpha compositing
|
||||||
|
|
||||||
|
For a source colour over a backdrop, with straight alpha `αs` and `αb`, output
|
||||||
|
alpha is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
αo = αs + αb × (1 − αs)
|
||||||
|
```
|
||||||
|
|
||||||
|
For each premultiplied channel under normal source-over:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Co = Cs × αs + Cb × αb × (1 − αs)
|
||||||
|
co = Co / αo when αo > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Layers are accumulated from the bottom upward even though the editor displays
|
||||||
|
the top layer first. Layer opacity multiplies the colour's own alpha. Blend
|
||||||
|
modes are applied before source-over using the model in CSS Compositing and
|
||||||
|
Blending.
|
||||||
|
|
||||||
|
“Encoded sRGB” applies the operation directly to sRGB-encoded channel values,
|
||||||
|
matching many quick web calculators. “Linear-light sRGB” first removes the
|
||||||
|
sRGB transfer curve and is generally the physically meaningful choice for
|
||||||
|
mixing emitted light. The two results are expected to differ.
|
||||||
|
|
||||||
|
## Interpolation
|
||||||
|
|
||||||
|
A ramp samples its first and last stop and distributes missing positions like a
|
||||||
|
CSS gradient. Each segment is interpolated in the selected space with the
|
||||||
|
chosen easing. Cylindrical spaces need an explicit hue route: shorter, longer,
|
||||||
|
increasing, decreasing or raw. The default premultiplies colour components by
|
||||||
|
alpha before mixing and unpremultiplies afterwards, preventing hidden channels
|
||||||
|
inside a transparent endpoint from tinting the ramp. A straight-alpha toggle
|
||||||
|
is available for intentionally reproducing that model. OKLCH is the UI default
|
||||||
|
because equal numeric progress tends to look more even than encoded RGB; it
|
||||||
|
does not guarantee that every intermediate colour lies in a display gamut.
|
||||||
|
|
||||||
|
## Gamut mapping
|
||||||
|
|
||||||
|
An in-gamut check does not modify the colour. Display mapping uses an OKLCH
|
||||||
|
chroma-reduction method by default; channel clipping is available at the engine
|
||||||
|
boundary where explicitly selected. The result includes Delta E OK from the
|
||||||
|
source so callers can quantify the change. A browser preview is always mapped
|
||||||
|
to sRGB because CSS support and physical displays differ.
|
||||||
|
|
||||||
|
## Contrast and transparency
|
||||||
|
|
||||||
|
Relative luminance and WCAG 2 contrast are evaluated after translucent
|
||||||
|
foreground/background colours are composited over the stated canvas (white by
|
||||||
|
default). The ratio is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(Llighter + 0.05) / (Ldarker + 0.05)
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI reports 3:1, 4.5:1 and 7:1 thresholds and searches for a nearby lighter
|
||||||
|
or darker passing foreground. A mathematical pass is only one part of an
|
||||||
|
accessibility review; font size/weight, focus states, disabled controls,
|
||||||
|
non-colour cues and actual display conditions remain relevant.
|
||||||
|
|
||||||
|
## Perceptual difference and simulations
|
||||||
|
|
||||||
|
Delta E 76, CMC, CIEDE2000, Delta E OK, ITP and Jz answer different historical
|
||||||
|
or application needs; values from different formulas are not interchangeable.
|
||||||
|
The colour-vision previews apply bounded matrices in linear sRGB and expose a
|
||||||
|
severity control. They are approximations for interface review, not a model of
|
||||||
|
every observer and not medical advice.
|
||||||
|
|
||||||
|
## Palette extraction
|
||||||
|
|
||||||
|
The raster extractor ignores sufficiently transparent pixels, converts bounded
|
||||||
|
samples to OKLab and clusters them deterministically. Reported coverage is the
|
||||||
|
share of accepted samples assigned to a cluster, not an exact segmentation of
|
||||||
|
every source pixel. Small antialiased regions and colour-managed decoding can
|
||||||
|
therefore influence the result.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Privacy, security and deployment
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
Colour input, saved palette entries, selected image bytes, decoded pixels and
|
||||||
|
generated exports stay in the browser. The application does not issue API
|
||||||
|
requests, load remote fonts, submit analytics or resolve remote colour
|
||||||
|
references. A saved palette belongs to the current browser origin and is
|
||||||
|
stored until the user clears it or browser storage is removed. Images are
|
||||||
|
in-memory only.
|
||||||
|
|
||||||
|
The static host still receives ordinary requests for HTML, JavaScript, CSS,
|
||||||
|
icons and worker files on initial load and revalidation. Host logs, reverse
|
||||||
|
proxy logs and browser extension behaviour are outside the application's local
|
||||||
|
processing boundary.
|
||||||
|
|
||||||
|
## Recommended headers
|
||||||
|
|
||||||
|
Serve the static artifact over HTTPS and apply a policy equivalent to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
|
||||||
|
Cross-Origin-Opener-Policy: same-origin
|
||||||
|
Cross-Origin-Resource-Policy: same-origin
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=(), payment=()
|
||||||
|
Referrer-Policy: no-referrer
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared Toolbox shell uses inline style properties for colour previews, so
|
||||||
|
`style-src 'unsafe-inline'` is currently required. Do not add
|
||||||
|
`script-src 'unsafe-inline'` or third-party script origins. `blob:` is required
|
||||||
|
for local image object URLs and may be used for a worker fallback.
|
||||||
|
|
||||||
|
Serve `.js` as `text/javascript`, `.json` as `application/json`, `.webmanifest`
|
||||||
|
as `application/manifest+json`, and `.svg` as `image/svg+xml`. Hashed assets may
|
||||||
|
use a long immutable cache lifetime; keep `index.html`, `sw.js` and
|
||||||
|
`toolbox-app.json` on revalidation/no-cache so releases update predictably.
|
||||||
|
|
||||||
|
## Reverse-proxy example
|
||||||
|
|
||||||
|
For an nginx deployment rooted at `/apps/colour/`, use `try_files` only to
|
||||||
|
resolve actual static files and the directory index; do not rewrite missing
|
||||||
|
asset paths to HTML. The application itself uses relative paths and needs no
|
||||||
|
server-side router. Keep the `sw.js` scope at the application directory.
|
||||||
|
|
||||||
|
If the app is assembled into toolbox-portal, use the portal's release lock and
|
||||||
|
assembly process instead of unpacking files into an existing live directory.
|
||||||
|
Verify the ZIP against its SHA-256 sidecar before assembly.
|
||||||
|
|
||||||
|
## File limits and cleanup
|
||||||
|
|
||||||
|
The image picker enforces encoded-byte, dimension and decoded-pixel limits
|
||||||
|
before expensive processing. Sampling radius and palette colour counts are
|
||||||
|
bounded. Object URLs, decoded buffers and worker requests are released or
|
||||||
|
cancelled when replaced. Browsers and image decoders remain security-sensitive
|
||||||
|
dependencies, so deploy current supported browser versions and publish patched
|
||||||
|
application releases promptly.
|
||||||
|
|
||||||
|
See [SECURITY.md](../SECURITY.md) for private reporting instructions.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"dist",
|
||||||
|
"release",
|
||||||
|
"coverage",
|
||||||
|
"test-results",
|
||||||
|
"playwright-report",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2023,
|
||||||
|
globals: { ...globals.browser, ...globals.worker },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["scripts/**/*.mjs", "playwright.config.ts"],
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
|
},
|
||||||
|
);
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Convert, composite, compare and build colours locally in your browser."
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#2d2859" />
|
||||||
|
<title>Colour Tools</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"name": "colour-tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Convert, composite, compare and build colours locally in the browser.",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"author": "Albrecht Degering",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://git.add-ideas.de/lotobo/colour-tools.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.add-ideas.de/lotobo/colour-tools",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.add-ideas.de/lotobo/colour-tools/issues"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"predev": "npm run manifest:generate",
|
||||||
|
"dev": "vite",
|
||||||
|
"prebuild": "npm run release:prepare && npm run manifest:generate",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc -b --pretty false",
|
||||||
|
"lint": "eslint . --max-warnings=0",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:browser": "playwright test",
|
||||||
|
"manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
|
||||||
|
"manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
|
||||||
|
"release:prepare": "node scripts/prepare-release-files.mjs",
|
||||||
|
"toolbox:check": "toolbox-check dist",
|
||||||
|
"package:release": "node scripts/package-release.mjs",
|
||||||
|
"check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run toolbox:check",
|
||||||
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@add-ideas/toolbox-contract": "0.2.3",
|
||||||
|
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||||
|
"colorjs.io": "0.7.1",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
|
"@playwright/test": "1.62.1",
|
||||||
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
"@testing-library/react": "16.3.2",
|
||||||
|
"@testing-library/user-event": "14.6.1",
|
||||||
|
"@types/node": "25.9.5",
|
||||||
|
"@types/react": "19.2.17",
|
||||||
|
"@types/react-dom": "19.2.5",
|
||||||
|
"@vitejs/plugin-react": "6.0.3",
|
||||||
|
"eslint": "10.7.0",
|
||||||
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "0.5.2",
|
||||||
|
"fast-check": "4.9.0",
|
||||||
|
"globals": "17.7.0",
|
||||||
|
"jsdom": "29.1.1",
|
||||||
|
"prettier": "3.9.5",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"typescript-eslint": "8.64.0",
|
||||||
|
"vite": "8.2.2",
|
||||||
|
"vitest": "4.1.11"
|
||||||
|
},
|
||||||
|
"packageManager": "npm@11.17.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/browser",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 10_000 },
|
||||||
|
reporter: [["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:4173",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "npm run build && node scripts/serve-test.mjs",
|
||||||
|
url: "http://127.0.0.1:4173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 180_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes are documented here.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-08-31
|
||||||
|
|
||||||
|
- Added a local CSS colour parser and converters for web, perceptual,
|
||||||
|
wide-gamut and developer colour formats, including bounded batch conversion.
|
||||||
|
- Added arbitrary RGBA layer compositing with Porter–Duff source-over,
|
||||||
|
per-layer opacity, standard blend modes, reordering, encoded-sRGB and
|
||||||
|
linear-light processing, and optional matte flattening.
|
||||||
|
- Added multi-stop colour ramps with positioned stops, nine interpolation
|
||||||
|
spaces, selectable hue routes, easing, premultiplied/straight alpha control
|
||||||
|
and CSS/JSON export.
|
||||||
|
- Added a large pointer and keyboard picker with alpha, exact CSS input,
|
||||||
|
browser-native colour input, local history and optional system EyeDropper.
|
||||||
|
- Added bounded local image decoding, coordinate and neighbourhood sampling,
|
||||||
|
deterministic perceptual palette extraction and Web Worker fallback.
|
||||||
|
- Added transparency-aware WCAG 2 contrast reporting and nearby passing colour
|
||||||
|
suggestions, Delta E comparison, gamut inspection/mapping and clearly
|
||||||
|
labelled colour-vision simulations.
|
||||||
|
- Added harmony, tint, shade and tone generation with local named palettes,
|
||||||
|
palette import and six design-token export formats.
|
||||||
|
- Added the shared Toolbox shell, system/light/dark themes, responsive layouts,
|
||||||
|
accessible navigation, nested-path deployment, offline application shell and
|
||||||
|
deterministic static release packaging.
|
||||||
|
- Added a GPL-3.0-or-later project licence and exact runtime dependency notices,
|
||||||
|
including Color.js 0.7.1 under MIT.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thank you for improving Colour Tools. Contributions should preserve its three
|
||||||
|
core properties: local-first processing, explicit colour semantics and a
|
||||||
|
relocatable static build.
|
||||||
|
|
||||||
|
## Set up
|
||||||
|
|
||||||
|
Use Node.js 22 or newer and npm 11 or newer.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
npm run test:browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `npm run dev` for the development server. Do not commit `dist/`, `release/`,
|
||||||
|
local images, browser traces or editor secrets.
|
||||||
|
|
||||||
|
## Expectations
|
||||||
|
|
||||||
|
- Keep calculations pure and serialisable where practical. Add unit tests for
|
||||||
|
numeric boundaries, invalid input, alpha and out-of-gamut behaviour.
|
||||||
|
- Never add telemetry, remote image processing, remote fonts or implicit
|
||||||
|
network lookups. A feature that needs network access requires prior design
|
||||||
|
discussion and a plainly visible, opt-in boundary.
|
||||||
|
- Preserve values outside a target gamut until mapping is explicitly requested.
|
||||||
|
Name the transfer curve or working light for operations where it changes the
|
||||||
|
result.
|
||||||
|
- Bound file bytes, decoded dimensions, worker jobs, list sizes and generated
|
||||||
|
output. Release object URLs, workers and large buffers after use.
|
||||||
|
- Keep all application URLs relative. Test from the nested path used by the
|
||||||
|
Playwright server, not only `/`.
|
||||||
|
- Ensure every pointer interaction has a keyboard route and every colour-only
|
||||||
|
signal has a textual or structural equivalent. Use semantic controls before
|
||||||
|
custom roles.
|
||||||
|
- Document third-party runtime code in `THIRD_PARTY_NOTICES.md`, retain its
|
||||||
|
exact licence in `LICENSES/`, and confirm compatibility with GPLv3 or later.
|
||||||
|
|
||||||
|
## Changes and review
|
||||||
|
|
||||||
|
Keep a change focused and update `CHANGELOG.md` for user-visible behaviour.
|
||||||
|
Include the reasoning behind colour-math choices and cite a primary standard
|
||||||
|
where the choice is normative. Screenshots can help explain UI changes but do
|
||||||
|
not replace browser tests.
|
||||||
|
|
||||||
|
Before proposing a change, run `npm run check` and `npm run test:browser`.
|
||||||
|
Tests must not contact external hosts. Security-sensitive reports should follow
|
||||||
|
[SECURITY.md](SECURITY.md) instead of a public issue.
|
||||||
|
|
||||||
|
Contributions are accepted under the repository's
|
||||||
|
`GPL-3.0-or-later` licence.
|
||||||
+674
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Licence inventory
|
||||||
|
|
||||||
|
Colour Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
|
||||||
|
|
||||||
|
`THIRD_PARTY_NOTICES.md` identifies shipped runtime packages. Release
|
||||||
|
preparation collates the exact installed licence files for every locked runtime
|
||||||
|
package into `npm-runtime-licenses.txt`.
|
||||||
|
|
||||||
|
`colorjs.io-MIT.txt` is retained separately because Color.js supplies the core
|
||||||
|
standards-aligned colour conversion primitives used by the application.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Lea Verou, Chris Lilley
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
==============================================================================
|
||||||
|
@add-ideas/toolbox-contract@0.2.3
|
||||||
|
Declared licence: Apache-2.0
|
||||||
|
Installed from: node_modules/@add-ideas/toolbox-contract
|
||||||
|
==============================================================================
|
||||||
|
--- LICENSE ---
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
Copyright 2026 ADD Ideas
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
@add-ideas/toolbox-shell-react@0.2.3
|
||||||
|
Declared licence: Apache-2.0
|
||||||
|
Installed from: node_modules/@add-ideas/toolbox-shell-react
|
||||||
|
==============================================================================
|
||||||
|
--- LICENSE ---
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
Copyright 2026 ADD Ideas
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
colorjs.io@0.7.1
|
||||||
|
Declared licence: MIT
|
||||||
|
Installed from: node_modules/colorjs.io
|
||||||
|
==============================================================================
|
||||||
|
--- LICENSE ---
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Lea Verou, Chris Lilley
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
react@19.2.8
|
||||||
|
Declared licence: MIT
|
||||||
|
Installed from: node_modules/react
|
||||||
|
==============================================================================
|
||||||
|
--- LICENSE ---
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
react-dom@19.2.8
|
||||||
|
Declared licence: MIT
|
||||||
|
Installed from: node_modules/react-dom
|
||||||
|
==============================================================================
|
||||||
|
--- LICENSE ---
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
scheduler@0.27.0
|
||||||
|
Declared licence: MIT
|
||||||
|
Installed from: node_modules/scheduler
|
||||||
|
==============================================================================
|
||||||
|
--- LICENSE ---
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Colour Tools
|
||||||
|
|
||||||
|
Colour Tools is a standalone, local-first browser workbench for converting,
|
||||||
|
compositing, interpolating, sampling, comparing and organising colours. It is
|
||||||
|
part of the
|
||||||
|
[add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal), but every
|
||||||
|
release also runs independently from a static HTTPS host or local preview.
|
||||||
|
|
||||||
|
No colour, image, palette or usage event is sent to a server. Image decoding,
|
||||||
|
pixel sampling and palette extraction happen in the browser; the application
|
||||||
|
has no analytics, accounts, advertisements or remote lookup feature.
|
||||||
|
|
||||||
|
## Workspaces
|
||||||
|
|
||||||
|
- **Convert** parses CSS colours and presents copy-ready hex, RGB, HSL, HSV,
|
||||||
|
HWB, CMYK, Lab, LCH, OKLab, OKLCH, Display P3, Adobe RGB, ProPhoto RGB,
|
||||||
|
Rec. 2020 and XYZ representations. Wide-gamut coordinates remain intact
|
||||||
|
until an explicit display or gamut-mapping step. A batch converter isolates
|
||||||
|
invalid lines instead of silently dropping them.
|
||||||
|
- **Composite** calculates Porter–Duff source-over for an arbitrary ordered
|
||||||
|
stack of translucent colours. Each layer has its own alpha, opacity and
|
||||||
|
blend mode. The working-light choice makes encoded-sRGB and linear-light
|
||||||
|
results explicit, and an optional matte produces an opaque RGB result.
|
||||||
|
- **Steps** builds multi-stop colour ramps in perceptual, RGB or cylindrical
|
||||||
|
colour spaces. It supports positioned stops, five hue routes, easing,
|
||||||
|
premultiplied-alpha interpolation by default with a straight-alpha option,
|
||||||
|
and copy-ready CSS and JSON output.
|
||||||
|
- **Pick** provides a large keyboard- and pointer-operable HSV picker, alpha
|
||||||
|
control, exact CSS entry, browser-native colour input, recent picks and the
|
||||||
|
system EyeDropper API where the browser exposes it.
|
||||||
|
- **Image** accepts one bounded local PNG, JPEG, WebP, GIF or AVIF. It samples
|
||||||
|
an exact coordinate or a configurable average/median neighbourhood and
|
||||||
|
extracts two to twelve perceptual palette colours in a worker when
|
||||||
|
available.
|
||||||
|
- **Contrast** flattens transparency against a stated canvas, reports WCAG 2
|
||||||
|
contrast thresholds, finds a nearby passing foreground, compares colours by
|
||||||
|
six Delta E methods, checks colour gamuts and previews four approximate
|
||||||
|
colour-vision deficiencies.
|
||||||
|
- **Palette** creates OKLCH harmonies, tints, shades and tones; keeps a named
|
||||||
|
palette in local browser storage; imports common text/JSON token forms; and
|
||||||
|
exports CSS, SCSS, JSON, Design Tokens JSON, JavaScript or CSV.
|
||||||
|
|
||||||
|
Adding a generated or sampled colour to the palette is always explicit. The
|
||||||
|
saved palette is device-local and can be cleared from the Palette workspace or
|
||||||
|
by clearing site storage.
|
||||||
|
|
||||||
|
## Colour behaviour
|
||||||
|
|
||||||
|
Colour Tools keeps source coordinates outside display gamuts so conversions do
|
||||||
|
not destructively clip them. Browser previews and hex exports are mapped to
|
||||||
|
sRGB with an explicit indicator when mapping was needed. Compositing is carried
|
||||||
|
out in the selected sRGB working light, while interpolation defaults to OKLCH
|
||||||
|
for perceptual spacing. See [docs/COLOUR-MATH.md](docs/COLOUR-MATH.md) for the
|
||||||
|
formulas, bounds and terminology.
|
||||||
|
|
||||||
|
Contrast pass/fail indicators follow WCAG 2 ratios: 3:1 for non-text UI and
|
||||||
|
large text, 4.5:1 for normal AA text and 7:1 for normal AAA text. They are an
|
||||||
|
engineering aid, not a complete accessibility audit; typography, state,
|
||||||
|
context and non-colour cues still need human review.
|
||||||
|
|
||||||
|
## Local image handling
|
||||||
|
|
||||||
|
Selecting an image decodes a bounded raster locally with `createImageBitmap`
|
||||||
|
where available. The image-element fallback uses a temporary in-memory object
|
||||||
|
URL. Decoded resources and any fallback URL are released when the image is
|
||||||
|
replaced, removed or the workspace is closed. Palette extraction uses a
|
||||||
|
deterministic OKLab clustering implementation and bounded sampling rather than
|
||||||
|
uploading the image or retaining a copy. Metadata outside the decoded raster
|
||||||
|
is not exposed or stored by the application.
|
||||||
|
|
||||||
|
## Browser support
|
||||||
|
|
||||||
|
Current evergreen Firefox, Chromium and Safari are the deployment target.
|
||||||
|
JavaScript modules, CSS custom properties, Canvas 2D and Web Workers are used.
|
||||||
|
The EyeDropper API is progressive enhancement and only appears where a browser
|
||||||
|
supports it. Clipboard writes, service workers and EyeDropper access may
|
||||||
|
require HTTPS and browser permission; all core calculations work without them.
|
||||||
|
|
||||||
|
The UI supports system, light and dark themes through the shared Toolbox shell.
|
||||||
|
Tabs, picker controls and image sampling are keyboard operable, status changes
|
||||||
|
use live regions, and swatches are accompanied by machine-readable colour
|
||||||
|
values rather than relying on colour alone.
|
||||||
|
|
||||||
|
## Offline application shell
|
||||||
|
|
||||||
|
Production builds include a relative-scope web app manifest and a
|
||||||
|
subpath-aware service worker. After one successful load, the worker caches the
|
||||||
|
application shell and same-origin built assets, uses network-first navigation
|
||||||
|
with a cached fallback, serves cached static resources where possible and
|
||||||
|
removes obsolete cache versions. Service-worker support is optional; colour
|
||||||
|
operations never depend on a network connection.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Requirements: Node.js 22 or newer and npm 11 or newer.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run check
|
||||||
|
npm run test:browser
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite uses `base: './'`, so `dist/` is relocatable to `/` or a nested Toolbox
|
||||||
|
path. `toolbox-check` validates standalone and contextual operation.
|
||||||
|
|
||||||
|
The main implementation layers are described in
|
||||||
|
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Contributions should follow
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md); please report security concerns according
|
||||||
|
to [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run release:artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
This command checks the manifest, types, lint, formatting, unit tests,
|
||||||
|
production build, Toolbox contract and browser workflows, then creates a
|
||||||
|
deterministic `release/colour-tools-0.1.0.zip` and matching SHA-256 sidecar.
|
||||||
|
The archive is a ready-to-host static application and contains project and
|
||||||
|
third-party licence notices.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Colour Tools is free software under `GPL-3.0-or-later`. See
|
||||||
|
[LICENSE](LICENSE). Runtime dependencies retain their respective licences; see
|
||||||
|
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Security fixes are made on the current release line. Users should update to the
|
||||||
|
latest published tag and verify the release ZIP against its `.sha256` sidecar.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please report a suspected vulnerability privately to the repository owner via
|
||||||
|
the contact channel shown on the
|
||||||
|
[Gitea profile](https://git.add-ideas.de/lotobo) or a private Gitea message. Do
|
||||||
|
not include private images, access tokens, unpublished exploit details or other
|
||||||
|
sensitive material in a public issue.
|
||||||
|
|
||||||
|
Include the affected version, browser and operating system; a minimal local
|
||||||
|
reproduction; the expected impact; and whether the issue requires a crafted
|
||||||
|
image, palette or colour string. You should receive acknowledgement within
|
||||||
|
seven days. Coordinated disclosure is appreciated while a fix and release are
|
||||||
|
prepared.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
Colour Tools is a static browser application. It has no application backend,
|
||||||
|
account system, analytics endpoint or image-upload endpoint. All selected image
|
||||||
|
bytes are decoded and processed locally. Saved palette entries use browser
|
||||||
|
storage for the current origin. Clipboard, file picker, download and EyeDropper
|
||||||
|
actions happen only after a user gesture and remain governed by browser
|
||||||
|
permissions.
|
||||||
|
|
||||||
|
File processing is deliberately bounded by encoded byte size, decoded pixel
|
||||||
|
count and dimensions. Image palette extraction samples a bounded number of
|
||||||
|
pixels and can run in a disposable worker. Object URLs and worker jobs are
|
||||||
|
cancelled or released when replaced. Input is displayed as text or parsed by
|
||||||
|
typed colour/image APIs; it is not evaluated as script or inserted as trusted
|
||||||
|
HTML.
|
||||||
|
|
||||||
|
Production hosts should serve the headers in
|
||||||
|
[docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md), use HTTPS, keep MIME types
|
||||||
|
correct, disable content sniffing and avoid injecting third-party scripts. A
|
||||||
|
host can still observe ordinary HTTP metadata for the initial static-file
|
||||||
|
request; “local-first” describes application data processing, not anonymity
|
||||||
|
from the static host.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Colour rendering differences between conforming browser/OS colour-management
|
||||||
|
pipelines are not vulnerabilities by themselves. Likewise, an approximate
|
||||||
|
colour-vision simulation is an educational preview, not a medical diagnosis or
|
||||||
|
a complete accessibility guarantee.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Corresponding source and provenance
|
||||||
|
|
||||||
|
The corresponding source for Colour Tools 0.1.0 is:
|
||||||
|
|
||||||
|
https://git.add-ideas.de/lotobo/colour-tools/src/tag/v0.1.0
|
||||||
|
|
||||||
|
Build that tag with Node.js 22 and the exact dependency graph recorded in
|
||||||
|
`package-lock.json`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run release:artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated JavaScript and CSS bundles are not the preferred form for
|
||||||
|
modification. No runtime code is loaded from a CDN. The production ZIP contains
|
||||||
|
this source pointer, the application licence, the dependency licence inventory,
|
||||||
|
and the applicable third-party notices.
|
||||||
|
|
||||||
|
## Implementation provenance
|
||||||
|
|
||||||
|
- The application is original project code and uses the shared Toolbox SDK
|
||||||
|
packages at version 0.2.3 for its manifest contract, shell, and build checks.
|
||||||
|
- Colour conversion and adaptation use Color.js 0.7.1. Its MIT licence is
|
||||||
|
reproduced in `LICENSES/colorjs.io-MIT.txt`.
|
||||||
|
- The compositing, interpolation, palette, image-sampling, contrast, comparison,
|
||||||
|
gamut, and colour-vision utilities in `src/colour` and `src/palette` were
|
||||||
|
implemented for this project.
|
||||||
|
- CSS Color 4 and WCAG 2.2 were used as public technical specifications. No
|
||||||
|
source code or branded assets were copied from the example websites that
|
||||||
|
informed the feature list.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Third-party notices
|
||||||
|
|
||||||
|
Release preparation collates licence files for the exact locked runtime
|
||||||
|
packages into `LICENSES/npm-runtime-licenses.txt` inside the static artifact.
|
||||||
|
|
||||||
|
| Package | Version | Licence | Role | Source |
|
||||||
|
| -------------------------------- | ------- | ---------- | ------------------------------------ | ------------------------------------------- |
|
||||||
|
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk |
|
||||||
|
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
|
||||||
|
| `colorjs.io` | 0.7.1 | MIT | parsing, conversion and colour maths | https://github.com/color-js/color.js |
|
||||||
|
| `react` | 19.2.8 | MIT | application UI | https://github.com/facebook/react |
|
||||||
|
| `react-dom` | 19.2.8 | MIT | browser rendering | https://github.com/facebook/react |
|
||||||
|
|
||||||
|
`scheduler` 0.27.0, distributed by the React project under MIT, is the only
|
||||||
|
transitive runtime package. Development-only packages build and test the
|
||||||
|
source but are not shipped as runtime modules.
|
||||||
|
|
||||||
|
The exact Color.js notice is retained at
|
||||||
|
`LICENSES/colorjs.io-MIT.txt`. Apache-2.0 and MIT texts from all installed
|
||||||
|
runtime packages are reproduced by the deterministic release-preparation step.
|
||||||
|
|
||||||
|
CSS Color, CSS Compositing and Blending, WCAG and the linked web utilities that
|
||||||
|
informed the product requirements are specification or research references
|
||||||
|
only. Their source code, artwork and documentation wording are not bundled or
|
||||||
|
copied into Colour Tools.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Accessibility notes
|
||||||
|
|
||||||
|
Colour Tools treats colour as data as well as appearance. Swatches are paired
|
||||||
|
with textual values, gamut/contrast status uses text and symbols in addition to
|
||||||
|
hue, and generated palettes remain usable without visually distinguishing
|
||||||
|
every swatch.
|
||||||
|
|
||||||
|
Workspace navigation uses tabs with selected state and arrow/Home/End keyboard
|
||||||
|
navigation. Native inputs and labelled controls are preferred. The large picker
|
||||||
|
supports arrow-key adjustment and announces its saturation and brightness;
|
||||||
|
image sampling supports numeric coordinates and keyboard movement in addition
|
||||||
|
to pointer selection. Calculation and file statuses use live regions without
|
||||||
|
clearing the last valid result while a user is typing.
|
||||||
|
|
||||||
|
The shared Toolbox shell provides system, light and dark themes. Focus rings,
|
||||||
|
touch targets, reflow and text contrast should be checked in all themes and at
|
||||||
|
400% zoom. A WCAG contrast result is not itself proof that the complete
|
||||||
|
interface conforms: test keyboard order, names/roles/values, error recovery,
|
||||||
|
high contrast modes, reduced motion and screen-reader output in context.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Colour Tools is a client-only React application built as relocatable static
|
||||||
|
files. The UI and calculation engine are intentionally separated so colour
|
||||||
|
operations can be tested without a browser and UI state can keep showing the
|
||||||
|
last valid result while an input is incomplete.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
1. `src/colour/` is the serialisable colour domain. It parses and formats
|
||||||
|
colours, converts gamuts, composites layers, interpolates stops, evaluates
|
||||||
|
contrast and Delta E, simulates colour-vision deficiencies, and builds or
|
||||||
|
exports palettes. Its public API is re-exported from `src/colour/index.ts`.
|
||||||
|
2. `src/palette/` contains browser-independent raster bounds, sampling and
|
||||||
|
deterministic OKLab clustering. `src/workers/palette.worker.ts` exposes the
|
||||||
|
expensive extraction path to a dedicated worker.
|
||||||
|
3. `src/components/` contains the seven workspaces and shared accessible
|
||||||
|
controls. Workspace selection is represented by a URL fragment, so a view
|
||||||
|
can be bookmarked without router or server support.
|
||||||
|
4. `src/hooks/` owns bounded device-local UI persistence. Image bytes are not
|
||||||
|
stored there.
|
||||||
|
5. `src/toolbox/` contains the checked manifest identity. The shared Toolbox
|
||||||
|
shell supplies navigation, contextual actions and theme behaviour without
|
||||||
|
changing the standalone application domain.
|
||||||
|
|
||||||
|
## Colour values
|
||||||
|
|
||||||
|
The engine's `ColourValue` is a plain object: colour-space identifier, three
|
||||||
|
numeric coordinates and alpha. It deliberately carries coordinates that may
|
||||||
|
sit outside a display gamut. Color.js 0.7.1 supplies standards-aligned parsing
|
||||||
|
and conversion primitives; Colour Tools wraps these in typed, bounded domain
|
||||||
|
operations and stable UI formats.
|
||||||
|
|
||||||
|
Browser preview is a separate operation. It maps a value to sRGB and reports
|
||||||
|
whether mapping occurred. This keeps display limitations from mutating the
|
||||||
|
source colour used by later conversions or comparisons.
|
||||||
|
|
||||||
|
## Raster pipeline
|
||||||
|
|
||||||
|
The image picker validates the selected file before decode, rejects excessive
|
||||||
|
bytes/dimensions/pixels, then draws the decoded image to a same-origin in-memory
|
||||||
|
Canvas 2D surface. Sampling reads a bounded neighbourhood. Palette extraction
|
||||||
|
subsamples deterministically, converts points to OKLab and clusters them with a
|
||||||
|
fixed initialization and iteration bound. The worker client can fall back to
|
||||||
|
the same pure function when workers are unavailable.
|
||||||
|
|
||||||
|
Temporary object URLs are revoked and outstanding extraction is aborted when an
|
||||||
|
image is replaced or removed. No image or extracted palette is transmitted.
|
||||||
|
|
||||||
|
## Build and deployment
|
||||||
|
|
||||||
|
Vite builds with a relative base. `scripts/generate-toolbox-manifest.mjs`
|
||||||
|
checks package, application and Toolbox versions plus immutable source
|
||||||
|
identity. `scripts/prepare-release-files.mjs` places project documentation and
|
||||||
|
the exact installed runtime licence inventory beside the executable app.
|
||||||
|
|
||||||
|
The service worker is relative-scope and deployment-subpath aware. It is
|
||||||
|
progressive enhancement: the app remains usable when registration is rejected.
|
||||||
|
The deterministic packager rejects symbolic links, source maps, secret-like
|
||||||
|
filenames and root-absolute asset references before creating the release ZIP
|
||||||
|
and SHA-256 sidecar.
|
||||||
|
|
||||||
|
## Testing boundaries
|
||||||
|
|
||||||
|
Unit tests cover parsing, numerical operations, invalid inputs, raster bounds,
|
||||||
|
sampling and palette determinism. Browser smoke tests run the production build
|
||||||
|
under a restrictive policy at `/deep/nested/colour/`, exercise all major
|
||||||
|
workspaces and fail on uncaught errors or external network requests. The
|
||||||
|
Toolbox contract checker validates the static artifact independently.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Colour mathematics and terminology
|
||||||
|
|
||||||
|
This document records the choices that materially change a result. It is not a
|
||||||
|
replacement for CSS Color, Compositing and Blending, or WCAG.
|
||||||
|
|
||||||
|
## Parsing and conversion
|
||||||
|
|
||||||
|
The parser accepts CSS colour syntax supported by the installed Color.js
|
||||||
|
version, including hex and named colours; `rgb()`, `hsl()`, `hwb()`, Lab/LCH
|
||||||
|
and OKLab/OKLCH functions; and registered `color()` spaces. Conversion keeps
|
||||||
|
finite coordinates outside a target gamut. Hex and ordinary browser sRGB
|
||||||
|
previews require mapping; the UI labels when this happened.
|
||||||
|
|
||||||
|
The converter exposes device-oriented and mathematical representations, but a
|
||||||
|
numeric tuple is meaningful only with its named colour space, white point,
|
||||||
|
transfer curve and alpha convention. CMYK output is a convenient mathematical
|
||||||
|
conversion, not a printer ICC separation or proof.
|
||||||
|
|
||||||
|
## Alpha compositing
|
||||||
|
|
||||||
|
For a source colour over a backdrop, with straight alpha `αs` and `αb`, output
|
||||||
|
alpha is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
αo = αs + αb × (1 − αs)
|
||||||
|
```
|
||||||
|
|
||||||
|
For each premultiplied channel under normal source-over:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Co = Cs × αs + Cb × αb × (1 − αs)
|
||||||
|
co = Co / αo when αo > 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Layers are accumulated from the bottom upward even though the editor displays
|
||||||
|
the top layer first. Layer opacity multiplies the colour's own alpha. Blend
|
||||||
|
modes are applied before source-over using the model in CSS Compositing and
|
||||||
|
Blending.
|
||||||
|
|
||||||
|
“Encoded sRGB” applies the operation directly to sRGB-encoded channel values,
|
||||||
|
matching many quick web calculators. “Linear-light sRGB” first removes the
|
||||||
|
sRGB transfer curve and is generally the physically meaningful choice for
|
||||||
|
mixing emitted light. The two results are expected to differ.
|
||||||
|
|
||||||
|
## Interpolation
|
||||||
|
|
||||||
|
A ramp samples its first and last stop and distributes missing positions like a
|
||||||
|
CSS gradient. Each segment is interpolated in the selected space with the
|
||||||
|
chosen easing. Cylindrical spaces need an explicit hue route: shorter, longer,
|
||||||
|
increasing, decreasing or raw. The default premultiplies colour components by
|
||||||
|
alpha before mixing and unpremultiplies afterwards, preventing hidden channels
|
||||||
|
inside a transparent endpoint from tinting the ramp. A straight-alpha toggle
|
||||||
|
is available for intentionally reproducing that model. OKLCH is the UI default
|
||||||
|
because equal numeric progress tends to look more even than encoded RGB; it
|
||||||
|
does not guarantee that every intermediate colour lies in a display gamut.
|
||||||
|
|
||||||
|
## Gamut mapping
|
||||||
|
|
||||||
|
An in-gamut check does not modify the colour. Display mapping uses an OKLCH
|
||||||
|
chroma-reduction method by default; channel clipping is available at the engine
|
||||||
|
boundary where explicitly selected. The result includes Delta E OK from the
|
||||||
|
source so callers can quantify the change. A browser preview is always mapped
|
||||||
|
to sRGB because CSS support and physical displays differ.
|
||||||
|
|
||||||
|
## Contrast and transparency
|
||||||
|
|
||||||
|
Relative luminance and WCAG 2 contrast are evaluated after translucent
|
||||||
|
foreground/background colours are composited over the stated canvas (white by
|
||||||
|
default). The ratio is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(Llighter + 0.05) / (Ldarker + 0.05)
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI reports 3:1, 4.5:1 and 7:1 thresholds and searches for a nearby lighter
|
||||||
|
or darker passing foreground. A mathematical pass is only one part of an
|
||||||
|
accessibility review; font size/weight, focus states, disabled controls,
|
||||||
|
non-colour cues and actual display conditions remain relevant.
|
||||||
|
|
||||||
|
## Perceptual difference and simulations
|
||||||
|
|
||||||
|
Delta E 76, CMC, CIEDE2000, Delta E OK, ITP and Jz answer different historical
|
||||||
|
or application needs; values from different formulas are not interchangeable.
|
||||||
|
The colour-vision previews apply bounded matrices in linear sRGB and expose a
|
||||||
|
severity control. They are approximations for interface review, not a model of
|
||||||
|
every observer and not medical advice.
|
||||||
|
|
||||||
|
## Palette extraction
|
||||||
|
|
||||||
|
The raster extractor ignores sufficiently transparent pixels, converts bounded
|
||||||
|
samples to OKLab and clusters them deterministically. Reported coverage is the
|
||||||
|
share of accepted samples assigned to a cluster, not an exact segmentation of
|
||||||
|
every source pixel. Small antialiased regions and colour-managed decoding can
|
||||||
|
therefore influence the result.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Privacy, security and deployment
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
Colour input, saved palette entries, selected image bytes, decoded pixels and
|
||||||
|
generated exports stay in the browser. The application does not issue API
|
||||||
|
requests, load remote fonts, submit analytics or resolve remote colour
|
||||||
|
references. A saved palette belongs to the current browser origin and is
|
||||||
|
stored until the user clears it or browser storage is removed. Images are
|
||||||
|
in-memory only.
|
||||||
|
|
||||||
|
The static host still receives ordinary requests for HTML, JavaScript, CSS,
|
||||||
|
icons and worker files on initial load and revalidation. Host logs, reverse
|
||||||
|
proxy logs and browser extension behaviour are outside the application's local
|
||||||
|
processing boundary.
|
||||||
|
|
||||||
|
## Recommended headers
|
||||||
|
|
||||||
|
Serve the static artifact over HTTPS and apply a policy equivalent to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
|
||||||
|
Cross-Origin-Opener-Policy: same-origin
|
||||||
|
Cross-Origin-Resource-Policy: same-origin
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=(), payment=()
|
||||||
|
Referrer-Policy: no-referrer
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
```
|
||||||
|
|
||||||
|
The shared Toolbox shell uses inline style properties for colour previews, so
|
||||||
|
`style-src 'unsafe-inline'` is currently required. Do not add
|
||||||
|
`script-src 'unsafe-inline'` or third-party script origins. `blob:` is required
|
||||||
|
for local image object URLs and may be used for a worker fallback.
|
||||||
|
|
||||||
|
Serve `.js` as `text/javascript`, `.json` as `application/json`, `.webmanifest`
|
||||||
|
as `application/manifest+json`, and `.svg` as `image/svg+xml`. Hashed assets may
|
||||||
|
use a long immutable cache lifetime; keep `index.html`, `sw.js` and
|
||||||
|
`toolbox-app.json` on revalidation/no-cache so releases update predictably.
|
||||||
|
|
||||||
|
## Reverse-proxy example
|
||||||
|
|
||||||
|
For an nginx deployment rooted at `/apps/colour/`, use `try_files` only to
|
||||||
|
resolve actual static files and the directory index; do not rewrite missing
|
||||||
|
asset paths to HTML. The application itself uses relative paths and needs no
|
||||||
|
server-side router. Keep the `sw.js` scope at the application directory.
|
||||||
|
|
||||||
|
If the app is assembled into toolbox-portal, use the portal's release lock and
|
||||||
|
assembly process instead of unpacking files into an existing live directory.
|
||||||
|
Verify the ZIP against its SHA-256 sidecar before assembly.
|
||||||
|
|
||||||
|
## File limits and cleanup
|
||||||
|
|
||||||
|
The image picker enforces encoded-byte, dimension and decoded-pixel limits
|
||||||
|
before expensive processing. Sampling radius and palette colour counts are
|
||||||
|
bounded. Object URLs, decoded buffers and worker requests are released or
|
||||||
|
cancelled when replaced. Browsers and image decoders remain security-sensitive
|
||||||
|
dependencies, so deploy current supported browser versions and publish patched
|
||||||
|
application releases promptly.
|
||||||
|
|
||||||
|
See [SECURITY.md](../SECURITY.md) for private reporting instructions.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paper" x1="8" y1="6" x2="55" y2="61" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#fff"/>
|
||||||
|
<stop offset="1" stop-color="#ececf4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="spectrum" x1="15" y1="44" x2="49" y2="21" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#f45274"/>
|
||||||
|
<stop offset=".28" stop-color="#f3b93f"/>
|
||||||
|
<stop offset=".52" stop-color="#43b978"/>
|
||||||
|
<stop offset=".76" stop-color="#3f8ee8"/>
|
||||||
|
<stop offset="1" stop-color="#8659d7"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M12 5h27l13 13v39a3 3 0 0 1-3 3H12a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3Z" fill="url(#paper)" stroke="#403b68" stroke-width="3"/>
|
||||||
|
<path d="M39 5v13h13" fill="#deddeb" stroke="#403b68" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<circle cx="30" cy="37" r="14" fill="url(#spectrum)"/>
|
||||||
|
<circle cx="30" cy="37" r="5" fill="#fff" fill-opacity=".92"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 979 B |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Colour Tools",
|
||||||
|
"short_name": "Colour",
|
||||||
|
"description": "Convert, composite, compare and build colours locally.",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f7f7fb",
|
||||||
|
"theme_color": "#2d2859",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "./favicon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const CACHE_PREFIX = "colour-tools-shell-";
|
||||||
|
const CACHE_NAME = `${CACHE_PREFIX}0.1.0`;
|
||||||
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then(async (cache) => {
|
||||||
|
await cache.addAll(CORE);
|
||||||
|
const response = await fetch("./");
|
||||||
|
const html = await response.text();
|
||||||
|
const assets = [...html.matchAll(/(?:src|href)="(\.\/assets\/[^"]+)"/g)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
.filter(Boolean);
|
||||||
|
await cache.addAll(assets);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
const scope = new URL(self.registration.scope);
|
||||||
|
if (!url.pathname.startsWith(scope.pathname)) return;
|
||||||
|
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const copy = response.clone();
|
||||||
|
void caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.put(event.request, copy));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cached = await caches.match(event.request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const shellUrl = new URL("./", self.registration.scope).href;
|
||||||
|
return (await caches.match(shellUrl)) ?? Response.error();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(
|
||||||
|
(cached) =>
|
||||||
|
cached ??
|
||||||
|
fetch(event.request).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const copy = response.clone();
|
||||||
|
void caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.put(event.request, copy));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "de.add-ideas.colour-tools",
|
||||||
|
"name": "Colour Tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Convert, composite, compare and build colours locally in the browser.",
|
||||||
|
"entry": "./",
|
||||||
|
"icon": "./favicon.svg",
|
||||||
|
"categories": ["graphics", "design", "developer"],
|
||||||
|
"tags": [
|
||||||
|
"colour",
|
||||||
|
"color",
|
||||||
|
"rgb",
|
||||||
|
"rgba",
|
||||||
|
"oklch",
|
||||||
|
"palette",
|
||||||
|
"gradient",
|
||||||
|
"contrast",
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"integration": {
|
||||||
|
"contextVersion": 1,
|
||||||
|
"launchModes": ["navigate", "new-tab"],
|
||||||
|
"embedding": "unsupported"
|
||||||
|
},
|
||||||
|
"requirements": {
|
||||||
|
"secureContext": false,
|
||||||
|
"workers": true,
|
||||||
|
"indexedDb": false,
|
||||||
|
"crossOriginIsolated": false,
|
||||||
|
"topLevelContext": false
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"processing": "local",
|
||||||
|
"fileUploads": false,
|
||||||
|
"telemetry": false,
|
||||||
|
"label": "Colours and images stay in this browser; nothing is uploaded."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"repository": "https://git.add-ideas.de/lotobo/colour-tools",
|
||||||
|
"license": "GPL-3.0-or-later"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "source",
|
||||||
|
"label": "Source",
|
||||||
|
"url": "https://git.add-ideas.de/lotobo/colour-tools"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { lstat, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { format } from "prettier";
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
|
||||||
|
const outputPath = join(root, "public", "toolbox-app.json");
|
||||||
|
const source = JSON.parse(await readFile(sourcePath, "utf8"));
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await readFile(join(root, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const versionSource = await readFile(join(root, "src", "version.ts"), "utf8");
|
||||||
|
const applicationVersion =
|
||||||
|
/^export const (?:APPLICATION|APP)_VERSION = "([^"]+)";$/mu.exec(
|
||||||
|
versionSource,
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
if (
|
||||||
|
source.version !== packageJson.version ||
|
||||||
|
applicationVersion !== packageJson.version
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Version drift: manifest ${source.version}, application ${String(applicationVersion)}, package ${packageJson.version}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
source.id !== "de.add-ideas.colour-tools" ||
|
||||||
|
source.source?.repository !==
|
||||||
|
"https://git.add-ideas.de/lotobo/colour-tools" ||
|
||||||
|
source.source?.license !== "GPL-3.0-or-later"
|
||||||
|
)
|
||||||
|
throw new Error("Manifest source identity is incomplete or inconsistent");
|
||||||
|
|
||||||
|
for (const asset of source.assets ?? []) {
|
||||||
|
if (
|
||||||
|
typeof asset !== "string" ||
|
||||||
|
!asset.startsWith("./") ||
|
||||||
|
asset.includes("\\") ||
|
||||||
|
asset.split("/").includes("..")
|
||||||
|
)
|
||||||
|
throw new Error(`Unsafe manifest asset path: ${JSON.stringify(asset)}`);
|
||||||
|
const details = await lstat(join(root, "public", asset.slice(2))).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
if (!details?.isFile() || details.isSymbolicLink())
|
||||||
|
throw new Error(`Manifest asset is missing or unsafe: ${asset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = await format(JSON.stringify(source), {
|
||||||
|
filepath: outputPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.argv.includes("--check")) {
|
||||||
|
if ((await readFile(outputPath, "utf8").catch(() => "")) !== serialized)
|
||||||
|
throw new Error(
|
||||||
|
`${relative(root, outputPath)} is stale; run npm run manifest:generate`,
|
||||||
|
);
|
||||||
|
console.log("Toolbox manifest is synchronized");
|
||||||
|
} else {
|
||||||
|
await writeFile(outputPath, serialized);
|
||||||
|
console.log(`Generated ${relative(root, outputPath)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import {
|
||||||
|
access,
|
||||||
|
chmod,
|
||||||
|
copyFile,
|
||||||
|
cp,
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
readdir,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
utimes,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const execute = promisify(execFile);
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
await readFile(path.join(root, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const argument = (name, fallback) => {
|
||||||
|
const index = process.argv.indexOf(name);
|
||||||
|
return index >= 0 ? process.argv[index + 1] : fallback;
|
||||||
|
};
|
||||||
|
const output = path.resolve(
|
||||||
|
root,
|
||||||
|
argument("--output", `release/colour-tools-${packageJson.version}.zip`),
|
||||||
|
);
|
||||||
|
const checksumOutput = `${output}.sha256`;
|
||||||
|
const force = process.argv.includes("--force");
|
||||||
|
|
||||||
|
if (
|
||||||
|
path.extname(output).toLowerCase() !== ".zip" ||
|
||||||
|
output === root ||
|
||||||
|
output === path.parse(output).root
|
||||||
|
)
|
||||||
|
throw new Error("Release output is not a safe ZIP target");
|
||||||
|
|
||||||
|
const exists = (file) =>
|
||||||
|
access(file).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
if (!force && ((await exists(output)) || (await exists(checksumOutput))))
|
||||||
|
throw new Error("Release output already exists; use --force to replace it");
|
||||||
|
|
||||||
|
const input = path.join(root, "dist");
|
||||||
|
for (const name of [
|
||||||
|
"index.html",
|
||||||
|
"manifest.webmanifest",
|
||||||
|
"sw.js",
|
||||||
|
"toolbox-app.json",
|
||||||
|
"favicon.svg",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"CONTRIBUTING.md",
|
||||||
|
"LICENSE",
|
||||||
|
"SECURITY.md",
|
||||||
|
"SOURCE.md",
|
||||||
|
"THIRD_PARTY_NOTICES.md",
|
||||||
|
"LICENSES/README.md",
|
||||||
|
"LICENSES/colorjs.io-MIT.txt",
|
||||||
|
"LICENSES/npm-runtime-licenses.txt",
|
||||||
|
"docs/ACCESSIBILITY.md",
|
||||||
|
"docs/ARCHITECTURE.md",
|
||||||
|
"docs/COLOUR-MATH.md",
|
||||||
|
"docs/PRIVACY-SECURITY.md",
|
||||||
|
]) {
|
||||||
|
const details = await lstat(path.join(input, name)).catch(() => null);
|
||||||
|
if (!details?.isFile() || details.isSymbolicLink())
|
||||||
|
throw new Error(`Release is missing a regular file: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
await readFile(path.join(input, "toolbox-app.json"), "utf8"),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
manifest.id !== "de.add-ideas.colour-tools" ||
|
||||||
|
manifest.version !== packageJson.version ||
|
||||||
|
manifest.entry !== "./" ||
|
||||||
|
manifest.icon !== "./favicon.svg" ||
|
||||||
|
manifest.source?.repository !== "https://git.add-ideas.de/lotobo/colour-tools"
|
||||||
|
)
|
||||||
|
throw new Error("Packaged Toolbox manifest identity is invalid");
|
||||||
|
|
||||||
|
const html = await readFile(path.join(input, "index.html"), "utf8");
|
||||||
|
if (/\b(?:src|href)=["']\//iu.test(html))
|
||||||
|
throw new Error("index.html contains a root-absolute asset reference");
|
||||||
|
|
||||||
|
async function collect(directory, prefix = "") {
|
||||||
|
const files = [];
|
||||||
|
for (const entry of (await readdir(directory, { withFileTypes: true })).sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.name === right.name ? 0 : left.name < right.name ? -1 : 1,
|
||||||
|
)) {
|
||||||
|
const absolute = path.join(directory, entry.name);
|
||||||
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isSymbolicLink())
|
||||||
|
throw new Error(`Release contains a symbolic link: ${relative}`);
|
||||||
|
if (entry.isDirectory()) files.push(...(await collect(absolute, relative)));
|
||||||
|
else if (entry.isFile()) files.push({ absolute, relative });
|
||||||
|
else throw new Error(`Unsupported release entry: ${relative}`);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceFiles = await collect(input);
|
||||||
|
for (const file of sourceFiles)
|
||||||
|
if (
|
||||||
|
file.relative.endsWith(".map") ||
|
||||||
|
/(?:^|\/)(?:\.env(?:\.|$)|id_rsa|id_ed25519|.*\.pem$|.*\.key$)/iu.test(
|
||||||
|
file.relative,
|
||||||
|
) ||
|
||||||
|
file.relative.startsWith("/") ||
|
||||||
|
file.relative.split("/").includes("..")
|
||||||
|
)
|
||||||
|
throw new Error(`Forbidden release entry: ${file.relative}`);
|
||||||
|
|
||||||
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
|
const stagingRoot = await mkdtemp(path.join(os.tmpdir(), "colour-release-"));
|
||||||
|
const publicationRoot = await mkdtemp(
|
||||||
|
path.join(path.dirname(output), ".colour-publish-"),
|
||||||
|
);
|
||||||
|
const stagedTree = path.join(stagingRoot, "tree");
|
||||||
|
const stagedArchive = path.join(stagingRoot, path.basename(output));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cp(input, stagedTree, { recursive: true });
|
||||||
|
const timestamp = new Date("1980-01-01T00:00:00.000Z");
|
||||||
|
for (const file of await collect(stagedTree)) {
|
||||||
|
await chmod(file.absolute, 0o644);
|
||||||
|
await utimes(file.absolute, timestamp, timestamp);
|
||||||
|
}
|
||||||
|
await execute(
|
||||||
|
"zip",
|
||||||
|
[
|
||||||
|
"-X",
|
||||||
|
"-q",
|
||||||
|
"-9",
|
||||||
|
stagedArchive,
|
||||||
|
...sourceFiles.map((file) => file.relative),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: stagedTree,
|
||||||
|
env: { ...process.env, TZ: "UTC" },
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const archive = await readFile(stagedArchive);
|
||||||
|
const digest = createHash("sha256").update(archive).digest("hex");
|
||||||
|
const stagedChecksum = `${stagedArchive}.sha256`;
|
||||||
|
await writeFile(stagedChecksum, `${digest} ${path.basename(output)}\n`, {
|
||||||
|
mode: 0o644,
|
||||||
|
});
|
||||||
|
const publicationArchive = path.join(publicationRoot, path.basename(output));
|
||||||
|
const publicationChecksum = `${publicationArchive}.sha256`;
|
||||||
|
await copyFile(stagedArchive, publicationArchive);
|
||||||
|
await copyFile(stagedChecksum, publicationChecksum);
|
||||||
|
if (force) {
|
||||||
|
await rm(output, { force: true });
|
||||||
|
await rm(checksumOutput, { force: true });
|
||||||
|
}
|
||||||
|
await rename(publicationArchive, output);
|
||||||
|
await rename(publicationChecksum, checksumOutput);
|
||||||
|
console.log(
|
||||||
|
`Created ${path.relative(root, output)} (${archive.byteLength} bytes, ${sourceFiles.length} files)\nSHA-256 ${digest}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(stagingRoot, { recursive: true, force: true });
|
||||||
|
await rm(publicationRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const destination = path.join(root, "public");
|
||||||
|
const required = [
|
||||||
|
"LICENSE",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"CONTRIBUTING.md",
|
||||||
|
"SECURITY.md",
|
||||||
|
"SOURCE.md",
|
||||||
|
"THIRD_PARTY_NOTICES.md",
|
||||||
|
];
|
||||||
|
|
||||||
|
await mkdir(destination, { recursive: true });
|
||||||
|
for (const name of required) {
|
||||||
|
await readFile(path.join(root, name));
|
||||||
|
await cp(path.join(root, name), path.join(destination, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of ["LICENSES", "docs"]) {
|
||||||
|
const output = path.join(destination, directory);
|
||||||
|
await rm(output, { recursive: true, force: true });
|
||||||
|
await cp(path.join(root, directory), output, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = JSON.parse(
|
||||||
|
await readFile(path.join(root, "package-lock.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
for (const [location, locked] of Object.entries(lock.packages ?? {}).sort(
|
||||||
|
([left], [right]) => (left === right ? 0 : left < right ? -1 : 1),
|
||||||
|
)) {
|
||||||
|
if (!location.includes("node_modules/") || locked.dev === true) continue;
|
||||||
|
const packageDirectory = path.join(root, location);
|
||||||
|
const details = JSON.parse(
|
||||||
|
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const candidates = (await readdir(packageDirectory))
|
||||||
|
.filter((name) => /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(name))
|
||||||
|
.sort();
|
||||||
|
const texts = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
texts.push(
|
||||||
|
`--- ${candidate} ---\n${await readFile(path.join(packageDirectory, candidate), "utf8")}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* Ignore directories and non-text aliases. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sections.push(
|
||||||
|
[
|
||||||
|
"=".repeat(78),
|
||||||
|
`${details.name}@${details.version}`,
|
||||||
|
`Declared licence: ${details.license ?? locked.license ?? "See upstream"}`,
|
||||||
|
`Installed from: ${location}`,
|
||||||
|
"=".repeat(78),
|
||||||
|
texts.join("\n\n") ||
|
||||||
|
"No package-local licence file was present; see THIRD_PARTY_NOTICES.md.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
path.join(destination, "LICENSES", "npm-runtime-licenses.txt"),
|
||||||
|
`${sections.join("\n\n").trimEnd()}\n`,
|
||||||
|
);
|
||||||
|
console.log("Prepared static release documentation and notices");
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"dist",
|
||||||
|
);
|
||||||
|
const nestedPrefix = "/deep/nested/colour/";
|
||||||
|
const mediaTypes = new Map([
|
||||||
|
[".css", "text/css; charset=utf-8"],
|
||||||
|
[".html", "text/html; charset=utf-8"],
|
||||||
|
[".js", "text/javascript; charset=utf-8"],
|
||||||
|
[".json", "application/json; charset=utf-8"],
|
||||||
|
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
||||||
|
[".svg", "image/svg+xml"],
|
||||||
|
[".md", "text/markdown; charset=utf-8"],
|
||||||
|
[".txt", "text/plain; charset=utf-8"],
|
||||||
|
[".wasm", "application/wasm"],
|
||||||
|
[".png", "image/png"],
|
||||||
|
[".jpg", "image/jpeg"],
|
||||||
|
[".jpeg", "image/jpeg"],
|
||||||
|
[".webp", "image/webp"],
|
||||||
|
[".avif", "image/avif"],
|
||||||
|
]);
|
||||||
|
const headers = {
|
||||||
|
"Content-Security-Policy":
|
||||||
|
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'",
|
||||||
|
"Cross-Origin-Opener-Policy": "same-origin",
|
||||||
|
"Cross-Origin-Resource-Policy": "same-origin",
|
||||||
|
"Permissions-Policy":
|
||||||
|
"camera=(), microphone=(), geolocation=(), usb=(), payment=()",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeFile(requestPath) {
|
||||||
|
const decoded = decodeURIComponent(requestPath);
|
||||||
|
const relative = decoded.startsWith(nestedPrefix)
|
||||||
|
? decoded.slice(nestedPrefix.length)
|
||||||
|
: decoded.replace(/^\/+/, "");
|
||||||
|
const normalized = path.posix.normalize(relative || "index.html");
|
||||||
|
if (
|
||||||
|
normalized === ".." ||
|
||||||
|
normalized.startsWith("../") ||
|
||||||
|
path.isAbsolute(normalized)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return path.join(root, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||||
|
let file = safeFile(url.pathname);
|
||||||
|
if (!file) {
|
||||||
|
response.writeHead(400).end("Bad request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((await stat(file).catch(() => null))?.isDirectory())
|
||||||
|
file = path.join(file, "index.html");
|
||||||
|
const content = await readFile(file);
|
||||||
|
response.writeHead(200, {
|
||||||
|
"Content-Type":
|
||||||
|
mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
response.end(content);
|
||||||
|
} catch {
|
||||||
|
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
response.end("Not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(4173, "127.0.0.1", () =>
|
||||||
|
console.log("Colour Tools test server listening on http://127.0.0.1:4173"),
|
||||||
|
);
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { lazy, Suspense, useState } from "react";
|
||||||
|
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||||
|
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||||
|
import "./styles.css";
|
||||||
|
import { AppErrorBoundary } from "./components/AppErrorBoundary";
|
||||||
|
import { HelpDialog } from "./components/HelpDialog";
|
||||||
|
import { manifest } from "./toolbox/manifest";
|
||||||
|
|
||||||
|
const Workbench = lazy(async () => ({
|
||||||
|
default: (await import("./components/Workbench")).Workbench,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppErrorBoundary>
|
||||||
|
<AppShell
|
||||||
|
app={manifest}
|
||||||
|
manifestUrl="./toolbox-app.json"
|
||||||
|
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||||
|
onContextError={(error) =>
|
||||||
|
console.warn(
|
||||||
|
"Toolbox context unavailable; continuing standalone.",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<p className="workbench-loading" role="status">
|
||||||
|
Preparing the local colour workbench…
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Workbench />
|
||||||
|
</Suspense>
|
||||||
|
</AppShell>
|
||||||
|
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||||
|
</AppErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import {
|
||||||
|
asColor,
|
||||||
|
clamp,
|
||||||
|
colorFrom,
|
||||||
|
lineariseSrgb,
|
||||||
|
srgbChannels,
|
||||||
|
toColourValue,
|
||||||
|
} from "./internal";
|
||||||
|
import { compositeSourceOver } from "./composite";
|
||||||
|
import { formatColour, toSrgbCss } from "./format";
|
||||||
|
import { mapToGamut } from "./gamut";
|
||||||
|
import { parseColour } from "./parse";
|
||||||
|
import type {
|
||||||
|
ColourInput,
|
||||||
|
ColourValue,
|
||||||
|
ContrastOptions,
|
||||||
|
ContrastReport,
|
||||||
|
ContrastSuggestion,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const WHITE: ColourValue = { space: "srgb", coords: [1, 1, 1], alpha: 1 };
|
||||||
|
|
||||||
|
export function flattenColour(
|
||||||
|
foreground: ColourInput,
|
||||||
|
background: ColourInput,
|
||||||
|
): ColourValue {
|
||||||
|
// Browser alpha compositing for ordinary CSS sRGB colours is performed in the
|
||||||
|
// encoded colour space; contrast must inspect the pixels users actually see.
|
||||||
|
return compositeSourceOver(foreground, background, { space: "srgb" }).colour;
|
||||||
|
}
|
||||||
|
|
||||||
|
function opaqueCanvas(input: ColourInput | undefined): ColourValue {
|
||||||
|
const canvas = parseColour(input ?? WHITE);
|
||||||
|
return canvas.alpha >= 1 ? canvas : flattenColour(canvas, WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenedPair(
|
||||||
|
foreground: ColourInput,
|
||||||
|
background: ColourInput,
|
||||||
|
options: ContrastOptions,
|
||||||
|
): { foreground: ColourValue; background: ColourValue } {
|
||||||
|
const canvas = opaqueCanvas(options.canvas);
|
||||||
|
const flattenedBackground = flattenColour(background, canvas);
|
||||||
|
const flattenedForeground = flattenColour(foreground, flattenedBackground);
|
||||||
|
return { foreground: flattenedForeground, background: flattenedBackground };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeLuminance(input: ColourInput): number {
|
||||||
|
const [red, green, blue] = srgbChannels(parseColour(input));
|
||||||
|
return (
|
||||||
|
0.2126 * lineariseSrgb(red) +
|
||||||
|
0.7152 * lineariseSrgb(green) +
|
||||||
|
0.0722 * lineariseSrgb(blue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrastRatio(
|
||||||
|
foreground: ColourInput,
|
||||||
|
background: ColourInput,
|
||||||
|
options: ContrastOptions = {},
|
||||||
|
): number {
|
||||||
|
const pair = flattenedPair(foreground, background, options);
|
||||||
|
const first = relativeLuminance(pair.foreground);
|
||||||
|
const second = relativeLuminance(pair.background);
|
||||||
|
const lighter = Math.max(first, second);
|
||||||
|
const darker = Math.min(first, second);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrastReport(
|
||||||
|
foreground: ColourInput,
|
||||||
|
background: ColourInput,
|
||||||
|
options: ContrastOptions = {},
|
||||||
|
): ContrastReport {
|
||||||
|
const foregroundValue = parseColour(foreground);
|
||||||
|
const backgroundValue = parseColour(background);
|
||||||
|
const pair = flattenedPair(foregroundValue, backgroundValue, options);
|
||||||
|
const ratio = contrastRatio(foregroundValue, backgroundValue, options);
|
||||||
|
return {
|
||||||
|
ratio,
|
||||||
|
foreground: foregroundValue,
|
||||||
|
background: backgroundValue,
|
||||||
|
flattenedForeground: pair.foreground,
|
||||||
|
flattenedBackground: pair.background,
|
||||||
|
passes: {
|
||||||
|
aaLarge: ratio >= 3,
|
||||||
|
aaaLarge: ratio >= 4.5,
|
||||||
|
aaNormal: ratio >= 4.5,
|
||||||
|
aaaNormal: ratio >= 7,
|
||||||
|
nonText: ratio >= 3,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
colour: ColourValue;
|
||||||
|
ratio: number;
|
||||||
|
difference: number;
|
||||||
|
lightness: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateAtLightness(
|
||||||
|
original: ColourValue,
|
||||||
|
oklch: readonly number[],
|
||||||
|
lightness: number,
|
||||||
|
background: ColourInput,
|
||||||
|
target: number,
|
||||||
|
options: ContrastOptions,
|
||||||
|
): Candidate | undefined {
|
||||||
|
const candidate = mapToGamut(
|
||||||
|
toColourValue(
|
||||||
|
colorFrom(
|
||||||
|
"oklch",
|
||||||
|
[lightness, oklch[1] ?? 0, oklch[2] ?? 0],
|
||||||
|
original.alpha,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
candidate.alpha = original.alpha;
|
||||||
|
const ratio = contrastRatio(candidate, background, options);
|
||||||
|
if (ratio + 1e-9 < target) return undefined;
|
||||||
|
return {
|
||||||
|
colour: candidate,
|
||||||
|
ratio,
|
||||||
|
difference: asColor(original).deltaEOK(asColor(candidate)),
|
||||||
|
lightness,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nearestPassingColour(
|
||||||
|
foreground: ColourInput,
|
||||||
|
background: ColourInput,
|
||||||
|
target = 4.5,
|
||||||
|
options: ContrastOptions = {},
|
||||||
|
): ContrastSuggestion | null {
|
||||||
|
if (!Number.isFinite(target) || target < 1 || target > 21) {
|
||||||
|
throw new RangeError("Contrast target must be between 1 and 21.");
|
||||||
|
}
|
||||||
|
const original = parseColour(foreground);
|
||||||
|
const originalRatio = contrastRatio(original, background, options);
|
||||||
|
const originalOklch = asColor(original)
|
||||||
|
.to("oklch")
|
||||||
|
.coords.map((coordinate) => Number(coordinate ?? 0));
|
||||||
|
const originalLightness = clamp(originalOklch[0] ?? 0);
|
||||||
|
if (originalRatio >= target) {
|
||||||
|
return {
|
||||||
|
colour: original,
|
||||||
|
css: toSrgbCss(original),
|
||||||
|
hex: formatColour(original, original.alpha < 1 ? "hex8" : "hex"),
|
||||||
|
ratio: originalRatio,
|
||||||
|
deltaEOK: 0,
|
||||||
|
direction: originalLightness >= 0.5 ? "lighter" : "darker",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let best: Candidate | undefined;
|
||||||
|
const consider = (candidate: Candidate | undefined): void => {
|
||||||
|
if (!candidate) return;
|
||||||
|
if (
|
||||||
|
!best ||
|
||||||
|
candidate.difference < best.difference - 1e-9 ||
|
||||||
|
(Math.abs(candidate.difference - best.difference) <= 1e-9 &&
|
||||||
|
candidate.ratio < best.ratio)
|
||||||
|
) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index <= 400; index += 1) {
|
||||||
|
consider(
|
||||||
|
candidateAtLightness(
|
||||||
|
original,
|
||||||
|
originalOklch,
|
||||||
|
index / 400,
|
||||||
|
background,
|
||||||
|
target,
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!best) return null;
|
||||||
|
|
||||||
|
const coarse = best as Candidate;
|
||||||
|
const lower = clamp(coarse.lightness - 1 / 400);
|
||||||
|
const upper = clamp(coarse.lightness + 1 / 400);
|
||||||
|
for (let index = 0; index <= 100; index += 1) {
|
||||||
|
const lightness = lower + ((upper - lower) * index) / 100;
|
||||||
|
consider(
|
||||||
|
candidateAtLightness(
|
||||||
|
original,
|
||||||
|
originalOklch,
|
||||||
|
lightness,
|
||||||
|
background,
|
||||||
|
target,
|
||||||
|
options,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosen = best as Candidate;
|
||||||
|
return {
|
||||||
|
colour: chosen.colour,
|
||||||
|
css: toSrgbCss(chosen.colour),
|
||||||
|
hex: formatColour(chosen.colour, chosen.colour.alpha < 1 ? "hex8" : "hex"),
|
||||||
|
ratio: chosen.ratio,
|
||||||
|
deltaEOK: chosen.difference,
|
||||||
|
direction: chosen.lightness >= originalLightness ? "lighter" : "darker",
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "colorjs.io/dist/color.js" {
|
||||||
|
export { default } from "colorjs.io";
|
||||||
|
export type * from "colorjs.io";
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
asColor,
|
||||||
|
clamp,
|
||||||
|
encodeSrgb,
|
||||||
|
lineariseSrgb,
|
||||||
|
srgbChannels,
|
||||||
|
} from "./internal";
|
||||||
|
import { mapToGamut } from "./gamut";
|
||||||
|
import { parseColour } from "./parse";
|
||||||
|
import type {
|
||||||
|
ColourCoordinates,
|
||||||
|
ColourInput,
|
||||||
|
ColourValue,
|
||||||
|
ColourVisionDeficiency,
|
||||||
|
ColourVisionOptions,
|
||||||
|
DeltaEMethod,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export function deltaE(
|
||||||
|
first: ColourInput,
|
||||||
|
second: ColourInput,
|
||||||
|
method: DeltaEMethod = "2000",
|
||||||
|
): number {
|
||||||
|
const left = asColor(parseColour(first));
|
||||||
|
const right = asColor(parseColour(second));
|
||||||
|
switch (method) {
|
||||||
|
case "76":
|
||||||
|
return left.deltaE76(right);
|
||||||
|
case "cmc":
|
||||||
|
return left.deltaECMC(right);
|
||||||
|
case "2000":
|
||||||
|
return left.deltaE2000(right);
|
||||||
|
case "ok":
|
||||||
|
return left.deltaEOK(right);
|
||||||
|
case "itp":
|
||||||
|
return left.deltaEITP(right);
|
||||||
|
case "jz":
|
||||||
|
return left.deltaEJz(right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Matrix = readonly [
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
readonly [number, number, number],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Full-severity Machado et al. matrices, applied to linear-light sRGB.
|
||||||
|
const VISION_MATRICES: Record<ColourVisionDeficiency, Matrix> = {
|
||||||
|
protanopia: [
|
||||||
|
[0.152286, 1.052583, -0.204868],
|
||||||
|
[0.114503, 0.786281, 0.099216],
|
||||||
|
[-0.003882, -0.048116, 1.051998],
|
||||||
|
],
|
||||||
|
deuteranopia: [
|
||||||
|
[0.367322, 0.860646, -0.227968],
|
||||||
|
[0.280085, 0.672501, 0.047413],
|
||||||
|
[-0.01182, 0.04294, 0.968881],
|
||||||
|
],
|
||||||
|
tritanopia: [
|
||||||
|
[1.255528, -0.076749, -0.178779],
|
||||||
|
[-0.078411, 0.930809, 0.147602],
|
||||||
|
[0.004733, 0.691367, 0.3039],
|
||||||
|
],
|
||||||
|
achromatopsia: [
|
||||||
|
[0.2126, 0.7152, 0.0722],
|
||||||
|
[0.2126, 0.7152, 0.0722],
|
||||||
|
[0.2126, 0.7152, 0.0722],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function multiply(
|
||||||
|
matrix: Matrix,
|
||||||
|
vector: ColourCoordinates,
|
||||||
|
): ColourCoordinates {
|
||||||
|
return matrix.map(
|
||||||
|
(row) => row[0] * vector[0] + row[1] * vector[1] + row[2] * vector[2],
|
||||||
|
) as ColourCoordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateColourVision(
|
||||||
|
input: ColourInput,
|
||||||
|
deficiency: ColourVisionDeficiency,
|
||||||
|
options: ColourVisionOptions = {},
|
||||||
|
): ColourValue {
|
||||||
|
const source = parseColour(input);
|
||||||
|
const severity = clamp(options.severity ?? 1);
|
||||||
|
const encoded = srgbChannels(source);
|
||||||
|
const linear = encoded.map(lineariseSrgb) as ColourCoordinates;
|
||||||
|
const simulated = multiply(VISION_MATRICES[deficiency], linear);
|
||||||
|
const mixed = linear.map(
|
||||||
|
(channel, index) =>
|
||||||
|
channel + ((simulated[index] ?? channel) - channel) * severity,
|
||||||
|
);
|
||||||
|
const result: ColourValue = {
|
||||||
|
space: "srgb",
|
||||||
|
coords: mixed.map(encodeSrgb) as ColourCoordinates,
|
||||||
|
alpha: source.alpha,
|
||||||
|
};
|
||||||
|
return options.mapToSrgb === false
|
||||||
|
? result
|
||||||
|
: mapToGamut(result, { method: "clip" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simulateColourVisionSet(
|
||||||
|
input: ColourInput,
|
||||||
|
options: ColourVisionOptions = {},
|
||||||
|
): Record<ColourVisionDeficiency, ColourValue> {
|
||||||
|
return {
|
||||||
|
protanopia: simulateColourVision(input, "protanopia", options),
|
||||||
|
deuteranopia: simulateColourVision(input, "deuteranopia", options),
|
||||||
|
tritanopia: simulateColourVision(input, "tritanopia", options),
|
||||||
|
achromatopsia: simulateColourVision(input, "achromatopsia", options),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { clamp, encodeSrgb, lineariseSrgb, srgbChannels } from "./internal";
|
||||||
|
import { formatColour, toSrgbCss } from "./format";
|
||||||
|
import { parseColour } from "./parse";
|
||||||
|
import type {
|
||||||
|
BlendMode,
|
||||||
|
ColourCoordinates,
|
||||||
|
ColourInput,
|
||||||
|
ColourValue,
|
||||||
|
CompositeLayer,
|
||||||
|
CompositeOptions,
|
||||||
|
CompositeResult,
|
||||||
|
CompositingSpace,
|
||||||
|
SourceOverOptions,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
function luminosity([red, green, blue]: ColourCoordinates): number {
|
||||||
|
return 0.3 * red + 0.59 * green + 0.11 * blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saturation(channels: ColourCoordinates): number {
|
||||||
|
return Math.max(...channels) - Math.min(...channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipColour(channels: ColourCoordinates): ColourCoordinates {
|
||||||
|
const lightness = luminosity(channels);
|
||||||
|
const minimum = Math.min(...channels);
|
||||||
|
const maximum = Math.max(...channels);
|
||||||
|
let output = [...channels] as ColourCoordinates;
|
||||||
|
if (minimum < 0) {
|
||||||
|
output = output.map(
|
||||||
|
(channel) =>
|
||||||
|
lightness + ((channel - lightness) * lightness) / (lightness - minimum),
|
||||||
|
) as ColourCoordinates;
|
||||||
|
}
|
||||||
|
if (maximum > 1) {
|
||||||
|
output = output.map(
|
||||||
|
(channel) =>
|
||||||
|
lightness +
|
||||||
|
((channel - lightness) * (1 - lightness)) / (maximum - lightness),
|
||||||
|
) as ColourCoordinates;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLuminosity(
|
||||||
|
channels: ColourCoordinates,
|
||||||
|
target: number,
|
||||||
|
): ColourCoordinates {
|
||||||
|
const difference = target - luminosity(channels);
|
||||||
|
return clipColour(
|
||||||
|
channels.map((channel) => channel + difference) as ColourCoordinates,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSaturation(
|
||||||
|
channels: ColourCoordinates,
|
||||||
|
target: number,
|
||||||
|
): ColourCoordinates {
|
||||||
|
const indices = [0, 1, 2] as const;
|
||||||
|
const sorted = [...indices].sort(
|
||||||
|
(first, second) => channels[first] - channels[second],
|
||||||
|
);
|
||||||
|
const minimumIndex = sorted[0] ?? 0;
|
||||||
|
const middleIndex = sorted[1] ?? 1;
|
||||||
|
const maximumIndex = sorted[2] ?? 2;
|
||||||
|
const minimum = channels[minimumIndex];
|
||||||
|
const maximum = channels[maximumIndex];
|
||||||
|
const output: ColourCoordinates = [0, 0, 0];
|
||||||
|
if (maximum > minimum) {
|
||||||
|
output[middleIndex] =
|
||||||
|
((channels[middleIndex] - minimum) * target) / (maximum - minimum);
|
||||||
|
output[maximumIndex] = target;
|
||||||
|
}
|
||||||
|
output[minimumIndex] = 0;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function softLight(backdrop: number, source: number): number {
|
||||||
|
if (source <= 0.5)
|
||||||
|
return backdrop - (1 - 2 * source) * backdrop * (1 - backdrop);
|
||||||
|
const d =
|
||||||
|
backdrop <= 0.25
|
||||||
|
? ((16 * backdrop - 12) * backdrop + 4) * backdrop
|
||||||
|
: Math.sqrt(backdrop);
|
||||||
|
return backdrop + (2 * source - 1) * (d - backdrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blendChannel(
|
||||||
|
backdrop: number,
|
||||||
|
source: number,
|
||||||
|
mode: BlendMode,
|
||||||
|
): number {
|
||||||
|
switch (mode) {
|
||||||
|
case "multiply":
|
||||||
|
return backdrop * source;
|
||||||
|
case "screen":
|
||||||
|
return backdrop + source - backdrop * source;
|
||||||
|
case "overlay":
|
||||||
|
return backdrop <= 0.5
|
||||||
|
? 2 * backdrop * source
|
||||||
|
: 1 - 2 * (1 - backdrop) * (1 - source);
|
||||||
|
case "darken":
|
||||||
|
return Math.min(backdrop, source);
|
||||||
|
case "lighten":
|
||||||
|
return Math.max(backdrop, source);
|
||||||
|
case "color-dodge":
|
||||||
|
return source >= 1 ? 1 : Math.min(1, backdrop / (1 - source));
|
||||||
|
case "color-burn":
|
||||||
|
return source <= 0 ? 0 : 1 - Math.min(1, (1 - backdrop) / source);
|
||||||
|
case "hard-light":
|
||||||
|
return source <= 0.5
|
||||||
|
? 2 * backdrop * source
|
||||||
|
: 1 - 2 * (1 - backdrop) * (1 - source);
|
||||||
|
case "soft-light":
|
||||||
|
return softLight(backdrop, source);
|
||||||
|
case "difference":
|
||||||
|
return Math.abs(backdrop - source);
|
||||||
|
case "exclusion":
|
||||||
|
return backdrop + source - 2 * backdrop * source;
|
||||||
|
case "normal":
|
||||||
|
case "hue":
|
||||||
|
case "saturation":
|
||||||
|
case "color":
|
||||||
|
case "luminosity":
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blend(
|
||||||
|
backdrop: ColourCoordinates,
|
||||||
|
source: ColourCoordinates,
|
||||||
|
mode: BlendMode,
|
||||||
|
): ColourCoordinates {
|
||||||
|
if (["hue", "saturation", "color", "luminosity"].includes(mode)) {
|
||||||
|
const nonSeparable =
|
||||||
|
mode === "hue"
|
||||||
|
? setLuminosity(
|
||||||
|
setSaturation(source, saturation(backdrop)),
|
||||||
|
luminosity(backdrop),
|
||||||
|
)
|
||||||
|
: mode === "saturation"
|
||||||
|
? setLuminosity(
|
||||||
|
setSaturation(backdrop, saturation(source)),
|
||||||
|
luminosity(backdrop),
|
||||||
|
)
|
||||||
|
: mode === "color"
|
||||||
|
? setLuminosity(source, luminosity(backdrop))
|
||||||
|
: setLuminosity(backdrop, luminosity(source));
|
||||||
|
return nonSeparable.map((channel) => clamp(channel)) as ColourCoordinates;
|
||||||
|
}
|
||||||
|
return backdrop.map((channel, index) =>
|
||||||
|
clamp(blendChannel(channel, source[index] ?? 0, mode)),
|
||||||
|
) as ColourCoordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelsFor(
|
||||||
|
input: ColourInput,
|
||||||
|
space: CompositingSpace,
|
||||||
|
): ColourCoordinates {
|
||||||
|
const channels = srgbChannels(parseColour(input));
|
||||||
|
return space === "linear-srgb"
|
||||||
|
? (channels.map(lineariseSrgb) as ColourCoordinates)
|
||||||
|
: channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compositeValues(
|
||||||
|
source: ColourValue,
|
||||||
|
backdrop: ColourValue,
|
||||||
|
space: CompositingSpace,
|
||||||
|
mode: BlendMode,
|
||||||
|
opacity: number,
|
||||||
|
): ColourValue {
|
||||||
|
const sourceChannels = channelsFor(source, space);
|
||||||
|
const backdropChannels = channelsFor(backdrop, space);
|
||||||
|
const sourceAlpha = clamp(source.alpha * clamp(opacity));
|
||||||
|
const backdropAlpha = clamp(backdrop.alpha);
|
||||||
|
const outputAlpha = sourceAlpha + backdropAlpha * (1 - sourceAlpha);
|
||||||
|
if (outputAlpha <= 0) return { space: "srgb", coords: [0, 0, 0], alpha: 0 };
|
||||||
|
|
||||||
|
const blended = blend(backdropChannels, sourceChannels, mode);
|
||||||
|
const output = sourceChannels.map((sourceChannel, index) => {
|
||||||
|
const backdropChannel = backdropChannels[index] ?? 0;
|
||||||
|
const blendChannelValue = blended[index] ?? sourceChannel;
|
||||||
|
const premultiplied =
|
||||||
|
sourceAlpha * (1 - backdropAlpha) * sourceChannel +
|
||||||
|
sourceAlpha * backdropAlpha * blendChannelValue +
|
||||||
|
(1 - sourceAlpha) * backdropAlpha * backdropChannel;
|
||||||
|
return premultiplied / outputAlpha;
|
||||||
|
}) as ColourCoordinates;
|
||||||
|
const encoded =
|
||||||
|
space === "linear-srgb"
|
||||||
|
? (output.map(encodeSrgb) as ColourCoordinates)
|
||||||
|
: output;
|
||||||
|
return {
|
||||||
|
space: "srgb",
|
||||||
|
coords: encoded.map((channel) => clamp(channel)) as ColourCoordinates,
|
||||||
|
alpha: outputAlpha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(colour: ColourValue): CompositeResult {
|
||||||
|
return {
|
||||||
|
colour,
|
||||||
|
css: toSrgbCss(colour),
|
||||||
|
hex: formatColour(colour, colour.alpha < 1 ? "hex8" : "hex"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositeSourceOver(
|
||||||
|
foreground: ColourInput,
|
||||||
|
background: ColourInput,
|
||||||
|
options: SourceOverOptions = {},
|
||||||
|
): CompositeResult {
|
||||||
|
return result(
|
||||||
|
compositeValues(
|
||||||
|
parseColour(foreground),
|
||||||
|
parseColour(background),
|
||||||
|
options.space ?? "srgb",
|
||||||
|
options.blendMode ?? "normal",
|
||||||
|
options.opacity ?? 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositeLayers(
|
||||||
|
layers: readonly CompositeLayer[],
|
||||||
|
options: CompositeOptions = {},
|
||||||
|
): CompositeResult {
|
||||||
|
if (layers.length === 0)
|
||||||
|
return result({ space: "srgb", coords: [0, 0, 0], alpha: 0 });
|
||||||
|
const ordered =
|
||||||
|
options.order === "top-to-bottom" ? [...layers].reverse() : [...layers];
|
||||||
|
let accumulator: ColourValue = { space: "srgb", coords: [0, 0, 0], alpha: 0 };
|
||||||
|
for (const layer of ordered) {
|
||||||
|
accumulator = compositeValues(
|
||||||
|
parseColour(layer.colour),
|
||||||
|
accumulator,
|
||||||
|
options.space ?? "srgb",
|
||||||
|
layer.blendMode ?? "normal",
|
||||||
|
layer.opacity ?? 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result(accumulator);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
alphaText,
|
||||||
|
asColor,
|
||||||
|
clamp,
|
||||||
|
mappedColor,
|
||||||
|
numberText,
|
||||||
|
round,
|
||||||
|
srgbChannels,
|
||||||
|
toColourValue,
|
||||||
|
} from "./internal";
|
||||||
|
import { parseColour } from "./parse";
|
||||||
|
import type {
|
||||||
|
ColourFormat,
|
||||||
|
ColourInput,
|
||||||
|
ConversionRow,
|
||||||
|
FormatColourOptions,
|
||||||
|
SrgbPreview,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const FORMAT_LABELS: Record<ColourFormat, string> = {
|
||||||
|
hex: "HEX",
|
||||||
|
hex8: "HEX + alpha",
|
||||||
|
rgb: "RGB",
|
||||||
|
rgba: "RGBA",
|
||||||
|
hsl: "HSL",
|
||||||
|
hsv: "HSV / HSB",
|
||||||
|
hwb: "HWB",
|
||||||
|
cmyk: "CMYK",
|
||||||
|
lab: "CIELAB",
|
||||||
|
lch: "CIELCH",
|
||||||
|
oklab: "OKLab",
|
||||||
|
oklch: "OKLCH",
|
||||||
|
p3: "Display P3",
|
||||||
|
rec2020: "Rec. 2020",
|
||||||
|
a98rgb: "Adobe RGB (1998)",
|
||||||
|
prophoto: "ProPhoto RGB",
|
||||||
|
"xyz-d50": "XYZ D50",
|
||||||
|
"xyz-d65": "XYZ D65",
|
||||||
|
css: "Original CSS",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CONVERSION_FORMATS: readonly ColourFormat[] = [
|
||||||
|
"hex",
|
||||||
|
"hex8",
|
||||||
|
"rgb",
|
||||||
|
"hsl",
|
||||||
|
"hsv",
|
||||||
|
"hwb",
|
||||||
|
"cmyk",
|
||||||
|
"lab",
|
||||||
|
"lch",
|
||||||
|
"oklab",
|
||||||
|
"oklch",
|
||||||
|
"p3",
|
||||||
|
"rec2020",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function byte(value: number): number {
|
||||||
|
return Math.round(clamp(value) * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteHex(value: number): string {
|
||||||
|
return byte(value).toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function asHex(input: ColourInput, includeAlpha: boolean): string {
|
||||||
|
const value = parseColour(input);
|
||||||
|
const channels = srgbChannels(value);
|
||||||
|
const rgb = channels.map(byteHex).join("");
|
||||||
|
return `#${rgb}${includeAlpha ? byteHex(value.alpha) : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(value: number, precision: number): string {
|
||||||
|
return `${numberText(value, precision)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function functional(
|
||||||
|
name: string,
|
||||||
|
coords: readonly string[],
|
||||||
|
alpha: number,
|
||||||
|
includeAlpha: boolean,
|
||||||
|
): string {
|
||||||
|
return `${name}(${coords.join(" ")}${includeAlpha && alpha < 1 ? ` / ${alphaText(alpha)}` : ""})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseColorFunction(
|
||||||
|
space: string,
|
||||||
|
coords: readonly number[],
|
||||||
|
alpha: number,
|
||||||
|
precision: number,
|
||||||
|
): string {
|
||||||
|
return `color(${space} ${coords.map((coordinate) => numberText(coordinate, precision)).join(" ")}${
|
||||||
|
alpha < 1 ? ` / ${alphaText(alpha)}` : ""
|
||||||
|
})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatColour(
|
||||||
|
input: ColourInput,
|
||||||
|
format: ColourFormat = "css",
|
||||||
|
options: FormatColourOptions = {},
|
||||||
|
): string {
|
||||||
|
const precision = options.precision ?? 4;
|
||||||
|
const value = parseColour(input);
|
||||||
|
const alpha = value.alpha;
|
||||||
|
const includeAlpha = options.includeAlpha ?? alpha < 1;
|
||||||
|
|
||||||
|
if (format === "hex") return asHex(value, false);
|
||||||
|
if (format === "hex8") return asHex(value, true);
|
||||||
|
|
||||||
|
if (format === "rgb" || format === "rgba") {
|
||||||
|
const channels = srgbChannels(value, options.mapToSrgb ?? true).map(byte);
|
||||||
|
const forceAlpha = format === "rgba" || includeAlpha;
|
||||||
|
return forceAlpha
|
||||||
|
? `rgba(${channels.join(", ")}, ${alphaText(alpha)})`
|
||||||
|
: `rgb(${channels.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "cmyk") {
|
||||||
|
const [red, green, blue] = srgbChannels(value, options.mapToSrgb ?? true);
|
||||||
|
const black = 1 - Math.max(red, green, blue);
|
||||||
|
const denominator = 1 - black;
|
||||||
|
const cyan = denominator <= 1e-12 ? 0 : (1 - red - black) / denominator;
|
||||||
|
const magenta =
|
||||||
|
denominator <= 1e-12 ? 0 : (1 - green - black) / denominator;
|
||||||
|
const yellow = denominator <= 1e-12 ? 0 : (1 - blue - black) / denominator;
|
||||||
|
return functional(
|
||||||
|
"cmyk",
|
||||||
|
[cyan, magenta, yellow, black].map((channel) =>
|
||||||
|
percent(channel * 100, precision),
|
||||||
|
),
|
||||||
|
alpha,
|
||||||
|
includeAlpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = asColor(value);
|
||||||
|
if (format === "hsl" || format === "hsv" || format === "hwb") {
|
||||||
|
const converted = color.to(format);
|
||||||
|
const [hue, first, second] = converted.coords.map((coordinate) =>
|
||||||
|
Number(coordinate ?? 0),
|
||||||
|
);
|
||||||
|
return functional(
|
||||||
|
format,
|
||||||
|
[
|
||||||
|
numberText(hue ?? 0, precision),
|
||||||
|
percent(first ?? 0, precision),
|
||||||
|
percent(second ?? 0, precision),
|
||||||
|
],
|
||||||
|
alpha,
|
||||||
|
includeAlpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const space =
|
||||||
|
format === "p3"
|
||||||
|
? "p3"
|
||||||
|
: format === "a98rgb"
|
||||||
|
? "a98rgb"
|
||||||
|
: format === "prophoto"
|
||||||
|
? "prophoto"
|
||||||
|
: format;
|
||||||
|
|
||||||
|
if (format === "lab" || format === "lch") {
|
||||||
|
const converted = color.to(space);
|
||||||
|
const coords = converted.coords.map((coordinate) =>
|
||||||
|
Number(coordinate ?? 0),
|
||||||
|
);
|
||||||
|
return functional(
|
||||||
|
format,
|
||||||
|
[
|
||||||
|
percent(coords[0] ?? 0, precision),
|
||||||
|
numberText(coords[1] ?? 0, precision),
|
||||||
|
numberText(coords[2] ?? 0, precision),
|
||||||
|
],
|
||||||
|
alpha,
|
||||||
|
includeAlpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (format === "oklab" || format === "oklch") {
|
||||||
|
const converted = color.to(space);
|
||||||
|
const coords = converted.coords.map((coordinate) =>
|
||||||
|
Number(coordinate ?? 0),
|
||||||
|
);
|
||||||
|
return functional(
|
||||||
|
format,
|
||||||
|
[
|
||||||
|
percent((coords[0] ?? 0) * 100, precision),
|
||||||
|
numberText(coords[1] ?? 0, precision),
|
||||||
|
numberText(coords[2] ?? 0, precision),
|
||||||
|
],
|
||||||
|
alpha,
|
||||||
|
includeAlpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
["p3", "rec2020", "a98rgb", "prophoto", "xyz-d50", "xyz-d65"].includes(
|
||||||
|
format,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const converted = color.to(space);
|
||||||
|
const cssSpace =
|
||||||
|
format === "p3"
|
||||||
|
? "display-p3"
|
||||||
|
: format === "a98rgb"
|
||||||
|
? "a98-rgb"
|
||||||
|
: format === "prophoto"
|
||||||
|
? "prophoto-rgb"
|
||||||
|
: format;
|
||||||
|
return serialiseColorFunction(
|
||||||
|
cssSpace,
|
||||||
|
converted.coords.map((coordinate) =>
|
||||||
|
round(Number(coordinate ?? 0), precision),
|
||||||
|
),
|
||||||
|
alpha,
|
||||||
|
precision,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "css") {
|
||||||
|
return color.toString({ precision, inGamut: options.mapToSrgb === true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhaustiveness safeguard for callers compiled against older declarations.
|
||||||
|
return color.toString({ precision, inGamut: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conversionRows(
|
||||||
|
input: ColourInput,
|
||||||
|
formats: readonly ColourFormat[] = DEFAULT_CONVERSION_FORMATS,
|
||||||
|
): ConversionRow[] {
|
||||||
|
return formats.map((format) => {
|
||||||
|
const value = formatColour(input, format);
|
||||||
|
return {
|
||||||
|
id: format,
|
||||||
|
label: FORMAT_LABELS[format],
|
||||||
|
value,
|
||||||
|
copyValue: value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatRows = conversionRows;
|
||||||
|
|
||||||
|
export function toSrgbPreview(input: ColourInput): SrgbPreview {
|
||||||
|
const value = parseColour(input);
|
||||||
|
const original = asColor(value);
|
||||||
|
const mapped = mappedColor(value, "srgb");
|
||||||
|
const rgb = mapped.coords.map((coordinate) =>
|
||||||
|
clamp(Number(coordinate ?? 0)),
|
||||||
|
) as [number, number, number];
|
||||||
|
const integerChannels = rgb.map(byte);
|
||||||
|
return {
|
||||||
|
css:
|
||||||
|
value.alpha < 1
|
||||||
|
? `rgb(${integerChannels.join(" ")} / ${alphaText(value.alpha)})`
|
||||||
|
: `rgb(${integerChannels.join(" ")})`,
|
||||||
|
hex: formatColour(toColourValue(mapped), value.alpha < 1 ? "hex8" : "hex", {
|
||||||
|
includeAlpha: value.alpha < 1,
|
||||||
|
}),
|
||||||
|
rgb,
|
||||||
|
alpha: value.alpha,
|
||||||
|
wasMapped: !original.inGamut("srgb"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSrgbCss(input: ColourInput): string {
|
||||||
|
return toSrgbPreview(input).css;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { asColor, toColourValue } from "./internal";
|
||||||
|
import { parseColour } from "./parse";
|
||||||
|
import { formatColour, toSrgbCss } from "./format";
|
||||||
|
import type {
|
||||||
|
ColourInput,
|
||||||
|
ColourValue,
|
||||||
|
GamutMappingOptions,
|
||||||
|
GamutReport,
|
||||||
|
GamutSpaceReport,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const GAMUT_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
srgb: "sRGB",
|
||||||
|
p3: "Display P3",
|
||||||
|
rec2020: "Rec. 2020",
|
||||||
|
a98rgb: "Adobe RGB (1998)",
|
||||||
|
prophoto: "ProPhoto RGB",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_GAMUT_SPACES = ["srgb", "p3", "rec2020"] as const;
|
||||||
|
|
||||||
|
export function mapToGamut(
|
||||||
|
input: ColourInput,
|
||||||
|
options: GamutMappingOptions = {},
|
||||||
|
): ColourValue {
|
||||||
|
const target = options.target ?? "srgb";
|
||||||
|
const method = options.method === "clip" ? "clip" : "oklch.c";
|
||||||
|
const converted = asColor(parseColour(input)).to(target);
|
||||||
|
const mapped = converted.inGamut(target)
|
||||||
|
? converted
|
||||||
|
: converted.toGamut({ space: target, method });
|
||||||
|
return toColourValue(mapped.to(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportSpace(input: ColourValue, space: string): GamutSpaceReport {
|
||||||
|
const source = asColor(input);
|
||||||
|
const inGamut = source.inGamut(space);
|
||||||
|
const mapped = mapToGamut(input, { target: space });
|
||||||
|
const format =
|
||||||
|
space === "p3"
|
||||||
|
? "p3"
|
||||||
|
: space === "rec2020"
|
||||||
|
? "rec2020"
|
||||||
|
: space === "a98rgb"
|
||||||
|
? "a98rgb"
|
||||||
|
: space === "prophoto"
|
||||||
|
? "prophoto"
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
space,
|
||||||
|
label: GAMUT_LABELS[space] ?? space,
|
||||||
|
inGamut,
|
||||||
|
mapped,
|
||||||
|
mappedCss: format ? formatColour(mapped, format) : toSrgbCss(mapped),
|
||||||
|
deltaEOK: inGamut ? 0 : source.deltaEOK(asColor(mapped)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gamutReport(
|
||||||
|
input: ColourInput,
|
||||||
|
spaces: readonly string[] = DEFAULT_GAMUT_SPACES,
|
||||||
|
): GamutReport {
|
||||||
|
const source = parseColour(input);
|
||||||
|
return { source, spaces: spaces.map((space) => reportSpace(source, space)) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export {
|
||||||
|
ColourParseError,
|
||||||
|
parseColour,
|
||||||
|
parseColourList,
|
||||||
|
tryParseColour,
|
||||||
|
} from "./parse";
|
||||||
|
export {
|
||||||
|
DEFAULT_CONVERSION_FORMATS,
|
||||||
|
conversionRows,
|
||||||
|
formatColour,
|
||||||
|
formatRows,
|
||||||
|
toSrgbCss,
|
||||||
|
toSrgbPreview,
|
||||||
|
} from "./format";
|
||||||
|
export { DEFAULT_GAMUT_SPACES, gamutReport, mapToGamut } from "./gamut";
|
||||||
|
export { compositeLayers, compositeSourceOver } from "./composite";
|
||||||
|
export { interpolateColourStops, interpolateStops } from "./interpolate";
|
||||||
|
export {
|
||||||
|
contrastRatio,
|
||||||
|
contrastReport,
|
||||||
|
flattenColour,
|
||||||
|
nearestPassingColour,
|
||||||
|
relativeLuminance,
|
||||||
|
} from "./accessibility";
|
||||||
|
export {
|
||||||
|
deltaE,
|
||||||
|
simulateColourVision,
|
||||||
|
simulateColourVisionSet,
|
||||||
|
} from "./compare";
|
||||||
|
export {
|
||||||
|
colourHarmony,
|
||||||
|
exportCssVariables,
|
||||||
|
exportCsvPalette,
|
||||||
|
exportDesignTokens,
|
||||||
|
exportJsonPalette,
|
||||||
|
exportPalette,
|
||||||
|
exportScssVariables,
|
||||||
|
exportTailwindPalette,
|
||||||
|
generateHarmony,
|
||||||
|
normaliseTokenName,
|
||||||
|
parseNamedColourList,
|
||||||
|
parsePaletteList,
|
||||||
|
shades,
|
||||||
|
tints,
|
||||||
|
tones,
|
||||||
|
} from "./palette";
|
||||||
|
|
||||||
|
export type * from "./types";
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import Color from "colorjs.io/dist/color.js";
|
||||||
|
|
||||||
|
import type { ColourCoordinates, ColourInput, ColourValue } from "./types";
|
||||||
|
|
||||||
|
export const EPSILON = 1e-9;
|
||||||
|
|
||||||
|
export function clamp(value: number, minimum = 0, maximum = 1): number {
|
||||||
|
return Math.min(maximum, Math.max(minimum, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round(value: number, precision = 4): number {
|
||||||
|
const factor = 10 ** precision;
|
||||||
|
const result = Math.round((value + Number.EPSILON) * factor) / factor;
|
||||||
|
return Object.is(result, -0) ? 0 : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberText(value: number, precision = 4): string {
|
||||||
|
return String(round(value, precision));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alphaText(alpha: number, precision = 3): string {
|
||||||
|
return numberText(clamp(alpha), precision);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finiteCoordinate(value: number | null | undefined): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toColourValue(color: Color): ColourValue {
|
||||||
|
return {
|
||||||
|
space: color.spaceId,
|
||||||
|
coords: color.coords.map(finiteCoordinate) as ColourCoordinates,
|
||||||
|
alpha: clamp(Number.isFinite(color.alpha) ? color.alpha : 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isColourValue(value: unknown): value is ColourValue {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const candidate = value as Partial<ColourValue>;
|
||||||
|
return (
|
||||||
|
typeof candidate.space === "string" &&
|
||||||
|
Array.isArray(candidate.coords) &&
|
||||||
|
candidate.coords.length === 3 &&
|
||||||
|
candidate.coords.every(
|
||||||
|
(coordinate) =>
|
||||||
|
typeof coordinate === "number" && Number.isFinite(coordinate),
|
||||||
|
) &&
|
||||||
|
typeof candidate.alpha === "number" &&
|
||||||
|
Number.isFinite(candidate.alpha)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asColor(input: ColourInput): Color {
|
||||||
|
if (typeof input === "string") return new Color(input);
|
||||||
|
return new Color(input.space, [...input.coords], clamp(input.alpha));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function colorFrom(
|
||||||
|
space: string,
|
||||||
|
coords: readonly number[],
|
||||||
|
alpha = 1,
|
||||||
|
): Color {
|
||||||
|
return new Color(
|
||||||
|
space,
|
||||||
|
coords.map(finiteCoordinate) as ColourCoordinates,
|
||||||
|
clamp(alpha),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyColour(value: ColourValue): ColourValue {
|
||||||
|
return { space: value.space, coords: [...value.coords], alpha: value.alpha };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mappedColor(
|
||||||
|
input: ColourInput,
|
||||||
|
target = "srgb",
|
||||||
|
method: "oklch.c" | "clip" = "oklch.c",
|
||||||
|
): Color {
|
||||||
|
const converted = asColor(input).to(target);
|
||||||
|
return converted.inGamut(target)
|
||||||
|
? converted
|
||||||
|
: converted.toGamut({ space: target, method });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function srgbChannels(
|
||||||
|
input: ColourInput,
|
||||||
|
map = true,
|
||||||
|
): ColourCoordinates {
|
||||||
|
const color = map ? mappedColor(input, "srgb") : asColor(input).to("srgb");
|
||||||
|
return color.coords.map((coordinate) =>
|
||||||
|
map ? clamp(finiteCoordinate(coordinate)) : finiteCoordinate(coordinate),
|
||||||
|
) as ColourCoordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lineariseSrgb(channel: number): number {
|
||||||
|
return channel <= 0.04045
|
||||||
|
? channel / 12.92
|
||||||
|
: ((channel + 0.055) / 1.055) ** 2.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeSrgb(channel: number): number {
|
||||||
|
return channel <= 0.0031308
|
||||||
|
? 12.92 * channel
|
||||||
|
: 1.055 * channel ** (1 / 2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hueIndex(space: string): number | undefined {
|
||||||
|
if (["hsl", "hsv", "hwb", "okhsl", "okhsv"].includes(space)) return 0;
|
||||||
|
if (["lch", "oklch", "jzczhz", "hct"].includes(space)) return 2;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Color };
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { asColor, clamp, colorFrom, hueIndex, toColourValue } from "./internal";
|
||||||
|
import { formatColour, toSrgbCss } from "./format";
|
||||||
|
import { parseColour } from "./parse";
|
||||||
|
import type {
|
||||||
|
ColourCoordinates,
|
||||||
|
ColourStop,
|
||||||
|
ColourValue,
|
||||||
|
EasingName,
|
||||||
|
HueInterpolation,
|
||||||
|
InterpolationOptions,
|
||||||
|
InterpolationStep,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface PositionedStop {
|
||||||
|
colour: ColourValue;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ease(value: number, name: EasingName): number {
|
||||||
|
switch (name) {
|
||||||
|
case "ease-in":
|
||||||
|
return value * value;
|
||||||
|
case "ease-out":
|
||||||
|
return 1 - (1 - value) ** 2;
|
||||||
|
case "ease-in-out":
|
||||||
|
return value < 0.5 ? 2 * value * value : 1 - (-2 * value + 2) ** 2 / 2;
|
||||||
|
case "smoothstep":
|
||||||
|
return value * value * (3 - 2 * value);
|
||||||
|
case "linear":
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseStops(
|
||||||
|
stops: readonly ColourStop[],
|
||||||
|
shouldClamp: boolean,
|
||||||
|
): PositionedStop[] {
|
||||||
|
if (stops.length < 2)
|
||||||
|
throw new RangeError("At least two colour stops are required.");
|
||||||
|
const positions = stops.map((stop) => {
|
||||||
|
if (stop.position === undefined) return undefined;
|
||||||
|
if (!Number.isFinite(stop.position))
|
||||||
|
throw new RangeError("Stop positions must be finite numbers.");
|
||||||
|
return shouldClamp ? clamp(stop.position) : stop.position;
|
||||||
|
});
|
||||||
|
positions[0] ??= 0;
|
||||||
|
positions[positions.length - 1] ??= 1;
|
||||||
|
|
||||||
|
let previousKnown = 0;
|
||||||
|
for (let index = 1; index < positions.length; index += 1) {
|
||||||
|
if (positions[index] === undefined) continue;
|
||||||
|
const start = positions[previousKnown] ?? 0;
|
||||||
|
const end = Math.max(start, positions[index] ?? start);
|
||||||
|
positions[index] = end;
|
||||||
|
const gap = index - previousKnown;
|
||||||
|
for (let missing = 1; missing < gap; missing += 1) {
|
||||||
|
positions[previousKnown + missing] =
|
||||||
|
start + ((end - start) * missing) / gap;
|
||||||
|
}
|
||||||
|
previousKnown = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stops.map((stop, index) => ({
|
||||||
|
colour: parseColour(stop.colour),
|
||||||
|
position: positions[index] ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueDelta(from: number, to: number, method: HueInterpolation): number {
|
||||||
|
if (method === "raw") return to - from;
|
||||||
|
const increasing = (((to - from) % 360) + 360) % 360;
|
||||||
|
const decreasing = increasing === 0 ? 0 : increasing - 360;
|
||||||
|
if (method === "increasing") return increasing;
|
||||||
|
if (method === "decreasing") return decreasing;
|
||||||
|
if (method === "shorter") return increasing <= 180 ? increasing : decreasing;
|
||||||
|
if (increasing === 0) return 360;
|
||||||
|
return increasing <= 180 ? decreasing : increasing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpolateColourStops(
|
||||||
|
stops: readonly ColourStop[],
|
||||||
|
position: number,
|
||||||
|
options: InterpolationOptions = {},
|
||||||
|
): ColourValue {
|
||||||
|
if (!Number.isFinite(position))
|
||||||
|
throw new RangeError("Interpolation position must be finite.");
|
||||||
|
const shouldClamp = options.clamp ?? true;
|
||||||
|
const prepared = normaliseStops(stops, shouldClamp);
|
||||||
|
const sample = shouldClamp ? clamp(position) : position;
|
||||||
|
let left = prepared[0];
|
||||||
|
let right = prepared[prepared.length - 1];
|
||||||
|
if (!left || !right)
|
||||||
|
throw new RangeError("At least two colour stops are required.");
|
||||||
|
|
||||||
|
if (sample <= left.position) right = prepared[1] ?? right;
|
||||||
|
else if (sample >= right.position)
|
||||||
|
left = prepared[prepared.length - 2] ?? left;
|
||||||
|
else {
|
||||||
|
for (let index = 1; index < prepared.length; index += 1) {
|
||||||
|
const candidate = prepared[index];
|
||||||
|
if (candidate && sample <= candidate.position) {
|
||||||
|
left = prepared[index - 1] ?? left;
|
||||||
|
right = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = right.position - left.position;
|
||||||
|
const local = range === 0 ? 1 : (sample - left.position) / range;
|
||||||
|
const progress = ease(
|
||||||
|
shouldClamp ? clamp(local) : local,
|
||||||
|
options.easing ?? "linear",
|
||||||
|
);
|
||||||
|
const space = options.space ?? "oklch";
|
||||||
|
const leftColor = asColor(left.colour).to(space);
|
||||||
|
const rightColor = asColor(right.colour).to(space);
|
||||||
|
const leftCoords = leftColor.coords.map((coordinate) =>
|
||||||
|
Number(coordinate ?? 0),
|
||||||
|
) as ColourCoordinates;
|
||||||
|
const rightCoords = rightColor.coords.map((coordinate) =>
|
||||||
|
Number(coordinate ?? 0),
|
||||||
|
) as ColourCoordinates;
|
||||||
|
const hueCoordinate = hueIndex(space);
|
||||||
|
const alpha =
|
||||||
|
left.colour.alpha + (right.colour.alpha - left.colour.alpha) * progress;
|
||||||
|
const premultiplied = options.premultiplied ?? true;
|
||||||
|
const coords = leftCoords.map((coordinate, index) => {
|
||||||
|
if (index === hueCoordinate) {
|
||||||
|
return (
|
||||||
|
coordinate +
|
||||||
|
hueDelta(
|
||||||
|
coordinate,
|
||||||
|
rightCoords[index] ?? coordinate,
|
||||||
|
options.hue ?? "shorter",
|
||||||
|
) *
|
||||||
|
progress
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rightCoordinate = rightCoords[index] ?? coordinate;
|
||||||
|
if (!premultiplied) {
|
||||||
|
return coordinate + (rightCoordinate - coordinate) * progress;
|
||||||
|
}
|
||||||
|
const mixed =
|
||||||
|
coordinate * left.colour.alpha * (1 - progress) +
|
||||||
|
rightCoordinate * right.colour.alpha * progress;
|
||||||
|
return alpha > 1e-12 ? mixed / alpha : 0;
|
||||||
|
}) as ColourCoordinates;
|
||||||
|
return toColourValue(colorFrom(space, coords, alpha));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function interpolateStops(
|
||||||
|
stops: readonly ColourStop[],
|
||||||
|
count: number,
|
||||||
|
options: InterpolationOptions = {},
|
||||||
|
): InterpolationStep[] {
|
||||||
|
if (!Number.isInteger(count) || count < 2 || count > 10_000) {
|
||||||
|
throw new RangeError("Step count must be an integer from 2 to 10,000.");
|
||||||
|
}
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const position = index / (count - 1);
|
||||||
|
const colour = interpolateColourStops(stops, position, options);
|
||||||
|
return {
|
||||||
|
position,
|
||||||
|
colour,
|
||||||
|
css: toSrgbCss(colour),
|
||||||
|
hex: formatColour(colour, colour.alpha < 1 ? "hex8" : "hex"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { asColor, clamp, colorFrom, toColourValue } from "./internal";
|
||||||
|
import { formatColour } from "./format";
|
||||||
|
import { mapToGamut } from "./gamut";
|
||||||
|
import { interpolateColourStops } from "./interpolate";
|
||||||
|
import { parseColour, tryParseColour } from "./parse";
|
||||||
|
import type {
|
||||||
|
ColourFormat,
|
||||||
|
ColourInput,
|
||||||
|
ColourValue,
|
||||||
|
HarmonyType,
|
||||||
|
PaletteEntry,
|
||||||
|
PaletteExportFormat,
|
||||||
|
PaletteExportOptions,
|
||||||
|
PaletteScaleOptions,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const HARMONY_OFFSETS: Record<HarmonyType, readonly number[]> = {
|
||||||
|
complementary: [0, 180],
|
||||||
|
analogous: [-30, 0, 30],
|
||||||
|
"split-complementary": [0, 150, 210],
|
||||||
|
triadic: [0, 120, 240],
|
||||||
|
tetradic: [0, 60, 180, 240],
|
||||||
|
square: [0, 90, 180, 270],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function colourHarmony(
|
||||||
|
input: ColourInput,
|
||||||
|
type: HarmonyType,
|
||||||
|
): ColourValue[] {
|
||||||
|
const source = parseColour(input);
|
||||||
|
const converted = asColor(source).to("oklch");
|
||||||
|
const lightness = Number(converted.coords[0] ?? 0);
|
||||||
|
const chroma = Number(converted.coords[1] ?? 0);
|
||||||
|
const hue = Number(converted.coords[2] ?? 0);
|
||||||
|
return HARMONY_OFFSETS[type].map((offset) =>
|
||||||
|
mapToGamut(
|
||||||
|
toColourValue(
|
||||||
|
colorFrom(
|
||||||
|
"oklch",
|
||||||
|
[lightness, chroma, (hue + offset + 360) % 360],
|
||||||
|
source.alpha,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateHarmony = colourHarmony;
|
||||||
|
|
||||||
|
function scale(
|
||||||
|
input: ColourInput,
|
||||||
|
endpoint: ColourInput,
|
||||||
|
count: number,
|
||||||
|
options: PaletteScaleOptions,
|
||||||
|
): ColourValue[] {
|
||||||
|
if (!Number.isInteger(count) || count < 1 || count > 1_000) {
|
||||||
|
throw new RangeError("Palette count must be an integer from 1 to 1,000.");
|
||||||
|
}
|
||||||
|
const source = parseColour(input);
|
||||||
|
const values: ColourValue[] = options.includeBase ? [source] : [];
|
||||||
|
const includeEndpoint = options.includeEndpoint ?? false;
|
||||||
|
const denominator = includeEndpoint ? count : count + 1;
|
||||||
|
for (let index = 1; index <= count; index += 1) {
|
||||||
|
values.push(
|
||||||
|
interpolateColourStops(
|
||||||
|
[{ colour: source }, { colour: endpoint }],
|
||||||
|
index / denominator,
|
||||||
|
{ space: options.space ?? "oklab" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tints(
|
||||||
|
input: ColourInput,
|
||||||
|
count = 5,
|
||||||
|
options: PaletteScaleOptions = {},
|
||||||
|
): ColourValue[] {
|
||||||
|
return scale(input, "#ffffff", count, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shades(
|
||||||
|
input: ColourInput,
|
||||||
|
count = 5,
|
||||||
|
options: PaletteScaleOptions = {},
|
||||||
|
): ColourValue[] {
|
||||||
|
return scale(input, "#000000", count, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tones(
|
||||||
|
input: ColourInput,
|
||||||
|
count = 5,
|
||||||
|
options: PaletteScaleOptions = {},
|
||||||
|
): ColourValue[] {
|
||||||
|
const source = parseColour(input);
|
||||||
|
const lightness = clamp(Number(asColor(source).to("oklab").coords[0] ?? 0));
|
||||||
|
const neutral = toColourValue(
|
||||||
|
colorFrom("oklab", [lightness, 0, 0], source.alpha),
|
||||||
|
);
|
||||||
|
return scale(source, neutral, count, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryFromUnknown(
|
||||||
|
name: string,
|
||||||
|
value: unknown,
|
||||||
|
): PaletteEntry | undefined {
|
||||||
|
const candidate =
|
||||||
|
typeof value === "object" && value !== null
|
||||||
|
? ((value as Record<string, unknown>).$value ??
|
||||||
|
(value as Record<string, unknown>).value ??
|
||||||
|
(value as Record<string, unknown>).colour ??
|
||||||
|
(value as Record<string, unknown>).color)
|
||||||
|
: value;
|
||||||
|
if (typeof candidate !== "string") return undefined;
|
||||||
|
const result = tryParseColour(candidate);
|
||||||
|
return result.ok
|
||||||
|
? { name, colour: result.value, source: candidate }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonPalette(input: string): PaletteEntry[] | undefined {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(input) as unknown;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item, index) => {
|
||||||
|
if (typeof item === "string")
|
||||||
|
return {
|
||||||
|
name: `colour-${index + 1}`,
|
||||||
|
colour: parseColour(item),
|
||||||
|
source: item,
|
||||||
|
};
|
||||||
|
if (typeof item !== "object" || item === null)
|
||||||
|
throw new TypeError(`Palette item ${index + 1} is invalid.`);
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const name =
|
||||||
|
typeof record.name === "string" ? record.name : `colour-${index + 1}`;
|
||||||
|
const entry = entryFromUnknown(name, record);
|
||||||
|
if (!entry)
|
||||||
|
throw new TypeError(
|
||||||
|
`Palette item “${name}” does not contain a valid colour.`,
|
||||||
|
);
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof parsed === "object" && parsed !== null) {
|
||||||
|
const entries: PaletteEntry[] = [];
|
||||||
|
const visit = (record: Record<string, unknown>, path: string[]): void => {
|
||||||
|
for (const [name, value] of Object.entries(record)) {
|
||||||
|
if (name.startsWith("$")) continue;
|
||||||
|
const nextPath = [...path, name];
|
||||||
|
const entry = entryFromUnknown(nextPath.join("-"), value);
|
||||||
|
if (entry) entries.push(entry);
|
||||||
|
else if (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
!Array.isArray(value)
|
||||||
|
) {
|
||||||
|
visit(value as Record<string, unknown>, nextPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(parsed as Record<string, unknown>, []);
|
||||||
|
if (entries.length > 0) return entries;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePaletteList(input: string): PaletteEntry[] {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
const json = parseJsonPalette(trimmed);
|
||||||
|
if (json) return json;
|
||||||
|
|
||||||
|
const cssBlock = trimmed.match(/^\s*(?::root|[^{}]+)\s*\{([\s\S]*)\}\s*$/);
|
||||||
|
const listText = cssBlock?.[1] ?? trimmed;
|
||||||
|
|
||||||
|
const lines = listText
|
||||||
|
.split(/[\n;]/)
|
||||||
|
.map((line) => line.trim().replace(/,$/, ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
return lines.map((line, index) => {
|
||||||
|
const whole = tryParseColour(line);
|
||||||
|
if (whole.ok)
|
||||||
|
return { name: `colour-${index + 1}`, colour: whole.value, source: line };
|
||||||
|
const match = line.match(/^(--)?([^:=]+?)\s*[:=]\s*(.+)$/);
|
||||||
|
if (!match)
|
||||||
|
throw new TypeError(`Could not parse palette line ${index + 1}: ${line}`);
|
||||||
|
const name = (match[2] ?? `colour-${index + 1}`).trim();
|
||||||
|
const source = (match[3] ?? "").trim();
|
||||||
|
return { name, colour: parseColour(source), source };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseNamedColourList = parsePaletteList;
|
||||||
|
|
||||||
|
export function normaliseTokenName(input: string): string {
|
||||||
|
const normalised = input
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.replace(/-{2,}/g, "-");
|
||||||
|
return normalised || "colour";
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputValue(entry: PaletteEntry, format: ColourFormat): string {
|
||||||
|
return formatColour(
|
||||||
|
entry.colour,
|
||||||
|
format === "hex" && entry.colour.alpha < 1 ? "hex8" : format,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueNames(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
): Array<{ name: string; entry: PaletteEntry }> {
|
||||||
|
const used = new Map<string, number>();
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const base = normaliseTokenName(entry.name);
|
||||||
|
const occurrence = (used.get(base) ?? 0) + 1;
|
||||||
|
used.set(base, occurrence);
|
||||||
|
return { name: occurrence === 1 ? base : `${base}-${occurrence}`, entry };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportCssVariables(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
options: PaletteExportOptions = {},
|
||||||
|
): string {
|
||||||
|
const prefix = normaliseTokenName(options.prefix ?? "colour");
|
||||||
|
const lines = uniqueNames(entries).map(
|
||||||
|
({ name, entry }) =>
|
||||||
|
` --${prefix}-${name}: ${outputValue(entry, options.format ?? "hex")};`,
|
||||||
|
);
|
||||||
|
return `:root {\n${lines.join("\n")}\n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportScssVariables(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
options: PaletteExportOptions = {},
|
||||||
|
): string {
|
||||||
|
const prefix = normaliseTokenName(options.prefix ?? "colour");
|
||||||
|
return uniqueNames(entries)
|
||||||
|
.map(
|
||||||
|
({ name, entry }) =>
|
||||||
|
`$${prefix}-${name}: ${outputValue(entry, options.format ?? "hex")};`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportJsonPalette(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
options: PaletteExportOptions = {},
|
||||||
|
): string {
|
||||||
|
const value = Object.fromEntries(
|
||||||
|
uniqueNames(entries).map(({ name, entry }) => [
|
||||||
|
name,
|
||||||
|
outputValue(entry, options.format ?? "hex"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return JSON.stringify(value, null, options.pretty === false ? undefined : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportDesignTokens(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
options: PaletteExportOptions = {},
|
||||||
|
): string {
|
||||||
|
const value = Object.fromEntries(
|
||||||
|
uniqueNames(entries).map(({ name, entry }) => [
|
||||||
|
name,
|
||||||
|
{ $type: "color", $value: outputValue(entry, options.format ?? "hex") },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return JSON.stringify(value, null, options.pretty === false ? undefined : 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportTailwindPalette(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
options: PaletteExportOptions = {},
|
||||||
|
): string {
|
||||||
|
const value = Object.fromEntries(
|
||||||
|
uniqueNames(entries).map(({ name, entry }) => [
|
||||||
|
name,
|
||||||
|
outputValue(entry, options.format ?? "hex"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return `export default ${JSON.stringify(value, null, options.pretty === false ? undefined : 2)};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string): string {
|
||||||
|
return /[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportCsvPalette(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
options: PaletteExportOptions = {},
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
"name,value",
|
||||||
|
...uniqueNames(entries).map(
|
||||||
|
({ name, entry }) =>
|
||||||
|
`${csvCell(name)},${csvCell(outputValue(entry, options.format ?? "hex"))}`,
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportPalette(
|
||||||
|
entries: readonly PaletteEntry[],
|
||||||
|
format: PaletteExportFormat = "css",
|
||||||
|
options: PaletteExportOptions = {},
|
||||||
|
): string {
|
||||||
|
switch (format) {
|
||||||
|
case "css":
|
||||||
|
return exportCssVariables(entries, options);
|
||||||
|
case "scss":
|
||||||
|
return exportScssVariables(entries, options);
|
||||||
|
case "json":
|
||||||
|
return exportJsonPalette(entries, options);
|
||||||
|
case "tokens":
|
||||||
|
return exportDesignTokens(entries, options);
|
||||||
|
case "tailwind":
|
||||||
|
return exportTailwindPalette(entries, options);
|
||||||
|
case "csv":
|
||||||
|
return exportCsvPalette(entries, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import {
|
||||||
|
Color,
|
||||||
|
clamp,
|
||||||
|
copyColour,
|
||||||
|
isColourValue,
|
||||||
|
toColourValue,
|
||||||
|
} from "./internal";
|
||||||
|
import type {
|
||||||
|
ColourInput,
|
||||||
|
ColourParseErrorCode,
|
||||||
|
ColourParseResult,
|
||||||
|
ColourValue,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export class ColourParseError extends Error {
|
||||||
|
readonly code: ColourParseErrorCode;
|
||||||
|
readonly input: string;
|
||||||
|
|
||||||
|
constructor(code: ColourParseErrorCode, message: string, input: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ColourParseError";
|
||||||
|
this.code = code;
|
||||||
|
this.input = input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(token: string, label: string, input: string): number {
|
||||||
|
const value = Number(token);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"non-finite",
|
||||||
|
`${label} must be a finite number.`,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnitInterval(
|
||||||
|
token: string,
|
||||||
|
label: string,
|
||||||
|
input: string,
|
||||||
|
allowHundred = true,
|
||||||
|
): number {
|
||||||
|
const trimmed = token.trim();
|
||||||
|
const percentage = trimmed.endsWith("%");
|
||||||
|
const value = parseNumber(
|
||||||
|
percentage ? trimmed.slice(0, -1) : trimmed,
|
||||||
|
label,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
const normalised =
|
||||||
|
percentage || (allowHundred && value > 1) ? value / 100 : value;
|
||||||
|
if (normalised < 0 || normalised > 1) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"out-of-range",
|
||||||
|
`${label} must be between 0 and 100%.`,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalised;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAlpha(token: string | undefined, input: string): number {
|
||||||
|
return token === undefined
|
||||||
|
? 1
|
||||||
|
: parseUnitInterval(token, "Alpha", input, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAngle(token: string, input: string): number {
|
||||||
|
const match = token
|
||||||
|
.trim()
|
||||||
|
.match(
|
||||||
|
/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(deg|grad|rad|turn)?$/i,
|
||||||
|
);
|
||||||
|
if (!match)
|
||||||
|
throw new ColourParseError(
|
||||||
|
"invalid-syntax",
|
||||||
|
"Hue must be a valid CSS angle.",
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
const value = parseNumber(match[1] ?? "", "Hue", input);
|
||||||
|
const unit = match[2]?.toLowerCase() ?? "deg";
|
||||||
|
const degrees =
|
||||||
|
unit === "turn"
|
||||||
|
? value * 360
|
||||||
|
: unit === "rad"
|
||||||
|
? (value * 180) / Math.PI
|
||||||
|
: unit === "grad"
|
||||||
|
? value * 0.9
|
||||||
|
: value;
|
||||||
|
return ((degrees % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionParts(
|
||||||
|
body: string,
|
||||||
|
expectedChannels = 3,
|
||||||
|
): { channels: string[]; alpha?: string } {
|
||||||
|
const slash = body.split("/");
|
||||||
|
if (slash.length > 2) return { channels: [] };
|
||||||
|
const channelText = slash[0]?.trim() ?? "";
|
||||||
|
const commaParts = channelText.includes(",")
|
||||||
|
? channelText.split(",").map((part) => part.trim())
|
||||||
|
: channelText.split(/\s+/).filter(Boolean);
|
||||||
|
let alpha = slash[1]?.trim();
|
||||||
|
if (commaParts.length === expectedChannels + 1 && alpha === undefined)
|
||||||
|
alpha = commaParts.pop();
|
||||||
|
return { channels: commaParts, ...(alpha === undefined ? {} : { alpha }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHsv(input: string): ColourValue | undefined {
|
||||||
|
const match = input.match(/^hs(?:v|b)a?\((.*)\)$/is);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const parts = functionParts(match[1] ?? "");
|
||||||
|
if (parts.channels.length !== 3) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"invalid-syntax",
|
||||||
|
"HSV needs hue, saturation and value channels.",
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hue = parseAngle(parts.channels[0] ?? "", input);
|
||||||
|
const saturation =
|
||||||
|
parseUnitInterval(parts.channels[1] ?? "", "Saturation", input) * 100;
|
||||||
|
const value =
|
||||||
|
parseUnitInterval(parts.channels[2] ?? "", "Value", input) * 100;
|
||||||
|
return {
|
||||||
|
space: "hsv",
|
||||||
|
coords: [hue, saturation, value],
|
||||||
|
alpha: parseAlpha(parts.alpha, input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCmyk(input: string): ColourValue | undefined {
|
||||||
|
const match = input.match(/^(?:device-)?cmyka?\((.*)\)$/is);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const parts = functionParts(match[1] ?? "", 4);
|
||||||
|
if (parts.channels.length !== 4) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"invalid-syntax",
|
||||||
|
"CMYK needs cyan, magenta, yellow and black channels.",
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [cyan, magenta, yellow, black] = parts.channels.map((part, index) =>
|
||||||
|
parseUnitInterval(
|
||||||
|
part,
|
||||||
|
["Cyan", "Magenta", "Yellow", "Black"][index] ?? "Channel",
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const c = cyan ?? 0;
|
||||||
|
const m = magenta ?? 0;
|
||||||
|
const y = yellow ?? 0;
|
||||||
|
const k = black ?? 0;
|
||||||
|
return {
|
||||||
|
space: "srgb",
|
||||||
|
coords: [(1 - c) * (1 - k), (1 - m) * (1 - k), (1 - y) * (1 - k)],
|
||||||
|
alpha: parseAlpha(parts.alpha, input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBareRgb(input: string): ColourValue | undefined {
|
||||||
|
if (!/^\s*[+-]?(?:\d|\.)/.test(input) || !input.includes(","))
|
||||||
|
return undefined;
|
||||||
|
const parts = input.split(",").map((part) => part.trim());
|
||||||
|
if (parts.length < 3 || parts.length > 4 || parts.some((part) => part === ""))
|
||||||
|
return undefined;
|
||||||
|
const channels = parts.slice(0, 3).map((part, index) => {
|
||||||
|
const value = parseNumber(
|
||||||
|
part,
|
||||||
|
["Red", "Green", "Blue"][index] ?? "Channel",
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
if (value < 0 || value > 255) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"out-of-range",
|
||||||
|
"RGB channels must be between 0 and 255.",
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value / 255;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
space: "srgb",
|
||||||
|
coords: channels as [number, number, number],
|
||||||
|
alpha: parseAlpha(parts[3], input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseStringInput(input: string): string {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (/^[\da-f]{3,8}$/i.test(trimmed) && [3, 4, 6, 8].includes(trimmed.length))
|
||||||
|
return `#${trimmed}`;
|
||||||
|
if (/^0x[\da-f]{6,8}$/i.test(trimmed)) return `#${trimmed.slice(2)}`;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseColour(input: ColourInput): ColourValue {
|
||||||
|
if (typeof input !== "string") {
|
||||||
|
if (!isColourValue(input)) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"invalid-syntax",
|
||||||
|
"Colour value is malformed.",
|
||||||
|
String(input),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (input.alpha < 0 || input.alpha > 1) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"out-of-range",
|
||||||
|
"Alpha must be between 0 and 1.",
|
||||||
|
JSON.stringify(input),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Validate the public space identifier before returning a defensive copy.
|
||||||
|
new Color(input.space, [...input.coords], clamp(input.alpha));
|
||||||
|
return copyColour(input);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ColourParseError(
|
||||||
|
"unsupported-space",
|
||||||
|
error instanceof Error ? error.message : "Unsupported colour space.",
|
||||||
|
JSON.stringify(input),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = normaliseStringInput(input);
|
||||||
|
if (text === "")
|
||||||
|
throw new ColourParseError("empty", "Enter a colour.", input);
|
||||||
|
|
||||||
|
const custom = parseHsv(text) ?? parseCmyk(text) ?? parseBareRgb(text);
|
||||||
|
if (custom) return custom;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return toColourValue(new Color(text));
|
||||||
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
|
error instanceof Error && error.message ? ` ${error.message}` : "";
|
||||||
|
throw new ColourParseError(
|
||||||
|
"invalid-syntax",
|
||||||
|
`Could not parse “${text}”.${detail}`,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tryParseColour(input: ColourInput): ColourParseResult {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: parseColour(input) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ColourParseError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { code: error.code, message: error.message, input: error.input },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "invalid-syntax",
|
||||||
|
message: "Could not parse this colour.",
|
||||||
|
input: String(input),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevel(input: string): string[] {
|
||||||
|
const output: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
let depth = 0;
|
||||||
|
let quote = "";
|
||||||
|
for (let index = 0; index < input.length; index += 1) {
|
||||||
|
const character = input[index] ?? "";
|
||||||
|
if (quote) {
|
||||||
|
if (character === quote && input[index - 1] !== "\\") quote = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '"' || character === "'") quote = character;
|
||||||
|
else if (character === "(") depth += 1;
|
||||||
|
else if (character === ")") depth = Math.max(0, depth - 1);
|
||||||
|
else if (
|
||||||
|
depth === 0 &&
|
||||||
|
(character === "\n" || character === ";" || character === ",")
|
||||||
|
) {
|
||||||
|
const part = input.slice(start, index).trim();
|
||||||
|
if (part) output.push(part);
|
||||||
|
start = index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const last = input.slice(start).trim();
|
||||||
|
if (last) output.push(last);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseColourList(input: string): ColourValue[] {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
const whole = tryParseColour(trimmed);
|
||||||
|
if (whole.ok) return [whole.value];
|
||||||
|
|
||||||
|
let values: unknown;
|
||||||
|
try {
|
||||||
|
values = JSON.parse(trimmed) as unknown;
|
||||||
|
} catch {
|
||||||
|
values = undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(values)) {
|
||||||
|
return values.map((value) =>
|
||||||
|
parseColour(typeof value === "string" ? value : (value as ColourValue)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = splitTopLevel(trimmed);
|
||||||
|
if (parts.length <= 1)
|
||||||
|
throw new ColourParseError(whole.error.code, whole.error.message, input);
|
||||||
|
return parts.map(parseColour);
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
export type ColourCoordinates = [number, number, number];
|
||||||
|
|
||||||
|
/** A serialisable colour value. Coordinates use Color.js space conventions. */
|
||||||
|
export interface ColourValue {
|
||||||
|
space: string;
|
||||||
|
coords: ColourCoordinates;
|
||||||
|
alpha: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColourInput = string | ColourValue;
|
||||||
|
|
||||||
|
export type ColourParseErrorCode =
|
||||||
|
| "empty"
|
||||||
|
| "invalid-syntax"
|
||||||
|
| "unsupported-space"
|
||||||
|
| "non-finite"
|
||||||
|
| "out-of-range";
|
||||||
|
|
||||||
|
export interface ColourParseFailure {
|
||||||
|
ok: false;
|
||||||
|
error: {
|
||||||
|
code: ColourParseErrorCode;
|
||||||
|
message: string;
|
||||||
|
input: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColourParseSuccess {
|
||||||
|
ok: true;
|
||||||
|
value: ColourValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColourParseResult = ColourParseSuccess | ColourParseFailure;
|
||||||
|
|
||||||
|
export type ColourFormat =
|
||||||
|
| "hex"
|
||||||
|
| "hex8"
|
||||||
|
| "rgb"
|
||||||
|
| "rgba"
|
||||||
|
| "hsl"
|
||||||
|
| "hsv"
|
||||||
|
| "hwb"
|
||||||
|
| "cmyk"
|
||||||
|
| "lab"
|
||||||
|
| "lch"
|
||||||
|
| "oklab"
|
||||||
|
| "oklch"
|
||||||
|
| "p3"
|
||||||
|
| "rec2020"
|
||||||
|
| "a98rgb"
|
||||||
|
| "prophoto"
|
||||||
|
| "xyz-d50"
|
||||||
|
| "xyz-d65"
|
||||||
|
| "css";
|
||||||
|
|
||||||
|
export interface FormatColourOptions {
|
||||||
|
precision?: number;
|
||||||
|
mapToSrgb?: boolean;
|
||||||
|
includeAlpha?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionRow {
|
||||||
|
id: ColourFormat;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
copyValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SrgbPreview {
|
||||||
|
css: string;
|
||||||
|
hex: string;
|
||||||
|
rgb: ColourCoordinates;
|
||||||
|
alpha: number;
|
||||||
|
wasMapped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GamutMappingMethod = "oklch-chroma" | "clip";
|
||||||
|
|
||||||
|
export interface GamutMappingOptions {
|
||||||
|
target?: string;
|
||||||
|
method?: GamutMappingMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamutSpaceReport {
|
||||||
|
space: string;
|
||||||
|
label: string;
|
||||||
|
inGamut: boolean;
|
||||||
|
mapped: ColourValue;
|
||||||
|
mappedCss: string;
|
||||||
|
deltaEOK: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamutReport {
|
||||||
|
source: ColourValue;
|
||||||
|
spaces: GamutSpaceReport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlendMode =
|
||||||
|
| "normal"
|
||||||
|
| "multiply"
|
||||||
|
| "screen"
|
||||||
|
| "overlay"
|
||||||
|
| "darken"
|
||||||
|
| "lighten"
|
||||||
|
| "color-dodge"
|
||||||
|
| "color-burn"
|
||||||
|
| "hard-light"
|
||||||
|
| "soft-light"
|
||||||
|
| "difference"
|
||||||
|
| "exclusion"
|
||||||
|
| "hue"
|
||||||
|
| "saturation"
|
||||||
|
| "color"
|
||||||
|
| "luminosity";
|
||||||
|
|
||||||
|
export type CompositingSpace = "srgb" | "linear-srgb";
|
||||||
|
|
||||||
|
export interface CompositeLayer {
|
||||||
|
colour: ColourInput;
|
||||||
|
opacity?: number;
|
||||||
|
blendMode?: BlendMode;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositeOptions {
|
||||||
|
space?: CompositingSpace;
|
||||||
|
/** Array order. The default models a paint stack from its background upwards. */
|
||||||
|
order?: "bottom-to-top" | "top-to-bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceOverOptions {
|
||||||
|
space?: CompositingSpace;
|
||||||
|
blendMode?: BlendMode;
|
||||||
|
/** Additional source opacity, multiplied with its own alpha. */
|
||||||
|
opacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositeResult {
|
||||||
|
colour: ColourValue;
|
||||||
|
css: string;
|
||||||
|
hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColourStop {
|
||||||
|
colour: ColourInput;
|
||||||
|
/** Normalised position. Missing positions are distributed like a CSS gradient. */
|
||||||
|
position?: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InterpolationSpace =
|
||||||
|
| "srgb"
|
||||||
|
| "srgb-linear"
|
||||||
|
| "hsl"
|
||||||
|
| "hsv"
|
||||||
|
| "lab"
|
||||||
|
| "lch"
|
||||||
|
| "oklab"
|
||||||
|
| "oklch"
|
||||||
|
| "p3";
|
||||||
|
|
||||||
|
export type HueInterpolation =
|
||||||
|
"shorter" | "longer" | "increasing" | "decreasing" | "raw";
|
||||||
|
export type EasingName =
|
||||||
|
"linear" | "ease-in" | "ease-out" | "ease-in-out" | "smoothstep";
|
||||||
|
|
||||||
|
export interface InterpolationOptions {
|
||||||
|
space?: InterpolationSpace;
|
||||||
|
hue?: HueInterpolation;
|
||||||
|
easing?: EasingName;
|
||||||
|
clamp?: boolean;
|
||||||
|
/** Interpolate premultiplied components to avoid transparent-colour fringes. Defaults to true. */
|
||||||
|
premultiplied?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterpolationStep {
|
||||||
|
position: number;
|
||||||
|
colour: ColourValue;
|
||||||
|
css: string;
|
||||||
|
hex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContrastOptions {
|
||||||
|
/** Canvas below a translucent background. Defaults to white. */
|
||||||
|
canvas?: ColourInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContrastReport {
|
||||||
|
ratio: number;
|
||||||
|
foreground: ColourValue;
|
||||||
|
background: ColourValue;
|
||||||
|
flattenedForeground: ColourValue;
|
||||||
|
flattenedBackground: ColourValue;
|
||||||
|
passes: {
|
||||||
|
aaLarge: boolean;
|
||||||
|
aaaLarge: boolean;
|
||||||
|
aaNormal: boolean;
|
||||||
|
aaaNormal: boolean;
|
||||||
|
nonText: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContrastSuggestion {
|
||||||
|
colour: ColourValue;
|
||||||
|
css: string;
|
||||||
|
hex: string;
|
||||||
|
ratio: number;
|
||||||
|
deltaEOK: number;
|
||||||
|
direction: "lighter" | "darker";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeltaEMethod = "76" | "cmc" | "2000" | "ok" | "itp" | "jz";
|
||||||
|
|
||||||
|
export type ColourVisionDeficiency =
|
||||||
|
"protanopia" | "deuteranopia" | "tritanopia" | "achromatopsia";
|
||||||
|
|
||||||
|
export interface ColourVisionOptions {
|
||||||
|
severity?: number;
|
||||||
|
mapToSrgb?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HarmonyType =
|
||||||
|
| "complementary"
|
||||||
|
| "analogous"
|
||||||
|
| "split-complementary"
|
||||||
|
| "triadic"
|
||||||
|
| "tetradic"
|
||||||
|
| "square";
|
||||||
|
|
||||||
|
export interface PaletteEntry {
|
||||||
|
name: string;
|
||||||
|
colour: ColourValue;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaletteExportFormat =
|
||||||
|
"css" | "scss" | "json" | "tokens" | "tailwind" | "csv";
|
||||||
|
|
||||||
|
export interface PaletteExportOptions {
|
||||||
|
format?: ColourFormat;
|
||||||
|
prefix?: string;
|
||||||
|
pretty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaletteScaleOptions {
|
||||||
|
includeBase?: boolean;
|
||||||
|
includeEndpoint?: boolean;
|
||||||
|
space?: InterpolationSpace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||||
|
console.error("Colour Tools encountered an interface error", error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="fatal-error" role="alert">
|
||||||
|
<h1>Colour Tools could not continue</h1>
|
||||||
|
<p>{this.state.error.message}</p>
|
||||||
|
<button type="button" onClick={() => globalThis.location.reload()}>
|
||||||
|
Reload application
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { toSrgbPreview, tryParseColour } from "../colour";
|
||||||
|
|
||||||
|
interface ColourFieldProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
description?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColourField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
description,
|
||||||
|
compact,
|
||||||
|
}: ColourFieldProps) {
|
||||||
|
const result = tryParseColour(value);
|
||||||
|
const preview = result.ok ? toSrgbPreview(result.value).css : "transparent";
|
||||||
|
const errorId = `${id}-error`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`field colour-field${compact ? " is-compact" : ""}`}>
|
||||||
|
<label className="field-label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<span className="colour-input-shell">
|
||||||
|
<span
|
||||||
|
className="mini-swatch checkerboard"
|
||||||
|
style={{ "--swatch": preview } as React.CSSProperties}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-invalid={!result.ok}
|
||||||
|
aria-describedby={!result.ok ? errorId : undefined}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{description && <span className="field-help">{description}</span>}
|
||||||
|
{!result.ok && (
|
||||||
|
<span id={errorId} className="field-error" role="status">
|
||||||
|
{result.error.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useMemo, useState, type DragEvent } from "react";
|
||||||
|
import {
|
||||||
|
compositeLayers,
|
||||||
|
toSrgbPreview,
|
||||||
|
tryParseColour,
|
||||||
|
type BlendMode,
|
||||||
|
type CompositingSpace,
|
||||||
|
} from "../colour";
|
||||||
|
import { ColourField } from "./ColourField";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
|
||||||
|
interface LayerState {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
colour: string;
|
||||||
|
opacity: number;
|
||||||
|
blendMode: BlendMode;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLEND_MODES: BlendMode[] = [
|
||||||
|
"normal",
|
||||||
|
"multiply",
|
||||||
|
"screen",
|
||||||
|
"overlay",
|
||||||
|
"darken",
|
||||||
|
"lighten",
|
||||||
|
"color-dodge",
|
||||||
|
"color-burn",
|
||||||
|
"hard-light",
|
||||||
|
"soft-light",
|
||||||
|
"difference",
|
||||||
|
"exclusion",
|
||||||
|
"hue",
|
||||||
|
"saturation",
|
||||||
|
"color",
|
||||||
|
"luminosity",
|
||||||
|
];
|
||||||
|
|
||||||
|
let layerSequence = 4;
|
||||||
|
const makeLayer = (
|
||||||
|
colour = "rgb(80 110 255 / 55%)",
|
||||||
|
name?: string,
|
||||||
|
): LayerState => ({
|
||||||
|
id: `layer-${layerSequence++}`,
|
||||||
|
name: name ?? `Layer ${layerSequence - 1}`,
|
||||||
|
colour,
|
||||||
|
opacity: 1,
|
||||||
|
blendMode: "normal",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CompositeLab({
|
||||||
|
onAddColour,
|
||||||
|
}: {
|
||||||
|
onAddColour: (colour: string, name?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [layers, setLayers] = useState<LayerState[]>([
|
||||||
|
{ ...makeLayer("rgb(240 52 89 / 65%)", "Rose"), id: "layer-1" },
|
||||||
|
{ ...makeLayer("rgb(36 128 255 / 55%)", "Blue"), id: "layer-2" },
|
||||||
|
{ ...makeLayer("rgb(250 196 65 / 45%)", "Gold"), id: "layer-3" },
|
||||||
|
]);
|
||||||
|
const [matte, setMatte] = useState("#ffffff");
|
||||||
|
const [flatten, setFlatten] = useState(true);
|
||||||
|
const [space, setSpace] = useState<CompositingSpace>("srgb");
|
||||||
|
const [dragging, setDragging] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const calculation = useMemo(() => {
|
||||||
|
const inputs = layers
|
||||||
|
.filter((layer) => layer.enabled)
|
||||||
|
.map((layer) => ({
|
||||||
|
colour: layer.colour,
|
||||||
|
opacity: layer.opacity,
|
||||||
|
blendMode: layer.blendMode,
|
||||||
|
name: layer.name,
|
||||||
|
}));
|
||||||
|
if (flatten)
|
||||||
|
inputs.push({
|
||||||
|
colour: matte,
|
||||||
|
opacity: 1,
|
||||||
|
blendMode: "normal",
|
||||||
|
name: "Matte",
|
||||||
|
});
|
||||||
|
if (inputs.length === 0)
|
||||||
|
return { error: "Enable at least one layer.", result: null };
|
||||||
|
if (inputs.some((layer) => !tryParseColour(layer.colour).ok)) {
|
||||||
|
return {
|
||||||
|
error: "Finish editing invalid layer colours to recalculate.",
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
result: compositeLayers(inputs, { order: "top-to-bottom", space }),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not composite these layers.",
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [flatten, layers, matte, space]);
|
||||||
|
|
||||||
|
function updateLayer(id: string, update: Partial<LayerState>) {
|
||||||
|
setLayers((current) =>
|
||||||
|
current.map((layer) =>
|
||||||
|
layer.id === id ? { ...layer, ...update } : layer,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveLayer(from: number, to: number) {
|
||||||
|
if (to < 0 || to >= layers.length || from === to) return;
|
||||||
|
setLayers((current) => {
|
||||||
|
const copy = [...current];
|
||||||
|
const [item] = copy.splice(from, 1);
|
||||||
|
if (!item) return current;
|
||||||
|
copy.splice(to, 0, item);
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(event: DragEvent, index: number) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (dragging !== null) moveLayer(dragging, index);
|
||||||
|
setDragging(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = calculation.result
|
||||||
|
? toSrgbPreview(calculation.result.colour)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lab-stack">
|
||||||
|
<header className="lab-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Source-over, from two to many</p>
|
||||||
|
<h2>Composite translucent colours</h2>
|
||||||
|
<p>
|
||||||
|
The first row is the topmost layer. Reorder, blend and flatten an
|
||||||
|
arbitrary RGBA stack to an opaque RGB result.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="field compact-control">
|
||||||
|
<span className="field-label">Working light</span>
|
||||||
|
<select
|
||||||
|
value={space}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSpace(event.target.value as CompositingSpace)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="srgb">Encoded sRGB</option>
|
||||||
|
<option value="linear-srgb">Linear-light sRGB</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="composite-layout">
|
||||||
|
<section className="panel layer-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Layer stack</h3>
|
||||||
|
<p>
|
||||||
|
Top to bottom · alpha in the colour and layer opacity are
|
||||||
|
multiplied.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLayers((current) => [...current, makeLayer()])}
|
||||||
|
>
|
||||||
|
+ Add layer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ol className="layer-list">
|
||||||
|
{layers.map((layer, index) => {
|
||||||
|
const parsed = tryParseColour(layer.colour);
|
||||||
|
const layerPreview = parsed.ok
|
||||||
|
? toSrgbPreview(parsed.value).css
|
||||||
|
: "transparent";
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={layer.id}
|
||||||
|
className={`layer-row${!layer.enabled ? " is-disabled" : ""}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
setDragging(index);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDragging(null)}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={(event) => drop(event, index)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="drag-handle"
|
||||||
|
title="Drag to reorder"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
⋮⋮
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="layer-swatch checkerboard"
|
||||||
|
style={{ "--swatch": layerPreview } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
<div className="layer-fields">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Name</span>
|
||||||
|
<input
|
||||||
|
value={layer.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLayer(layer.id, { name: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<ColourField
|
||||||
|
id={`${layer.id}-colour`}
|
||||||
|
label="Colour"
|
||||||
|
value={layer.colour}
|
||||||
|
onChange={(colour) => updateLayer(layer.id, { colour })}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Opacity · {Math.round(layer.opacity * 100)}%
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={layer.opacity}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLayer(layer.id, {
|
||||||
|
opacity: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Blend</span>
|
||||||
|
<select
|
||||||
|
value={layer.blendMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLayer(layer.id, {
|
||||||
|
blendMode: event.target.value as BlendMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{BLEND_MODES.map((mode) => (
|
||||||
|
<option key={mode}>{mode}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="layer-actions">
|
||||||
|
<label className="toggle" title="Enable layer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={layer.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateLayer(layer.id, {
|
||||||
|
enabled: event.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Enable {layer.name}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveLayer(index, index - 1)}
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
disabled={index === layers.length - 1}
|
||||||
|
onClick={() => moveLayer(index, index + 1)}
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={() =>
|
||||||
|
setLayers((current) => {
|
||||||
|
const copy = [...current];
|
||||||
|
copy.splice(index + 1, 0, {
|
||||||
|
...layer,
|
||||||
|
id: `layer-${layerSequence++}`,
|
||||||
|
name: `${layer.name} copy`,
|
||||||
|
});
|
||||||
|
return copy;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Duplicate layer"
|
||||||
|
>
|
||||||
|
⧉
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
disabled={layers.length === 1}
|
||||||
|
onClick={() =>
|
||||||
|
setLayers((current) =>
|
||||||
|
current.filter((item) => item.id !== layer.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title="Remove layer"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<div className="matte-row">
|
||||||
|
<label className="toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={flatten}
|
||||||
|
onChange={(event) => setFlatten(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Flatten over a matte</strong>
|
||||||
|
<small>Produces an opaque RGB result</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{flatten && (
|
||||||
|
<ColourField
|
||||||
|
id="matte-colour"
|
||||||
|
label="Matte"
|
||||||
|
value={matte}
|
||||||
|
onChange={setMatte}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="panel result-panel">
|
||||||
|
<p className="eyebrow">Result</p>
|
||||||
|
<div className="composite-preview checkerboard">
|
||||||
|
<div style={{ background: preview?.css ?? "transparent" }} />
|
||||||
|
</div>
|
||||||
|
{calculation.error && (
|
||||||
|
<p className="field-error" role="status">
|
||||||
|
{calculation.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{calculation.result && preview && (
|
||||||
|
<>
|
||||||
|
<div className="result-values">
|
||||||
|
<div>
|
||||||
|
<span>CSS</span>
|
||||||
|
<code>{calculation.result.css}</code>
|
||||||
|
<CopyButton value={calculation.result.css} compact />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Hex</span>
|
||||||
|
<code>{calculation.result.hex}</code>
|
||||||
|
<CopyButton value={calculation.result.hex} compact />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Preview</span>
|
||||||
|
<code>{preview.css}</code>
|
||||||
|
<CopyButton value={preview.css} compact />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => onAddColour(preview.css, "Composite")}
|
||||||
|
>
|
||||||
|
Add result to palette
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<details>
|
||||||
|
<summary>How the result is calculated</summary>
|
||||||
|
<p>
|
||||||
|
Porter–Duff source-over is applied from the bottom layer upward.
|
||||||
|
Encoded sRGB matches common quick calculators; linear-light sRGB
|
||||||
|
is physically closer for emitted light and often looks brighter.
|
||||||
|
Blend modes are applied before alpha compositing.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
contrastReport,
|
||||||
|
deltaE,
|
||||||
|
gamutReport,
|
||||||
|
nearestPassingColour,
|
||||||
|
simulateColourVision,
|
||||||
|
toSrgbPreview,
|
||||||
|
tryParseColour,
|
||||||
|
type ColourVisionDeficiency,
|
||||||
|
type DeltaEMethod,
|
||||||
|
} from "../colour";
|
||||||
|
import { ColourField } from "./ColourField";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
|
||||||
|
const DEFICIENCIES: Array<{ id: ColourVisionDeficiency; label: string }> = [
|
||||||
|
{ id: "protanopia", label: "Protanopia" },
|
||||||
|
{ id: "deuteranopia", label: "Deuteranopia" },
|
||||||
|
{ id: "tritanopia", label: "Tritanopia" },
|
||||||
|
{ id: "achromatopsia", label: "Achromatopsia" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ContrastLab({
|
||||||
|
onAddColour,
|
||||||
|
}: {
|
||||||
|
onAddColour: (colour: string, name?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [foreground, setForeground] = useState("#2d2859");
|
||||||
|
const [background, setBackground] = useState("#f8f5ee");
|
||||||
|
const [canvas, setCanvas] = useState("#ffffff");
|
||||||
|
const [target, setTarget] = useState(4.5);
|
||||||
|
const [method, setMethod] = useState<DeltaEMethod>("2000");
|
||||||
|
const [severity, setSeverity] = useState(1);
|
||||||
|
|
||||||
|
const analysis = useMemo(() => {
|
||||||
|
if (
|
||||||
|
![foreground, background, canvas].every(
|
||||||
|
(value) => tryParseColour(value).ok,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
try {
|
||||||
|
const report = contrastReport(foreground, background, { canvas });
|
||||||
|
const suggestion = nearestPassingColour(foreground, background, target, {
|
||||||
|
canvas,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
report,
|
||||||
|
suggestion,
|
||||||
|
difference: deltaE(foreground, background, method),
|
||||||
|
foregroundPreview: toSrgbPreview(report.flattenedForeground),
|
||||||
|
backgroundPreview: toSrgbPreview(report.flattenedBackground),
|
||||||
|
gamut: gamutReport(foreground),
|
||||||
|
simulations: DEFICIENCIES.map((item) => ({
|
||||||
|
...item,
|
||||||
|
foreground: toSrgbPreview(
|
||||||
|
simulateColourVision(foreground, item.id, { severity }),
|
||||||
|
).css,
|
||||||
|
background: toSrgbPreview(
|
||||||
|
simulateColourVision(background, item.id, { severity }),
|
||||||
|
).css,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [background, canvas, foreground, method, severity, target]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lab-stack">
|
||||||
|
<header className="lab-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Readable, distinguishable, in gamut</p>
|
||||||
|
<h2>Contrast & compare</h2>
|
||||||
|
<p>
|
||||||
|
Flatten transparency over a real canvas, test WCAG 2.x ratios and
|
||||||
|
compare perceptual distance. Simulations are useful approximations,
|
||||||
|
not a diagnosis or substitute for user testing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setForeground(background);
|
||||||
|
setBackground(foreground);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⇄ Swap colours
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="contrast-layout">
|
||||||
|
<section className="panel contrast-controls">
|
||||||
|
<ColourField
|
||||||
|
id="contrast-foreground"
|
||||||
|
label="Foreground"
|
||||||
|
value={foreground}
|
||||||
|
onChange={setForeground}
|
||||||
|
/>
|
||||||
|
<ColourField
|
||||||
|
id="contrast-background"
|
||||||
|
label="Background"
|
||||||
|
value={background}
|
||||||
|
onChange={setBackground}
|
||||||
|
/>
|
||||||
|
<ColourField
|
||||||
|
id="contrast-canvas"
|
||||||
|
label="Canvas below transparency"
|
||||||
|
value={canvas}
|
||||||
|
onChange={setCanvas}
|
||||||
|
description="Used only where foreground or background has alpha."
|
||||||
|
/>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Suggested foreground target</span>
|
||||||
|
<select
|
||||||
|
value={target}
|
||||||
|
onChange={(event) => setTarget(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
<option value="3">3:1 · UI / large text</option>
|
||||||
|
<option value="4.5">4.5:1 · AA normal text</option>
|
||||||
|
<option value="7">7:1 · AAA normal text</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{analysis?.suggestion && (
|
||||||
|
<div className="suggestion-card">
|
||||||
|
<span
|
||||||
|
className="mini-swatch checkerboard"
|
||||||
|
style={
|
||||||
|
{ "--swatch": analysis.suggestion.css } as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong>Nearest passing foreground</strong>
|
||||||
|
<code>{analysis.suggestion.hex}</code>
|
||||||
|
<small>
|
||||||
|
{analysis.suggestion.ratio.toFixed(2)}:1 · ΔE OK{" "}
|
||||||
|
{analysis.suggestion.deltaEOK.toFixed(3)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<CopyButton
|
||||||
|
value={analysis.suggestion.css}
|
||||||
|
compact
|
||||||
|
label="Copy suggestion"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setForeground(analysis.suggestion?.css ?? foreground)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{analysis && !analysis.suggestion && (
|
||||||
|
<p className="stale-notice" role="status">
|
||||||
|
No foreground at the current alpha can reach this target. Increase
|
||||||
|
its opacity or change the background.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel contrast-results">
|
||||||
|
{analysis ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="contrast-sample"
|
||||||
|
style={{
|
||||||
|
color: analysis.foregroundPreview.css,
|
||||||
|
background: analysis.backgroundPreview.css,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>Aa</strong>
|
||||||
|
<span>Colour should carry meaning beautifully—not alone.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
color: analysis.foregroundPreview.css,
|
||||||
|
borderColor: analysis.foregroundPreview.css,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Example control
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ratio-heading">
|
||||||
|
<div>
|
||||||
|
<strong>{analysis.report.ratio.toFixed(2)}</strong>
|
||||||
|
<span>: 1 contrast</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`status-chip ${analysis.report.passes.aaNormal ? "pass" : "fail"}`}
|
||||||
|
>
|
||||||
|
{analysis.report.passes.aaNormal
|
||||||
|
? "AA normal passes"
|
||||||
|
: "AA normal fails"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pass-grid">
|
||||||
|
<Pass
|
||||||
|
label="UI & graphics"
|
||||||
|
threshold="3:1"
|
||||||
|
pass={analysis.report.passes.nonText}
|
||||||
|
/>
|
||||||
|
<Pass
|
||||||
|
label="AA large text"
|
||||||
|
threshold="3:1"
|
||||||
|
pass={analysis.report.passes.aaLarge}
|
||||||
|
/>
|
||||||
|
<Pass
|
||||||
|
label="AA normal text"
|
||||||
|
threshold="4.5:1"
|
||||||
|
pass={analysis.report.passes.aaNormal}
|
||||||
|
/>
|
||||||
|
<Pass
|
||||||
|
label="AAA large text"
|
||||||
|
threshold="4.5:1"
|
||||||
|
pass={analysis.report.passes.aaaLarge}
|
||||||
|
/>
|
||||||
|
<Pass
|
||||||
|
label="AAA normal text"
|
||||||
|
threshold="7:1"
|
||||||
|
pass={analysis.report.passes.aaaNormal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="field-help">
|
||||||
|
WCAG 2.x defines large text as at least 18pt, or 14pt bold.
|
||||||
|
Check states, focus, disabled controls and non-colour cues
|
||||||
|
separately.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="field-error">
|
||||||
|
Enter three valid colours to run the checks.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analysis && (
|
||||||
|
<div className="comparison-grid">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Perceptual difference</h3>
|
||||||
|
<p>Different formulas answer different historical needs.</p>
|
||||||
|
</div>
|
||||||
|
<label className="field compact-control">
|
||||||
|
<span className="field-label">ΔE method</span>
|
||||||
|
<select
|
||||||
|
value={method}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMethod(event.target.value as DeltaEMethod)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="2000">CIEDE2000</option>
|
||||||
|
<option value="ok">OKLab ΔE</option>
|
||||||
|
<option value="76">CIE76</option>
|
||||||
|
<option value="cmc">CMC</option>
|
||||||
|
<option value="itp">ΔE ITP</option>
|
||||||
|
<option value="jz">ΔE Jz</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="delta-result">
|
||||||
|
<strong>{analysis.difference.toFixed(3)}</strong>
|
||||||
|
<span>ΔE {method}</span>
|
||||||
|
</div>
|
||||||
|
<p className="field-help">
|
||||||
|
There is no universal “just noticeable” number across formulas,
|
||||||
|
media, surrounds and observers. Use this as a comparison, not a
|
||||||
|
pass/fail guarantee.
|
||||||
|
</p>
|
||||||
|
<div className="gamut-summary">
|
||||||
|
<h3>Foreground gamut</h3>
|
||||||
|
<div className="status-chip-row">
|
||||||
|
{analysis.gamut.spaces.map((space) => (
|
||||||
|
<span
|
||||||
|
key={space.space}
|
||||||
|
className={`status-chip ${space.inGamut ? "pass" : "warn"}`}
|
||||||
|
>
|
||||||
|
{space.label} {space.inGamut ? "✓" : "↘"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Colour-vision preview</h3>
|
||||||
|
<p>Full-severity matrix approximation in linear sRGB.</p>
|
||||||
|
</div>
|
||||||
|
<label className="field compact-control">
|
||||||
|
<span className="field-label">
|
||||||
|
Severity · {Math.round(severity * 100)}%
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={severity}
|
||||||
|
onChange={(event) => setSeverity(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="simulation-grid">
|
||||||
|
{analysis.simulations.map((simulation) => (
|
||||||
|
<article key={simulation.id}>
|
||||||
|
<div style={{ background: simulation.background }}>
|
||||||
|
<span style={{ color: simulation.foreground }}>Aa</span>
|
||||||
|
</div>
|
||||||
|
<strong>{simulation.label}</strong>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis && (
|
||||||
|
<section className="panel quick-matrix">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Quick contrast matrix</h3>
|
||||||
|
<p>Current colours against each other, black and white.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="matrix-table" role="table">
|
||||||
|
{[foreground, background, "#000000", "#ffffff"].map((fg, row) => (
|
||||||
|
<div role="row" key={`${fg}-${row}`}>
|
||||||
|
{[foreground, background, "#000000", "#ffffff"].map(
|
||||||
|
(bg, column) => {
|
||||||
|
const valid = fg !== bg;
|
||||||
|
let ratio = 1;
|
||||||
|
if (valid) ratio = contrastReport(fg, bg, { canvas }).ratio;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
key={`${bg}-${column}`}
|
||||||
|
style={{
|
||||||
|
color: toSrgbPreview(fg).css,
|
||||||
|
background: toSrgbPreview(bg).css,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{ratio.toFixed(1)}</strong>
|
||||||
|
<small>
|
||||||
|
{valid
|
||||||
|
? ratio >= 4.5
|
||||||
|
? "AA"
|
||||||
|
: ratio >= 3
|
||||||
|
? "Large"
|
||||||
|
: "Fail"
|
||||||
|
: "Same"}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onAddColour(analysis.foregroundPreview.css, "Foreground")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add foreground
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onAddColour(analysis.backgroundPreview.css, "Background")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add background
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pass({
|
||||||
|
label,
|
||||||
|
threshold,
|
||||||
|
pass,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
threshold: string;
|
||||||
|
pass: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={pass ? "passes" : "fails"}>
|
||||||
|
<span aria-hidden="true">{pass ? "✓" : "×"}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<small>{threshold}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
conversionRows,
|
||||||
|
gamutReport,
|
||||||
|
toSrgbPreview,
|
||||||
|
tryParseColour,
|
||||||
|
type ColourFormat,
|
||||||
|
type ColourValue,
|
||||||
|
} from "../colour";
|
||||||
|
import { ColourField } from "./ColourField";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
|
||||||
|
const EXAMPLES = [
|
||||||
|
"#f8f5ee",
|
||||||
|
"rgb(21 96 189 / 72%)",
|
||||||
|
"oklch(68% 0.18 264 / .9)",
|
||||||
|
"color(display-p3 0.91 0.24 0.42)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONVERSION_FORMATS: readonly ColourFormat[] = [
|
||||||
|
"hex",
|
||||||
|
"hex8",
|
||||||
|
"rgb",
|
||||||
|
"rgba",
|
||||||
|
"hsl",
|
||||||
|
"hsv",
|
||||||
|
"hwb",
|
||||||
|
"cmyk",
|
||||||
|
"lab",
|
||||||
|
"lch",
|
||||||
|
"oklab",
|
||||||
|
"oklch",
|
||||||
|
"p3",
|
||||||
|
"rec2020",
|
||||||
|
"a98rgb",
|
||||||
|
"prophoto",
|
||||||
|
"xyz-d50",
|
||||||
|
"xyz-d65",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ConverterLab({
|
||||||
|
onAddColour,
|
||||||
|
}: {
|
||||||
|
onAddColour: (colour: string, name?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [input, setInput] = useState("#f8f5ee");
|
||||||
|
const [precision, setPrecision] = useState(4);
|
||||||
|
const parsed = useMemo(() => tryParseColour(input), [input]);
|
||||||
|
const [lastValid, setLastValid] = useState<ColourValue | null>(() =>
|
||||||
|
parsed.ok ? parsed.value : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
function updateInput(value: string) {
|
||||||
|
setInput(value);
|
||||||
|
const result = tryParseColour(value);
|
||||||
|
if (result.ok) setLastValid(result.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
lastValid
|
||||||
|
? conversionRows(lastValid, CONVERSION_FORMATS).map((row) => ({
|
||||||
|
...row,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
[lastValid],
|
||||||
|
);
|
||||||
|
const preview = lastValid ? toSrgbPreview(lastValid) : null;
|
||||||
|
const gamut = lastValid ? gamutReport(lastValid) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lab-stack">
|
||||||
|
<header className="lab-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">One colour, many representations</p>
|
||||||
|
<h2>Convert colours</h2>
|
||||||
|
<p>
|
||||||
|
Parse CSS colour syntax without discarding wide-gamut coordinates.
|
||||||
|
The large preview is mapped only for this display.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="field compact-control">
|
||||||
|
<span className="field-label">Precision</span>
|
||||||
|
<select
|
||||||
|
value={precision}
|
||||||
|
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
{[2, 3, 4, 5, 6].map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value} decimals
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="two-column-layout converter-layout">
|
||||||
|
<section className="panel input-panel">
|
||||||
|
<ColourField
|
||||||
|
id="convert-source"
|
||||||
|
label="Colour"
|
||||||
|
value={input}
|
||||||
|
onChange={updateInput}
|
||||||
|
description="Hex, named colours, rgb(), hsl(), hwb(), lab(), lch(), oklab(), oklch() and color()."
|
||||||
|
/>
|
||||||
|
<div className="example-row" aria-label="Example colours">
|
||||||
|
{EXAMPLES.map((example) => (
|
||||||
|
<button
|
||||||
|
key={example}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateInput(example)}
|
||||||
|
>
|
||||||
|
{example}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="hero-preview checkerboard">
|
||||||
|
<div style={{ background: preview.css }} />
|
||||||
|
<span>
|
||||||
|
{preview.wasMapped
|
||||||
|
? "Mapped to sRGB for preview"
|
||||||
|
: "In sRGB gamut"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => onAddColour(preview.css, preview.hex)}
|
||||||
|
>
|
||||||
|
Add to palette
|
||||||
|
</button>
|
||||||
|
<CopyButton value={preview.hex} label={`Copy ${preview.hex}`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gamut && (
|
||||||
|
<div className="gamut-summary">
|
||||||
|
<h3>Gamut</h3>
|
||||||
|
<div className="status-chip-row">
|
||||||
|
{gamut.spaces.map((space) => (
|
||||||
|
<span
|
||||||
|
key={space.space}
|
||||||
|
className={`status-chip ${space.inGamut ? "pass" : "warn"}`}
|
||||||
|
title={
|
||||||
|
space.inGamut
|
||||||
|
? `Inside ${space.label}`
|
||||||
|
: `Outside ${space.label}; mapped ΔE OK ${space.deltaEOK.toFixed(3)}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{space.label} {space.inGamut ? "✓" : "↘"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="field-help">
|
||||||
|
↘ means the source is outside that gamut. Values below remain
|
||||||
|
unmodified; each mapped value is labelled and copyable.
|
||||||
|
</p>
|
||||||
|
{gamut.spaces.some((space) => !space.inGamut) && (
|
||||||
|
<div className="mapped-gamut-list">
|
||||||
|
{gamut.spaces
|
||||||
|
.filter((space) => !space.inGamut)
|
||||||
|
.map((space) => (
|
||||||
|
<div key={space.space}>
|
||||||
|
<span>{space.label} mapped</span>
|
||||||
|
<code>{space.mappedCss}</code>
|
||||||
|
<CopyButton
|
||||||
|
value={space.mappedCss}
|
||||||
|
label={`Copy mapped ${space.label}`}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel conversion-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Representations</h3>
|
||||||
|
<p>Copy-ready CSS and developer formats.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="conversion-list"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy={!parsed.ok}
|
||||||
|
>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const rendered = row.value.replace(/(-?\d+\.\d{2,})/g, (number) =>
|
||||||
|
Number(number)
|
||||||
|
.toFixed(precision)
|
||||||
|
.replace(/0+$/, "")
|
||||||
|
.replace(/\.$/, ""),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="conversion-row" key={row.id}>
|
||||||
|
<span>{row.label}</span>
|
||||||
|
<code>{rendered}</code>
|
||||||
|
<CopyButton
|
||||||
|
value={rendered}
|
||||||
|
label={`Copy ${row.label}`}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!parsed.ok && lastValid && (
|
||||||
|
<p className="stale-notice" role="status">
|
||||||
|
Keeping the last valid conversion while you finish editing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BatchConverter onAddColour={onAddColour} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatchConverter({
|
||||||
|
onAddColour,
|
||||||
|
}: {
|
||||||
|
onAddColour: (colour: string, name?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [source, setSource] = useState(
|
||||||
|
["#264653", "rgb(42 157 143)", "oklch(78% 0.15 85)", "rebeccapurple"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const results = useMemo(
|
||||||
|
() =>
|
||||||
|
source
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line, index) => ({ line: line.trim(), index }))
|
||||||
|
.filter(({ line }) => line.length > 0)
|
||||||
|
.map(({ line, index }) => ({
|
||||||
|
line,
|
||||||
|
index,
|
||||||
|
result: tryParseColour(line),
|
||||||
|
})),
|
||||||
|
[source],
|
||||||
|
);
|
||||||
|
const valid = results.filter((item) => item.result.ok);
|
||||||
|
const exportText = valid
|
||||||
|
.map((item) => {
|
||||||
|
if (!item.result.ok) return "";
|
||||||
|
const preview = toSrgbPreview(item.result.value);
|
||||||
|
return `${item.line}\t${preview.hex}\t${preview.css}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel batch-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Batch</p>
|
||||||
|
<h3>Convert a colour list</h3>
|
||||||
|
<p>
|
||||||
|
One CSS colour per line. Invalid lines are isolated, not discarded
|
||||||
|
silently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<CopyButton value={exportText} label="Copy table" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={valid.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
valid.forEach((item) => {
|
||||||
|
if (item.result.ok) {
|
||||||
|
const preview = toSrgbPreview(item.result.value);
|
||||||
|
onAddColour(preview.css, preview.hex);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add valid to palette
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="batch-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Source colours</span>
|
||||||
|
<textarea
|
||||||
|
value={source}
|
||||||
|
onChange={(event) => setSource(event.target.value)}
|
||||||
|
rows={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="batch-results"
|
||||||
|
aria-label={`${valid.length} valid colours`}
|
||||||
|
>
|
||||||
|
{results.map((item) => {
|
||||||
|
const preview = item.result.ok
|
||||||
|
? toSrgbPreview(item.result.value)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="batch-result" key={`${item.index}-${item.line}`}>
|
||||||
|
<span
|
||||||
|
className="mini-swatch checkerboard"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--swatch": preview?.css ?? "transparent",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<code>{item.line}</code>
|
||||||
|
{preview ? <strong>{preview.hex}</strong> : <em>Invalid</em>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function CopyButton({
|
||||||
|
value,
|
||||||
|
label = "Copy",
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!copied) return;
|
||||||
|
const timer = globalThis.setTimeout(() => setCopied(false), 1400);
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.append(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
textarea.remove();
|
||||||
|
setCopied(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={compact ? "icon-button copy-button" : "copy-button"}
|
||||||
|
onClick={() => void copy()}
|
||||||
|
title={compact ? label : undefined}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 8V5.8A1.8 1.8 0 0 1 9.8 4h8.4A1.8 1.8 0 0 1 20 5.8v8.4a1.8 1.8 0 0 1-1.8 1.8H16M5.8 8h8.4A1.8 1.8 0 0 1 16 9.8v8.4a1.8 1.8 0 0 1-1.8 1.8H5.8A1.8 1.8 0 0 1 4 18.2V9.8A1.8 1.8 0 0 1 5.8 8Z" />
|
||||||
|
</svg>
|
||||||
|
{!compact && <span>{copied ? "Copied" : label}</span>}
|
||||||
|
{compact && <span className="sr-only">{copied ? "Copied" : label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface HelpDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpDialog({ open, onClose }: HelpDialogProps) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const dialog = dialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
if (open && !dialog.open) dialog.showModal();
|
||||||
|
if (!open && dialog.open) dialog.close();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className="colour-dialog"
|
||||||
|
aria-labelledby="help-title"
|
||||||
|
onClose={onClose}
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<article className="dialog-card">
|
||||||
|
<header className="dialog-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Local colour lab</p>
|
||||||
|
<h2 id="help-title">Colour Tools help</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="icon-button" onClick={onClose}>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span className="sr-only">Close help</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="help-grid">
|
||||||
|
<section>
|
||||||
|
<h3>Convert</h3>
|
||||||
|
<p>
|
||||||
|
Enter CSS colours in modern or legacy syntax. Inspect coordinates
|
||||||
|
in sRGB, HSL, HWB, Lab, LCH, OKLab, OKLCH and wide-gamut spaces,
|
||||||
|
then copy a deliberate mapped or unmapped representation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Composite</h3>
|
||||||
|
<p>
|
||||||
|
Stack any number of translucent colours over a matte. Drag layers
|
||||||
|
to reorder them, disable or duplicate them, and compare encoded
|
||||||
|
sRGB with linear-light source-over compositing.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Steps</h3>
|
||||||
|
<p>
|
||||||
|
Interpolate two or more stops in perceptual, RGB, cylindrical or
|
||||||
|
wide-gamut spaces. Control hue travel and alpha, then export
|
||||||
|
swatches, CSS custom properties or JSON.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Pick & Image</h3>
|
||||||
|
<p>
|
||||||
|
Use the large picker, the browser eyedropper when available, or a
|
||||||
|
local image. Image bytes remain in memory and are never uploaded.
|
||||||
|
Click a pixel or extract a deterministic palette.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Contrast</h3>
|
||||||
|
<p>
|
||||||
|
Check WCAG 2.x contrast, compare perceptual distance, inspect
|
||||||
|
gamut, preview approximate colour-vision simulations and find a
|
||||||
|
nearby passing foreground.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Palette</h3>
|
||||||
|
<p>
|
||||||
|
Build harmonies, tints, shades and tones. Keep a small
|
||||||
|
browser-local palette and export tokens as CSS, SCSS or JSON.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>Privacy & precision</h3>
|
||||||
|
<p>
|
||||||
|
There is no server processing or telemetry. Out-of-gamut values
|
||||||
|
are retained for inspection; clipping or gamut mapping is always
|
||||||
|
labelled. Browser rendering can still vary by display and colour
|
||||||
|
management.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type DragEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
DEFAULT_IMAGE_LIMITS,
|
||||||
|
decodeLocalImage,
|
||||||
|
extractPaletteSafely,
|
||||||
|
getBoundedSampleRegion,
|
||||||
|
mapClientPointToPixel,
|
||||||
|
samplePixels,
|
||||||
|
validateImageDimensions,
|
||||||
|
validateImageFile,
|
||||||
|
type ExtractedPaletteColour,
|
||||||
|
type ImageLimits,
|
||||||
|
type LoadedImageDetails,
|
||||||
|
type PixelPoint,
|
||||||
|
type PixelSample,
|
||||||
|
type SamplingMethod,
|
||||||
|
} from "../palette";
|
||||||
|
|
||||||
|
const EXTRACTION_PIXEL_LIMIT = 160_000;
|
||||||
|
|
||||||
|
export interface ImagePickerLabProps {
|
||||||
|
readonly onAddColour?: (colour: string) => void;
|
||||||
|
readonly onPaletteExtracted?: (
|
||||||
|
palette: readonly ExtractedPaletteColour[],
|
||||||
|
) => void;
|
||||||
|
readonly imageLimits?: ImageLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeExtractionBuffer = (source: HTMLCanvasElement): ImageData => {
|
||||||
|
const scale = Math.min(
|
||||||
|
1,
|
||||||
|
Math.sqrt(EXTRACTION_PIXEL_LIMIT / (source.width * source.height)),
|
||||||
|
);
|
||||||
|
const width = Math.max(1, Math.round(source.width * scale));
|
||||||
|
const height = Math.max(1, Math.round(source.height * scale));
|
||||||
|
if (width === source.width && height === source.height) {
|
||||||
|
const context = source.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) throw new Error("Canvas access is unavailable.");
|
||||||
|
return context.getImageData(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reduced = document.createElement("canvas");
|
||||||
|
reduced.width = width;
|
||||||
|
reduced.height = height;
|
||||||
|
const context = reduced.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) throw new Error("Canvas access is unavailable.");
|
||||||
|
context.imageSmoothingEnabled = true;
|
||||||
|
context.imageSmoothingQuality = "high";
|
||||||
|
context.drawImage(source, 0, 0, width, height);
|
||||||
|
return context.getImageData(0, 0, width, height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusForImage = (details: LoadedImageDetails): string =>
|
||||||
|
`${details.name} loaded: ${details.width.toLocaleString()} by ${details.height.toLocaleString()} pixels.`;
|
||||||
|
|
||||||
|
const boundedInputNumber = (
|
||||||
|
value: number,
|
||||||
|
current: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
): number =>
|
||||||
|
Number.isFinite(value)
|
||||||
|
? Math.min(maximum, Math.max(minimum, Math.round(value)))
|
||||||
|
: current;
|
||||||
|
|
||||||
|
export function ImagePickerLab({
|
||||||
|
onAddColour,
|
||||||
|
onPaletteExtracted,
|
||||||
|
imageLimits = DEFAULT_IMAGE_LIMITS,
|
||||||
|
}: ImagePickerLabProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const loadGenerationRef = useRef(0);
|
||||||
|
const loadAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const extractionAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const [image, setImage] = useState<LoadedImageDetails | null>(null);
|
||||||
|
const [status, setStatus] = useState("Choose a local image to begin.");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isExtracting, setIsExtracting] = useState(false);
|
||||||
|
const [radius, setRadius] = useState(0);
|
||||||
|
const [method, setMethod] = useState<SamplingMethod>("average");
|
||||||
|
const [point, setPoint] = useState<PixelPoint>({ x: 0, y: 0 });
|
||||||
|
const [sample, setSample] = useState<PixelSample | null>(null);
|
||||||
|
const [paletteCount, setPaletteCount] = useState(6);
|
||||||
|
const [palette, setPalette] = useState<ExtractedPaletteColour[]>([]);
|
||||||
|
|
||||||
|
const stopExtraction = useCallback(() => {
|
||||||
|
extractionAbortRef.current?.abort();
|
||||||
|
extractionAbortRef.current = null;
|
||||||
|
setIsExtracting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearCanvas = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearImage = useCallback(() => {
|
||||||
|
loadGenerationRef.current += 1;
|
||||||
|
loadAbortRef.current?.abort();
|
||||||
|
loadAbortRef.current = null;
|
||||||
|
stopExtraction();
|
||||||
|
clearCanvas();
|
||||||
|
setImage(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
setSample(null);
|
||||||
|
setPalette([]);
|
||||||
|
onPaletteExtracted?.([]);
|
||||||
|
setError(null);
|
||||||
|
setStatus("Image removed. Choose another local image when ready.");
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}, [clearCanvas, onPaletteExtracted, stopExtraction]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
loadGenerationRef.current += 1;
|
||||||
|
loadAbortRef.current?.abort();
|
||||||
|
extractionAbortRef.current?.abort();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
const validation = validateImageFile(file, imageLimits);
|
||||||
|
if (!validation.valid) {
|
||||||
|
setError(validation.message ?? "This image cannot be loaded.");
|
||||||
|
setStatus("Image not loaded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generation = ++loadGenerationRef.current;
|
||||||
|
loadAbortRef.current?.abort();
|
||||||
|
const loadController = new AbortController();
|
||||||
|
loadAbortRef.current = loadController;
|
||||||
|
stopExtraction();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(`Decoding ${file.name} locally…`);
|
||||||
|
try {
|
||||||
|
const decoded = await decodeLocalImage(file, loadController.signal);
|
||||||
|
if (generation !== loadGenerationRef.current) {
|
||||||
|
decoded.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dimensions = validateImageDimensions(
|
||||||
|
decoded.width,
|
||||||
|
decoded.height,
|
||||||
|
imageLimits,
|
||||||
|
);
|
||||||
|
if (!dimensions.valid) {
|
||||||
|
decoded.dispose();
|
||||||
|
setError(
|
||||||
|
dimensions.message ?? "This image is too large to process safely.",
|
||||||
|
);
|
||||||
|
setStatus("Image not loaded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas?.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!canvas || !context) {
|
||||||
|
decoded.dispose();
|
||||||
|
throw new Error("Canvas access is unavailable in this browser.");
|
||||||
|
}
|
||||||
|
canvas.width = decoded.width;
|
||||||
|
canvas.height = decoded.height;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.drawImage(decoded.source, 0, 0);
|
||||||
|
decoded.dispose();
|
||||||
|
|
||||||
|
const details: LoadedImageDetails = {
|
||||||
|
name: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
bytes: file.size,
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
};
|
||||||
|
setImage(details);
|
||||||
|
setPoint({ x: 0, y: 0 });
|
||||||
|
setSample(null);
|
||||||
|
setPalette([]);
|
||||||
|
onPaletteExtracted?.([]);
|
||||||
|
setStatus(statusForImage(details));
|
||||||
|
} catch (caught) {
|
||||||
|
if (generation !== loadGenerationRef.current) return;
|
||||||
|
const message =
|
||||||
|
caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: "The image could not be decoded.";
|
||||||
|
setError(message);
|
||||||
|
setStatus("Image not loaded.");
|
||||||
|
} finally {
|
||||||
|
if (loadAbortRef.current === loadController)
|
||||||
|
loadAbortRef.current = null;
|
||||||
|
if (generation === loadGenerationRef.current) setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[imageLimits, onPaletteExtracted, stopExtraction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptFiles = useCallback(
|
||||||
|
(files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
if (files.length > 1) {
|
||||||
|
setError("Choose one image at a time.");
|
||||||
|
setStatus("Image not loaded.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = files.item(0);
|
||||||
|
if (file) void loadFile(file);
|
||||||
|
},
|
||||||
|
[loadFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const takeSample = useCallback(
|
||||||
|
(nextPoint: PixelPoint) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !image) return;
|
||||||
|
const region = getBoundedSampleRegion(
|
||||||
|
nextPoint.x,
|
||||||
|
nextPoint.y,
|
||||||
|
radius,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
);
|
||||||
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!region || !context) return;
|
||||||
|
try {
|
||||||
|
const pixels = context.getImageData(
|
||||||
|
region.left,
|
||||||
|
region.top,
|
||||||
|
region.width,
|
||||||
|
region.height,
|
||||||
|
);
|
||||||
|
const result = samplePixels(
|
||||||
|
pixels,
|
||||||
|
region.centerX,
|
||||||
|
region.centerY,
|
||||||
|
radius,
|
||||||
|
method,
|
||||||
|
);
|
||||||
|
if (!result) return;
|
||||||
|
const positioned = {
|
||||||
|
...result,
|
||||||
|
x: region.left + result.x,
|
||||||
|
y: region.top + result.y,
|
||||||
|
};
|
||||||
|
setPoint({ x: positioned.x, y: positioned.y });
|
||||||
|
setSample(positioned);
|
||||||
|
setError(null);
|
||||||
|
setStatus(
|
||||||
|
`${positioned.hex} sampled at x ${positioned.x}, y ${positioned.y}.`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setError("The browser could not read pixels from this image.");
|
||||||
|
setStatus("Sampling failed.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[image, method, radius],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCanvasClick = (event: MouseEvent<HTMLCanvasElement>): void => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const nextPoint = mapClientPointToPixel(
|
||||||
|
event.clientX,
|
||||||
|
event.clientY,
|
||||||
|
canvas.getBoundingClientRect(),
|
||||||
|
canvas.width,
|
||||||
|
canvas.height,
|
||||||
|
);
|
||||||
|
if (nextPoint) takeSample(nextPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCanvasKeyDown = (
|
||||||
|
event: KeyboardEvent<HTMLCanvasElement>,
|
||||||
|
): void => {
|
||||||
|
if (!image) return;
|
||||||
|
const movement: Record<string, PixelPoint> = {
|
||||||
|
ArrowLeft: { x: -1, y: 0 },
|
||||||
|
ArrowRight: { x: 1, y: 0 },
|
||||||
|
ArrowUp: { x: 0, y: -1 },
|
||||||
|
ArrowDown: { x: 0, y: 1 },
|
||||||
|
};
|
||||||
|
const delta = movement[event.key];
|
||||||
|
if (delta) {
|
||||||
|
event.preventDefault();
|
||||||
|
takeSample({
|
||||||
|
x: Math.min(image.width - 1, Math.max(0, point.x + delta.x)),
|
||||||
|
y: Math.min(image.height - 1, Math.max(0, point.y + delta.y)),
|
||||||
|
});
|
||||||
|
} else if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
takeSample(point);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extract = async (): Promise<void> => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !image || isExtracting) return;
|
||||||
|
stopExtraction();
|
||||||
|
const controller = new AbortController();
|
||||||
|
extractionAbortRef.current = controller;
|
||||||
|
setIsExtracting(true);
|
||||||
|
setError(null);
|
||||||
|
setStatus(`Extracting ${paletteCount} colours locally…`);
|
||||||
|
try {
|
||||||
|
const pixels = makeExtractionBuffer(canvas);
|
||||||
|
const result = await extractPaletteSafely(
|
||||||
|
{ data: pixels.data, width: pixels.width, height: pixels.height },
|
||||||
|
{ count: paletteCount },
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setPalette(result);
|
||||||
|
onPaletteExtracted?.(result);
|
||||||
|
setStatus(
|
||||||
|
result.length === 0
|
||||||
|
? "No visible colours were found in the image."
|
||||||
|
: `${result.length} palette colours extracted locally.`,
|
||||||
|
);
|
||||||
|
} catch (caught) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(
|
||||||
|
caught instanceof Error ? caught.message : "Palette extraction failed.",
|
||||||
|
);
|
||||||
|
setStatus("Palette extraction failed.");
|
||||||
|
} finally {
|
||||||
|
if (extractionAbortRef.current === controller) {
|
||||||
|
extractionAbortRef.current = null;
|
||||||
|
setIsExtracting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="image-picker-lab"
|
||||||
|
aria-labelledby="image-picker-heading"
|
||||||
|
aria-busy={isLoading || isExtracting}
|
||||||
|
>
|
||||||
|
<header className="image-picker-lab__header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Local image colour lab</p>
|
||||||
|
<h2 id="image-picker-heading">Pick and extract colours</h2>
|
||||||
|
<p>
|
||||||
|
Sample exact pixels or build a perceptual palette. Image bytes never
|
||||||
|
leave this browser tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{image ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--quiet"
|
||||||
|
onClick={clearImage}
|
||||||
|
>
|
||||||
|
Remove image
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`image-picker-lab__dropzone${isDragging ? " is-dragging" : ""}`}
|
||||||
|
onDragEnter={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
}}
|
||||||
|
onDragLeave={(event) => {
|
||||||
|
const related = event.relatedTarget;
|
||||||
|
if (
|
||||||
|
!(related instanceof Node) ||
|
||||||
|
!event.currentTarget.contains(related)
|
||||||
|
)
|
||||||
|
setIsDragging(false);
|
||||||
|
}}
|
||||||
|
onDrop={(event: DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
acceptFiles(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>Drop one image here</strong>
|
||||||
|
<span>
|
||||||
|
PNG, JPEG, WebP, GIF, or AVIF · up to{" "}
|
||||||
|
{Math.round(imageLimits.maxBytes / 1024 / 1024)} MiB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Loading…"
|
||||||
|
: image
|
||||||
|
? "Choose another image"
|
||||||
|
: "Choose image"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="visually-hidden"
|
||||||
|
type="file"
|
||||||
|
accept={imageLimits.acceptedMimeTypes.join(",")}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
acceptFiles(event.currentTarget.files);
|
||||||
|
event.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`image-picker-lab__status${error ? " is-error" : ""}`}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{error ?? status}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="image-picker-lab__workspace" hidden={!image}>
|
||||||
|
<div className="image-picker-lab__picker">
|
||||||
|
{image ? (
|
||||||
|
<div className="image-picker-lab__image-meta">
|
||||||
|
<strong>{image.name}</strong>
|
||||||
|
<span>
|
||||||
|
{image.width.toLocaleString()} × {image.height.toLocaleString()}{" "}
|
||||||
|
px · {formatBytes(image.bytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="image-picker-lab__canvas-wrap">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="image-picker-lab__canvas"
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
onKeyDown={handleCanvasKeyDown}
|
||||||
|
tabIndex={0}
|
||||||
|
role="img"
|
||||||
|
aria-label={
|
||||||
|
image
|
||||||
|
? `Colour picker for ${image.name}. Click a pixel, use the arrow keys, or enter coordinates below.`
|
||||||
|
: "Local image colour picker"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Colour picker image canvas.
|
||||||
|
</canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{image ? (
|
||||||
|
<aside
|
||||||
|
className="image-picker-lab__controls"
|
||||||
|
aria-label="Image colour controls"
|
||||||
|
>
|
||||||
|
<fieldset className="control-group">
|
||||||
|
<legend>Pixel sampling</legend>
|
||||||
|
<div className="field-grid field-grid--compact">
|
||||||
|
<label>
|
||||||
|
X coordinate
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={image.width - 1}
|
||||||
|
value={point.x}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.currentTarget.valueAsNumber;
|
||||||
|
setPoint((current) => ({
|
||||||
|
...current,
|
||||||
|
x: boundedInputNumber(
|
||||||
|
value,
|
||||||
|
current.x,
|
||||||
|
0,
|
||||||
|
image.width - 1,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Y coordinate
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={image.height - 1}
|
||||||
|
value={point.y}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.currentTarget.valueAsNumber;
|
||||||
|
setPoint((current) => ({
|
||||||
|
...current,
|
||||||
|
y: boundedInputNumber(
|
||||||
|
value,
|
||||||
|
current.y,
|
||||||
|
0,
|
||||||
|
image.height - 1,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Radius
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={24}
|
||||||
|
value={radius}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRadius((current) =>
|
||||||
|
boundedInputNumber(
|
||||||
|
event.currentTarget.valueAsNumber,
|
||||||
|
current,
|
||||||
|
0,
|
||||||
|
24,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Method
|
||||||
|
<select
|
||||||
|
value={method}
|
||||||
|
onChange={(event) =>
|
||||||
|
setMethod(event.currentTarget.value as SamplingMethod)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="average">Alpha-weighted average</option>
|
||||||
|
<option value="median">Median</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--wide"
|
||||||
|
onClick={() =>
|
||||||
|
takeSample({
|
||||||
|
x: Math.min(
|
||||||
|
image.width - 1,
|
||||||
|
Math.max(0, Math.round(point.x)),
|
||||||
|
),
|
||||||
|
y: Math.min(
|
||||||
|
image.height - 1,
|
||||||
|
Math.max(0, Math.round(point.y)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sample coordinate
|
||||||
|
</button>
|
||||||
|
{sample ? (
|
||||||
|
<div
|
||||||
|
className="image-picker-lab__sample"
|
||||||
|
aria-label={`Sampled colour ${sample.hex}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="colour-swatch colour-swatch--large"
|
||||||
|
style={{ backgroundColor: sample.css }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<strong>{sample.hex}</strong>
|
||||||
|
<span>
|
||||||
|
x {sample.x}, y {sample.y} · {sample.pixelCount} pixel
|
||||||
|
{sample.pixelCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{onAddColour ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--quiet"
|
||||||
|
onClick={() => onAddColour(sample.hex)}
|
||||||
|
>
|
||||||
|
Add colour
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="control-group">
|
||||||
|
<legend>Perceptual palette</legend>
|
||||||
|
<label>
|
||||||
|
Number of colours
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={2}
|
||||||
|
max={12}
|
||||||
|
value={paletteCount}
|
||||||
|
onChange={(event) =>
|
||||||
|
setPaletteCount(Number(event.currentTarget.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<output>{paletteCount}</output>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--wide"
|
||||||
|
disabled={isExtracting}
|
||||||
|
onClick={() => void extract()}
|
||||||
|
>
|
||||||
|
{isExtracting ? "Extracting…" : "Extract palette"}
|
||||||
|
</button>
|
||||||
|
{palette.length > 0 ? (
|
||||||
|
<ul
|
||||||
|
className="image-picker-lab__palette"
|
||||||
|
aria-label="Extracted image palette"
|
||||||
|
>
|
||||||
|
{palette.map((colour, index) => (
|
||||||
|
<li key={`${colour.hex}-${index}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={onAddColour ? `Add ${colour.hex}` : colour.hex}
|
||||||
|
onClick={() => onAddColour?.(colour.hex)}
|
||||||
|
disabled={!onAddColour}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="colour-swatch"
|
||||||
|
style={{ backgroundColor: colour.css }}
|
||||||
|
/>
|
||||||
|
<strong>{colour.hex}</strong>
|
||||||
|
<span>
|
||||||
|
{colour.coverage > 0 && colour.coverage < 0.01
|
||||||
|
? "<1%"
|
||||||
|
: `${Math.round(colour.coverage * 100)}%`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImagePickerLab;
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
colourHarmony,
|
||||||
|
exportPalette,
|
||||||
|
parsePaletteList,
|
||||||
|
shades,
|
||||||
|
tints,
|
||||||
|
tones,
|
||||||
|
toSrgbPreview,
|
||||||
|
tryParseColour,
|
||||||
|
type HarmonyType,
|
||||||
|
type PaletteEntry,
|
||||||
|
type PaletteExportFormat,
|
||||||
|
} from "../colour";
|
||||||
|
import type { useSavedPalette } from "../hooks/useSavedPalette";
|
||||||
|
import { ColourField } from "./ColourField";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
|
||||||
|
type PaletteStore = ReturnType<typeof useSavedPalette>;
|
||||||
|
type GeneratorMode = HarmonyType | "tints" | "shades" | "tones";
|
||||||
|
|
||||||
|
const MODES: Array<{ id: GeneratorMode; label: string }> = [
|
||||||
|
{ id: "complementary", label: "Complementary" },
|
||||||
|
{ id: "analogous", label: "Analogous" },
|
||||||
|
{ id: "split-complementary", label: "Split complement" },
|
||||||
|
{ id: "triadic", label: "Triadic" },
|
||||||
|
{ id: "tetradic", label: "Tetradic" },
|
||||||
|
{ id: "square", label: "Square" },
|
||||||
|
{ id: "tints", label: "Tints" },
|
||||||
|
{ id: "shades", label: "Shades" },
|
||||||
|
{ id: "tones", label: "Tones" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PaletteLab({ palette }: { palette: PaletteStore }) {
|
||||||
|
const [base, setBase] = useState("oklch(64% 0.18 276)");
|
||||||
|
const [mode, setMode] = useState<GeneratorMode>("analogous");
|
||||||
|
const [count, setCount] = useState(6);
|
||||||
|
const [format, setFormat] = useState<PaletteExportFormat>("css");
|
||||||
|
const [prefix, setPrefix] = useState("brand");
|
||||||
|
const [importText, setImportText] = useState("");
|
||||||
|
const [importMessage, setImportMessage] = useState("");
|
||||||
|
|
||||||
|
const generated = useMemo(() => {
|
||||||
|
const parsed = tryParseColour(base);
|
||||||
|
if (!parsed.ok) return [];
|
||||||
|
if (mode === "tints")
|
||||||
|
return tints(parsed.value, count - 1, {
|
||||||
|
includeBase: true,
|
||||||
|
includeEndpoint: true,
|
||||||
|
});
|
||||||
|
if (mode === "shades")
|
||||||
|
return shades(parsed.value, count - 1, {
|
||||||
|
includeBase: true,
|
||||||
|
includeEndpoint: true,
|
||||||
|
});
|
||||||
|
if (mode === "tones")
|
||||||
|
return tones(parsed.value, count - 1, {
|
||||||
|
includeBase: true,
|
||||||
|
includeEndpoint: true,
|
||||||
|
});
|
||||||
|
return colourHarmony(parsed.value, mode);
|
||||||
|
}, [base, count, mode]);
|
||||||
|
|
||||||
|
const entries = useMemo<PaletteEntry[]>(
|
||||||
|
() =>
|
||||||
|
palette.colours.flatMap((entry) => {
|
||||||
|
const result = tryParseColour(entry.value);
|
||||||
|
return result.ok
|
||||||
|
? [{ name: entry.name, colour: result.value, source: entry.value }]
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
[palette.colours],
|
||||||
|
);
|
||||||
|
const exported =
|
||||||
|
entries.length > 0
|
||||||
|
? exportPalette(entries, format, { prefix, pretty: true })
|
||||||
|
: "";
|
||||||
|
|
||||||
|
function importPalette() {
|
||||||
|
try {
|
||||||
|
const imported = parsePaletteList(importText);
|
||||||
|
imported.forEach((entry) => {
|
||||||
|
const preview = toSrgbPreview(entry.colour);
|
||||||
|
palette.addColour(preview.css, entry.name);
|
||||||
|
});
|
||||||
|
setImportMessage(
|
||||||
|
`Added ${imported.length} colour${imported.length === 1 ? "" : "s"}.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setImportMessage(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not parse this palette.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function download() {
|
||||||
|
if (!exported) return;
|
||||||
|
const extension =
|
||||||
|
format === "scss"
|
||||||
|
? "scss"
|
||||||
|
: format === "css"
|
||||||
|
? "css"
|
||||||
|
: format === "csv"
|
||||||
|
? "csv"
|
||||||
|
: format === "tailwind"
|
||||||
|
? "js"
|
||||||
|
: "json";
|
||||||
|
const blob = new Blob([exported], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `colour-palette.${extension}`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSavedColour(index: number, direction: -1 | 1) {
|
||||||
|
const target = index + direction;
|
||||||
|
if (target < 0 || target >= palette.colours.length) return;
|
||||||
|
const current = palette.colours[index];
|
||||||
|
const other = palette.colours[target];
|
||||||
|
if (!current || !other) return;
|
||||||
|
const copy = [...palette.colours];
|
||||||
|
copy[index] = other;
|
||||||
|
copy[target] = current;
|
||||||
|
palette.replaceColours(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lab-stack">
|
||||||
|
<header className="lab-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">From one colour to a working system</p>
|
||||||
|
<h2>Build a palette</h2>
|
||||||
|
<p>
|
||||||
|
Generate perceptual relationships, curate local swatches and export
|
||||||
|
predictable developer tokens. Nothing leaves browser storage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="count-badge">{palette.colours.length} saved</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="palette-generator-layout">
|
||||||
|
<section className="panel generator-controls">
|
||||||
|
<ColourField
|
||||||
|
id="palette-base"
|
||||||
|
label="Base colour"
|
||||||
|
value={base}
|
||||||
|
onChange={setBase}
|
||||||
|
/>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Relationship</span>
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
onChange={(event) => setMode(event.target.value as GeneratorMode)}
|
||||||
|
>
|
||||||
|
{MODES.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{["tints", "shades", "tones"].includes(mode) && (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Scale size · {count}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="3"
|
||||||
|
max="12"
|
||||||
|
value={count}
|
||||||
|
onChange={(event) => setCount(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
disabled={generated.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
generated.forEach((colour, index) => {
|
||||||
|
const preview = toSrgbPreview(colour);
|
||||||
|
palette.addColour(
|
||||||
|
preview.css,
|
||||||
|
`${MODES.find((item) => item.id === mode)?.label ?? mode} ${index + 1}`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add generated palette
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section className="panel generated-palette">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Generated colours</h3>
|
||||||
|
<p>Mapped to sRGB only for browser-safe swatches.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="generated-swatches">
|
||||||
|
{generated.map((colour, index) => {
|
||||||
|
const preview = toSrgbPreview(colour);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${preview.hex}-${index}`}
|
||||||
|
type="button"
|
||||||
|
style={{ "--swatch": preview.css } as React.CSSProperties}
|
||||||
|
className="checkerboard"
|
||||||
|
onClick={() =>
|
||||||
|
palette.addColour(preview.css, `${mode} ${index + 1}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{index + 1}</span>
|
||||||
|
<code>{preview.hex}</code>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="panel saved-palette">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Browser-local</p>
|
||||||
|
<h3>Saved palette</h3>
|
||||||
|
<p>Names and colours persist on this device until cleared.</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
palette.addColour(
|
||||||
|
"#7b61ff",
|
||||||
|
`Colour ${palette.colours.length + 1}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Add colour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
disabled={palette.colours.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Clear the saved palette on this device?"))
|
||||||
|
palette.clearColours();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{palette.colours.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No saved colours yet</strong>
|
||||||
|
<p>
|
||||||
|
Add a conversion, composite, step, picked pixel or generated
|
||||||
|
harmony.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="saved-colour-list">
|
||||||
|
{palette.colours.map((entry, index) => {
|
||||||
|
const result = tryParseColour(entry.value);
|
||||||
|
const preview = result.ok ? toSrgbPreview(result.value) : null;
|
||||||
|
return (
|
||||||
|
<li key={entry.id}>
|
||||||
|
<span
|
||||||
|
className="palette-swatch checkerboard"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--swatch": preview?.css ?? "transparent",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Token name</span>
|
||||||
|
<input
|
||||||
|
value={entry.name}
|
||||||
|
onChange={(event) =>
|
||||||
|
palette.updateColour(entry.id, {
|
||||||
|
name: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<ColourField
|
||||||
|
id={`${entry.id}-saved-colour`}
|
||||||
|
label="Value"
|
||||||
|
value={entry.value}
|
||||||
|
onChange={(value) =>
|
||||||
|
palette.updateColour(entry.id, { value })
|
||||||
|
}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<div className="layer-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveSavedColour(index, -1)}
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
disabled={index === palette.colours.length - 1}
|
||||||
|
onClick={() => moveSavedColour(index, 1)}
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
onClick={() => palette.removeColour(entry.id)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="palette-io-layout">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Import palette text</h3>
|
||||||
|
<p>
|
||||||
|
JSON, one colour per line, or <code>name: colour</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Palette data</span>
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={importText}
|
||||||
|
onChange={(event) => {
|
||||||
|
setImportText(event.target.value);
|
||||||
|
setImportMessage("");
|
||||||
|
}}
|
||||||
|
placeholder={"primary: #6252d3\naccent: oklch(76% 0.17 87)"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!importText.trim()}
|
||||||
|
onClick={importPalette}
|
||||||
|
>
|
||||||
|
Import colours
|
||||||
|
</button>
|
||||||
|
{importMessage && (
|
||||||
|
<span className="field-help" role="status">
|
||||||
|
{importMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Export tokens</h3>
|
||||||
|
<p>Duplicate names are made deterministic.</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<CopyButton value={exported} label="Copy" />
|
||||||
|
<button type="button" disabled={!exported} onClick={download}>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-controls">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Format</span>
|
||||||
|
<select
|
||||||
|
value={format}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormat(event.target.value as PaletteExportFormat)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="css">CSS custom properties</option>
|
||||||
|
<option value="scss">SCSS variables</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="tokens">Design Tokens JSON</option>
|
||||||
|
<option value="tailwind">JavaScript object</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Prefix</span>
|
||||||
|
<input
|
||||||
|
value={prefix}
|
||||||
|
onChange={(event) => setPrefix(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<pre className="export-preview">
|
||||||
|
<code>
|
||||||
|
{exported || "Add valid saved colours to export tokens."}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import {
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react";
|
||||||
|
import { conversionRows, toSrgbPreview, tryParseColour } from "../colour";
|
||||||
|
import { ColourField } from "./ColourField";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
|
||||||
|
interface EyeDropperResult {
|
||||||
|
sRGBHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EyeDropperConstructor {
|
||||||
|
new (): { open: () => Promise<EyeDropperResult> };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
EyeDropper?: EyeDropperConstructor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min = 0, max = 1) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsvToRgb(hue: number, saturation: number, value: number) {
|
||||||
|
const chroma = value * saturation;
|
||||||
|
const section = (((hue % 360) + 360) % 360) / 60;
|
||||||
|
const x = chroma * (1 - Math.abs((section % 2) - 1));
|
||||||
|
const [r1, g1, b1] =
|
||||||
|
section < 1
|
||||||
|
? [chroma, x, 0]
|
||||||
|
: section < 2
|
||||||
|
? [x, chroma, 0]
|
||||||
|
: section < 3
|
||||||
|
? [0, chroma, x]
|
||||||
|
: section < 4
|
||||||
|
? [0, x, chroma]
|
||||||
|
: section < 5
|
||||||
|
? [x, 0, chroma]
|
||||||
|
: [chroma, 0, x];
|
||||||
|
const m = value - chroma;
|
||||||
|
return [r1 + m, g1 + m, b1 + m] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHsv(r: number, g: number, b: number) {
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const delta = max - min;
|
||||||
|
let hue = 0;
|
||||||
|
if (delta > 0) {
|
||||||
|
if (max === r) hue = 60 * (((g - b) / delta) % 6);
|
||||||
|
else if (max === g) hue = 60 * ((b - r) / delta + 2);
|
||||||
|
else hue = 60 * ((r - g) / delta + 4);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hue: hue < 0 ? hue + 360 : hue,
|
||||||
|
saturation: max === 0 ? 0 : delta / max,
|
||||||
|
value: max,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerCss(
|
||||||
|
hue: number,
|
||||||
|
saturation: number,
|
||||||
|
brightness: number,
|
||||||
|
alpha: number,
|
||||||
|
) {
|
||||||
|
const [r, g, b] = hsvToRgb(hue, saturation, brightness);
|
||||||
|
return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)} / ${Math.round(alpha * 1000) / 1000})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PickerLab({
|
||||||
|
onAddColour,
|
||||||
|
}: {
|
||||||
|
onAddColour: (colour: string, name?: string) => void;
|
||||||
|
}) {
|
||||||
|
const [hue, setHue] = useState(264);
|
||||||
|
const [saturation, setSaturation] = useState(0.62);
|
||||||
|
const [brightness, setBrightness] = useState(0.82);
|
||||||
|
const [alpha, setAlpha] = useState(1);
|
||||||
|
const [text, setText] = useState("#834fd1");
|
||||||
|
const [recent, setRecent] = useState<string[]>([]);
|
||||||
|
const planeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const colour = useMemo(
|
||||||
|
() => pickerCss(hue, saturation, brightness, alpha),
|
||||||
|
[alpha, brightness, hue, saturation],
|
||||||
|
);
|
||||||
|
const parsed = tryParseColour(colour);
|
||||||
|
const preview = parsed.ok ? toSrgbPreview(parsed.value) : null;
|
||||||
|
const rows = parsed.ok ? conversionRows(parsed.value) : [];
|
||||||
|
|
||||||
|
function remember(value: string) {
|
||||||
|
setRecent((current) =>
|
||||||
|
[value, ...current.filter((item) => item !== value)].slice(0, 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColour(value: string) {
|
||||||
|
setText(value);
|
||||||
|
const result = tryParseColour(value);
|
||||||
|
if (!result.ok) return;
|
||||||
|
const srgb = toSrgbPreview(result.value);
|
||||||
|
const hsv = rgbToHsv(...srgb.rgb);
|
||||||
|
setHue(hsv.hue);
|
||||||
|
setSaturation(hsv.saturation);
|
||||||
|
setBrightness(hsv.value);
|
||||||
|
setAlpha(srgb.alpha);
|
||||||
|
remember(srgb.hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisual(
|
||||||
|
update: Partial<{
|
||||||
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
brightness: number;
|
||||||
|
alpha: number;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const nextHue = update.hue ?? hue;
|
||||||
|
const nextSaturation = update.saturation ?? saturation;
|
||||||
|
const nextBrightness = update.brightness ?? brightness;
|
||||||
|
const nextAlpha = update.alpha ?? alpha;
|
||||||
|
setHue(nextHue);
|
||||||
|
setSaturation(nextSaturation);
|
||||||
|
setBrightness(nextBrightness);
|
||||||
|
setAlpha(nextAlpha);
|
||||||
|
const next = tryParseColour(
|
||||||
|
pickerCss(nextHue, nextSaturation, nextBrightness, nextAlpha),
|
||||||
|
);
|
||||||
|
if (next.ok) setText(toSrgbPreview(next.value).hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFromPlane(event: ReactPointerEvent<HTMLDivElement>) {
|
||||||
|
const plane = planeRef.current;
|
||||||
|
if (!plane) return;
|
||||||
|
if (event.type === "pointerdown") plane.setPointerCapture(event.pointerId);
|
||||||
|
const bounds = plane.getBoundingClientRect();
|
||||||
|
updateVisual({
|
||||||
|
saturation: clamp((event.clientX - bounds.left) / bounds.width),
|
||||||
|
brightness: 1 - clamp((event.clientY - bounds.top) / bounds.height),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEyeDropper() {
|
||||||
|
if (!window.EyeDropper) return;
|
||||||
|
try {
|
||||||
|
const result = await new window.EyeDropper().open();
|
||||||
|
applyColour(result.sRGBHex);
|
||||||
|
} catch {
|
||||||
|
// Escape and browser cancellation are normal outcomes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudge(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||||
|
const step = event.shiftKey ? 0.05 : 0.01;
|
||||||
|
if (
|
||||||
|
!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.key === "ArrowLeft")
|
||||||
|
updateVisual({ saturation: clamp(saturation - step) });
|
||||||
|
if (event.key === "ArrowRight")
|
||||||
|
updateVisual({ saturation: clamp(saturation + step) });
|
||||||
|
if (event.key === "ArrowDown")
|
||||||
|
updateVisual({ brightness: clamp(brightness - step) });
|
||||||
|
if (event.key === "ArrowUp")
|
||||||
|
updateVisual({ brightness: clamp(brightness + step) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lab-stack">
|
||||||
|
<header className="lab-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">A picker with room to breathe</p>
|
||||||
|
<h2>Pick a colour</h2>
|
||||||
|
<p>
|
||||||
|
Drag across the large saturation/value field, enter an exact CSS
|
||||||
|
colour, or sample any visible pixel with a supported browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
{window.EyeDropper && (
|
||||||
|
<button type="button" onClick={() => void openEyeDropper()}>
|
||||||
|
⌖ Pick from screen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label className="native-colour-button">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={preview?.hex.slice(0, 7) ?? "#7656d6"}
|
||||||
|
onChange={(event) => applyColour(event.target.value)}
|
||||||
|
/>
|
||||||
|
Native picker
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="picker-layout">
|
||||||
|
<section className="panel picker-panel">
|
||||||
|
<div
|
||||||
|
ref={planeRef}
|
||||||
|
className="picker-plane"
|
||||||
|
style={
|
||||||
|
{ "--picker-hue": `hsl(${hue} 100% 50%)` } as React.CSSProperties
|
||||||
|
}
|
||||||
|
onPointerDown={selectFromPlane}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
if (event.buttons === 1) selectFromPlane(event);
|
||||||
|
}}
|
||||||
|
onKeyDown={nudge}
|
||||||
|
tabIndex={0}
|
||||||
|
role="application"
|
||||||
|
aria-label={`Saturation ${Math.round(saturation * 100)} percent, brightness ${Math.round(brightness * 100)} percent. Use arrow keys to adjust.`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="picker-cursor"
|
||||||
|
style={{
|
||||||
|
left: `${saturation * 100}%`,
|
||||||
|
top: `${(1 - brightness) * 100}%`,
|
||||||
|
background: colour,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="field hue-slider">
|
||||||
|
<span className="field-label">Hue · {Math.round(hue)}°</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
step="1"
|
||||||
|
value={hue}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateVisual({ hue: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field alpha-slider">
|
||||||
|
<span className="field-label">
|
||||||
|
Alpha · {Math.round(alpha * 100)}%
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="alpha-track checkerboard"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--opaque": `rgb(${hsvToRgb(hue, saturation, brightness)
|
||||||
|
.map((channel) => Math.round(channel * 255))
|
||||||
|
.join(" ")})`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={alpha}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateVisual({ alpha: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="picker-numbers">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">H</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
value={Math.round(hue)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateVisual({
|
||||||
|
hue: clamp(Number(event.target.value), 0, 360),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">S %</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={Math.round(saturation * 100)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateVisual({
|
||||||
|
saturation: clamp(Number(event.target.value) / 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">V %</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={Math.round(brightness * 100)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateVisual({
|
||||||
|
brightness: clamp(Number(event.target.value) / 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">A %</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={Math.round(alpha * 100)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateVisual({
|
||||||
|
alpha: clamp(Number(event.target.value) / 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="panel picker-output">
|
||||||
|
<div className="picked-preview checkerboard">
|
||||||
|
<div style={{ background: colour }} />
|
||||||
|
</div>
|
||||||
|
<ColourField
|
||||||
|
id="picker-exact"
|
||||||
|
label="Exact CSS colour"
|
||||||
|
value={text}
|
||||||
|
onChange={applyColour}
|
||||||
|
description="Entering a valid colour updates the visual picker."
|
||||||
|
/>
|
||||||
|
<div className="result-values compact-values">
|
||||||
|
{rows.slice(0, 8).map((row) => (
|
||||||
|
<div key={row.id}>
|
||||||
|
<span>{row.label}</span>
|
||||||
|
<code>{row.value}</code>
|
||||||
|
<CopyButton
|
||||||
|
value={row.copyValue}
|
||||||
|
label={`Copy ${row.label}`}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!preview) return;
|
||||||
|
remember(preview.hex);
|
||||||
|
onAddColour(preview.css, preview.hex);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add to palette
|
||||||
|
</button>
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<div className="recent-colours">
|
||||||
|
<h3>Recent picks</h3>
|
||||||
|
<div>
|
||||||
|
{recent.map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className="checkerboard"
|
||||||
|
style={{ "--swatch": value } as React.CSSProperties}
|
||||||
|
onClick={() => applyColour(value)}
|
||||||
|
title={value}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Select {value}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
interpolateStops,
|
||||||
|
tryParseColour,
|
||||||
|
type ColourStop,
|
||||||
|
type EasingName,
|
||||||
|
type HueInterpolation,
|
||||||
|
type InterpolationSpace,
|
||||||
|
type InterpolationStep,
|
||||||
|
} from "../colour";
|
||||||
|
import { ColourField } from "./ColourField";
|
||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
|
||||||
|
interface StopState {
|
||||||
|
id: string;
|
||||||
|
colour: string;
|
||||||
|
position: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepsConfig {
|
||||||
|
stops: StopState[];
|
||||||
|
count: number;
|
||||||
|
space: InterpolationSpace;
|
||||||
|
hue: HueInterpolation;
|
||||||
|
easing: EasingName;
|
||||||
|
premultiplied: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateSteps(config: StepsConfig): {
|
||||||
|
steps: InterpolationStep[] | null;
|
||||||
|
error: string | null;
|
||||||
|
} {
|
||||||
|
if (config.stops.some((stop) => !tryParseColour(stop.colour).ok)) {
|
||||||
|
return {
|
||||||
|
steps: null,
|
||||||
|
error: "Finish editing invalid stops to recalculate.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let previousPosition = -Infinity;
|
||||||
|
for (const stop of config.stops) {
|
||||||
|
if (!stop.position.trim()) continue;
|
||||||
|
const position = Number(stop.position);
|
||||||
|
if (!Number.isFinite(position) || position < 0 || position > 100) {
|
||||||
|
return {
|
||||||
|
steps: null,
|
||||||
|
error: "Stop positions must be numbers from 0 to 100 percent.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (position < previousPosition) {
|
||||||
|
return {
|
||||||
|
steps: null,
|
||||||
|
error: "Stop positions must stay in ascending order.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
previousPosition = position;
|
||||||
|
}
|
||||||
|
const colourStops: ColourStop[] = config.stops.map((stop, index) => {
|
||||||
|
const numeric = Number(stop.position);
|
||||||
|
return {
|
||||||
|
colour: stop.colour,
|
||||||
|
name: `Stop ${index + 1}`,
|
||||||
|
position:
|
||||||
|
stop.position.trim() && Number.isFinite(numeric)
|
||||||
|
? Math.min(1, Math.max(0, numeric / 100))
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
steps: interpolateStops(colourStops, config.count, {
|
||||||
|
space: config.space,
|
||||||
|
hue: config.hue,
|
||||||
|
easing: config.easing,
|
||||||
|
premultiplied: config.premultiplied,
|
||||||
|
}),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
steps: null,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not interpolate these stops.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopSequence = 4;
|
||||||
|
|
||||||
|
export function StepsLab({
|
||||||
|
onAddColour,
|
||||||
|
}: {
|
||||||
|
onAddColour: (colour: string, name?: string) => void;
|
||||||
|
}) {
|
||||||
|
const initialConfig: StepsConfig = {
|
||||||
|
stops: [
|
||||||
|
{ id: "stop-1", colour: "#ef476f", position: "0" },
|
||||||
|
{ id: "stop-2", colour: "oklch(82% 0.16 92)", position: "50" },
|
||||||
|
{ id: "stop-3", colour: "#118ab2", position: "100" },
|
||||||
|
],
|
||||||
|
count: 9,
|
||||||
|
space: "oklch",
|
||||||
|
hue: "shorter",
|
||||||
|
easing: "linear",
|
||||||
|
premultiplied: true,
|
||||||
|
};
|
||||||
|
const [config, setConfig] = useState<StepsConfig>(initialConfig);
|
||||||
|
const [lastGood, setLastGood] = useState<InterpolationStep[]>(
|
||||||
|
() => calculateSteps(initialConfig).steps ?? [],
|
||||||
|
);
|
||||||
|
const { stops, count, space, hue, easing, premultiplied } = config;
|
||||||
|
|
||||||
|
const calculation = useMemo(() => {
|
||||||
|
return calculateSteps(config);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const shownSteps = calculation.steps ?? lastGood;
|
||||||
|
const cssVariables = shownSteps
|
||||||
|
.map(
|
||||||
|
(step, index) =>
|
||||||
|
` --colour-${String(index + 1).padStart(2, "0")}: ${step.css};`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
const cssExport = `:root {\n${cssVariables}\n}`;
|
||||||
|
const jsonExport = JSON.stringify(
|
||||||
|
shownSteps.map((step, index) => ({
|
||||||
|
name: `colour-${index + 1}`,
|
||||||
|
value: step.css,
|
||||||
|
position: step.position,
|
||||||
|
})),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const gradient = `linear-gradient(90deg, ${shownSteps
|
||||||
|
.map((step) => `${step.css} ${(step.position * 100).toFixed(2)}%`)
|
||||||
|
.join(", ")})`;
|
||||||
|
|
||||||
|
function updateStop(id: string, update: Partial<StopState>) {
|
||||||
|
commit({
|
||||||
|
stops: stops.map((stop) =>
|
||||||
|
stop.id === id ? { ...stop, ...update } : stop,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(update: Partial<StepsConfig>) {
|
||||||
|
const next = { ...config, ...update };
|
||||||
|
setConfig(next);
|
||||||
|
const nextCalculation = calculateSteps(next);
|
||||||
|
if (nextCalculation.steps) setLastGood(nextCalculation.steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lab-stack">
|
||||||
|
<header className="lab-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Perceptual ramps and exact endpoints</p>
|
||||||
|
<h2>Step between colours</h2>
|
||||||
|
<p>
|
||||||
|
Use two or more stops. OKLCH keeps perceived lightness and
|
||||||
|
colourfulness steadier; RGB modes help reproduce legacy output.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="steps-layout">
|
||||||
|
<section className="panel stop-editor">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Stops</h3>
|
||||||
|
<p>Positions are percentages along the ramp.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
commit({
|
||||||
|
stops: [
|
||||||
|
...stops,
|
||||||
|
{
|
||||||
|
id: `stop-${stopSequence++}`,
|
||||||
|
colour: "#7b61ff",
|
||||||
|
position: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Add stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ol className="stop-list">
|
||||||
|
{stops.map((stop, index) => (
|
||||||
|
<li key={stop.id} className="stop-row">
|
||||||
|
<span className="stop-index">{index + 1}</span>
|
||||||
|
<ColourField
|
||||||
|
id={`${stop.id}-value`}
|
||||||
|
label="Colour"
|
||||||
|
value={stop.colour}
|
||||||
|
onChange={(colour) => updateStop(stop.id, { colour })}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
<label className="field position-field">
|
||||||
|
<span className="field-label">Position</span>
|
||||||
|
<span className="suffix-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.1"
|
||||||
|
value={stop.position}
|
||||||
|
placeholder="auto"
|
||||||
|
onChange={(event) =>
|
||||||
|
updateStop(stop.id, { position: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>%</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button danger"
|
||||||
|
disabled={stops.length <= 2}
|
||||||
|
onClick={() =>
|
||||||
|
commit({
|
||||||
|
stops: stops.filter((item) => item.id !== stop.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Remove stop"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="control-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Swatches · {count}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="32"
|
||||||
|
value={count}
|
||||||
|
onChange={(event) =>
|
||||||
|
commit({ count: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Interpolation space</span>
|
||||||
|
<select
|
||||||
|
value={space}
|
||||||
|
onChange={(event) =>
|
||||||
|
commit({ space: event.target.value as InterpolationSpace })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<optgroup label="Perceptual">
|
||||||
|
<option value="oklch">OKLCH</option>
|
||||||
|
<option value="oklab">OKLab</option>
|
||||||
|
<option value="lch">LCH</option>
|
||||||
|
<option value="lab">Lab</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="RGB">
|
||||||
|
<option value="srgb">sRGB encoded</option>
|
||||||
|
<option value="srgb-linear">sRGB linear light</option>
|
||||||
|
<option value="p3">Display P3</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Cylindrical">
|
||||||
|
<option value="hsl">HSL</option>
|
||||||
|
<option value="hsv">HSV</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Hue route</span>
|
||||||
|
<select
|
||||||
|
value={hue}
|
||||||
|
onChange={(event) =>
|
||||||
|
commit({ hue: event.target.value as HueInterpolation })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="shorter">Shorter</option>
|
||||||
|
<option value="longer">Longer</option>
|
||||||
|
<option value="increasing">Increasing</option>
|
||||||
|
<option value="decreasing">Decreasing</option>
|
||||||
|
<option value="raw">Raw</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Easing</span>
|
||||||
|
<select
|
||||||
|
value={easing}
|
||||||
|
onChange={(event) =>
|
||||||
|
commit({ easing: event.target.value as EasingName })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="linear">Linear</option>
|
||||||
|
<option value="ease-in">Ease in</option>
|
||||||
|
<option value="ease-out">Ease out</option>
|
||||||
|
<option value="ease-in-out">Ease in/out</option>
|
||||||
|
<option value="smoothstep">Smoothstep</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="toggle-label alpha-mode-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={premultiplied}
|
||||||
|
onChange={(event) =>
|
||||||
|
commit({ premultiplied: event.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Premultiply alpha</strong>
|
||||||
|
<small>Avoid transparent-colour fringes</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel ramp-panel">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>Generated ramp</h3>
|
||||||
|
<p>
|
||||||
|
{space.toUpperCase()} · {hue} hue · {easing} ·{" "}
|
||||||
|
{premultiplied ? "premultiplied" : "straight"} alpha
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<CopyButton value={cssExport} label="Copy CSS" />
|
||||||
|
<CopyButton value={jsonExport} label="Copy JSON" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="gradient-preview checkerboard">
|
||||||
|
<div style={{ background: gradient }} />
|
||||||
|
</div>
|
||||||
|
{calculation.error && (
|
||||||
|
<p className="stale-notice" role="status">
|
||||||
|
{calculation.error} Keeping the last valid ramp.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="step-grid">
|
||||||
|
{shownSteps.map((step, index) => (
|
||||||
|
<article className="step-swatch" key={`${index}-${step.hex}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="step-colour checkerboard"
|
||||||
|
style={{ "--swatch": step.css } as React.CSSProperties}
|
||||||
|
onClick={() => onAddColour(step.css, `Step ${index + 1}`)}
|
||||||
|
title="Add to palette"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Add step {index + 1} to palette
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<strong>{Math.round(step.position * 100)}%</strong>
|
||||||
|
<code>{step.hex}</code>
|
||||||
|
<CopyButton
|
||||||
|
value={step.css}
|
||||||
|
label={`Copy step ${index + 1}`}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="field-help">
|
||||||
|
Click a swatch to add it to your saved local palette. Premultiplied
|
||||||
|
alpha prevents hidden transparent channels from tinting intermediate
|
||||||
|
colours.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Swatch({
|
||||||
|
colour,
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
|
actions,
|
||||||
|
large = false,
|
||||||
|
}: {
|
||||||
|
colour: string;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
large?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className={`swatch-card${large ? " swatch-card--large" : ""}`}>
|
||||||
|
<div
|
||||||
|
className="swatch-colour checkerboard"
|
||||||
|
style={{ "--swatch": colour } as CSSProperties}
|
||||||
|
aria-label={`${label}: ${detail ?? colour}`}
|
||||||
|
/>
|
||||||
|
<div className="swatch-meta">
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<code>{detail ?? colour}</code>
|
||||||
|
</div>
|
||||||
|
{actions && <div className="swatch-actions">{actions}</div>}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { CompositeLab } from "./CompositeLab";
|
||||||
|
import { ContrastLab } from "./ContrastLab";
|
||||||
|
import { ConverterLab } from "./ConverterLab";
|
||||||
|
import { ImagePickerLab } from "./ImagePickerLab";
|
||||||
|
import { PaletteLab } from "./PaletteLab";
|
||||||
|
import { PickerLab } from "./PickerLab";
|
||||||
|
import { StepsLab } from "./StepsLab";
|
||||||
|
import { useSavedPalette } from "../hooks/useSavedPalette";
|
||||||
|
|
||||||
|
const workspaces = [
|
||||||
|
{ id: "convert", label: "Convert", short: "Spaces & syntax" },
|
||||||
|
{ id: "composite", label: "Composite", short: "RGBA layers" },
|
||||||
|
{ id: "steps", label: "Steps", short: "Interpolation" },
|
||||||
|
{ id: "pick", label: "Pick", short: "Visual picker" },
|
||||||
|
{ id: "image", label: "Image", short: "Sample & extract" },
|
||||||
|
{ id: "contrast", label: "Contrast", short: "Access & compare" },
|
||||||
|
{ id: "palette", label: "Palette", short: "Harmonies & tokens" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type WorkspaceId = (typeof workspaces)[number]["id"];
|
||||||
|
|
||||||
|
function initialWorkspace(): WorkspaceId {
|
||||||
|
const fromHash = globalThis.location.hash.replace(/^#/, "") as WorkspaceId;
|
||||||
|
return workspaces.some((workspace) => workspace.id === fromHash)
|
||||||
|
? fromHash
|
||||||
|
: "convert";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Workbench() {
|
||||||
|
const [active, setActive] = useState<WorkspaceId>(initialWorkspace);
|
||||||
|
const palette = useSavedPalette();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHash = () => setActive(initialWorkspace());
|
||||||
|
globalThis.addEventListener("hashchange", onHash);
|
||||||
|
return () => globalThis.removeEventListener("hashchange", onHash);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document
|
||||||
|
.getElementById(`tab-${active}`)
|
||||||
|
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
function select(id: WorkspaceId) {
|
||||||
|
setActive(id);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${location.pathname}${location.search}#${id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
const onAddColour = (colour: string, name?: string) =>
|
||||||
|
palette.addColour(colour, name);
|
||||||
|
|
||||||
|
switch (active) {
|
||||||
|
case "composite":
|
||||||
|
return <CompositeLab onAddColour={onAddColour} />;
|
||||||
|
case "steps":
|
||||||
|
return <StepsLab onAddColour={onAddColour} />;
|
||||||
|
case "pick":
|
||||||
|
return <PickerLab onAddColour={onAddColour} />;
|
||||||
|
case "image":
|
||||||
|
return <ImagePickerLab onAddColour={onAddColour} />;
|
||||||
|
case "contrast":
|
||||||
|
return <ContrastLab onAddColour={onAddColour} />;
|
||||||
|
case "palette":
|
||||||
|
return <PaletteLab palette={palette} />;
|
||||||
|
default:
|
||||||
|
return <ConverterLab onAddColour={onAddColour} />;
|
||||||
|
}
|
||||||
|
}, [active, palette]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="colour-workbench">
|
||||||
|
<header className="workbench-intro">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Private by design</p>
|
||||||
|
<h1>Colour workbench</h1>
|
||||||
|
<p>
|
||||||
|
Convert, layer, interpolate, sample and test colours without sending
|
||||||
|
a byte away from your browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="privacy-badge"
|
||||||
|
title="No uploads, analytics or server processing"
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 10V7a5 5 0 0 1 10 0v3m-11 0h12a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Zm6 4v3" />
|
||||||
|
</svg>
|
||||||
|
Local only
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="workspace-tabs" aria-label="Colour workspaces">
|
||||||
|
<div role="tablist" aria-label="Colour workspaces">
|
||||||
|
{workspaces.map((workspace) => (
|
||||||
|
<button
|
||||||
|
key={workspace.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`tab-${workspace.id}`}
|
||||||
|
aria-controls={`panel-${workspace.id}`}
|
||||||
|
aria-selected={active === workspace.id}
|
||||||
|
tabIndex={active === workspace.id ? 0 : -1}
|
||||||
|
onClick={() => select(workspace.id)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
!["ArrowLeft", "ArrowRight", "Home", "End"].includes(
|
||||||
|
event.key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
const index = workspaces.findIndex(
|
||||||
|
(item) => item.id === active,
|
||||||
|
);
|
||||||
|
const nextIndex =
|
||||||
|
event.key === "Home"
|
||||||
|
? 0
|
||||||
|
: event.key === "End"
|
||||||
|
? workspaces.length - 1
|
||||||
|
: (index +
|
||||||
|
(event.key === "ArrowRight" ? 1 : -1) +
|
||||||
|
workspaces.length) %
|
||||||
|
workspaces.length;
|
||||||
|
const next = workspaces[nextIndex];
|
||||||
|
if (!next) return;
|
||||||
|
select(next.id);
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
document.getElementById(`tab-${next.id}`)?.focus(),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{workspace.label}</span>
|
||||||
|
<small>{workspace.short}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="workspace-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
id={`panel-${active}`}
|
||||||
|
aria-labelledby={`tab-${active}`}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface SavedColour {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = "colour-tools.palette.v1";
|
||||||
|
const LIMIT = 64;
|
||||||
|
|
||||||
|
function readPalette(): SavedColour[] {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(
|
||||||
|
localStorage.getItem(STORAGE_KEY) ?? "[]",
|
||||||
|
) as unknown;
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter(
|
||||||
|
(entry): entry is SavedColour =>
|
||||||
|
typeof entry === "object" &&
|
||||||
|
entry !== null &&
|
||||||
|
typeof (entry as SavedColour).id === "string" &&
|
||||||
|
typeof (entry as SavedColour).name === "string" &&
|
||||||
|
typeof (entry as SavedColour).value === "string",
|
||||||
|
)
|
||||||
|
.slice(0, LIMIT);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeId(): string {
|
||||||
|
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSavedPalette() {
|
||||||
|
const [colours, setColours] = useState<SavedColour[]>(readPalette);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify(colours.slice(0, LIMIT)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable in private contexts; session state still works.
|
||||||
|
}
|
||||||
|
}, [colours]);
|
||||||
|
|
||||||
|
const addColour = useCallback((value: string, name?: string) => {
|
||||||
|
setColours((current) => {
|
||||||
|
const duplicate = current.find(
|
||||||
|
(entry) => entry.value.toLowerCase() === value.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (duplicate) return current;
|
||||||
|
return [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: makeId(),
|
||||||
|
name: name?.trim() || `Colour ${current.length + 1}`,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
].slice(-LIMIT);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateColour = useCallback(
|
||||||
|
(id: string, update: Partial<Pick<SavedColour, "name" | "value">>) => {
|
||||||
|
setColours((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === id ? { ...entry, ...update } : entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeColour = useCallback((id: string) => {
|
||||||
|
setColours((current) => current.filter((entry) => entry.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceColours = useCallback((next: SavedColour[]) => {
|
||||||
|
setColours(next.slice(0, LIMIT));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearColours = useCallback(() => setColours([]), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
colours,
|
||||||
|
addColour,
|
||||||
|
updateColour,
|
||||||
|
removeColour,
|
||||||
|
replaceColours,
|
||||||
|
clearColours,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
const url = new URL("./sw.js", document.baseURI);
|
||||||
|
void navigator.serviceWorker
|
||||||
|
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||||
|
.catch(() => {
|
||||||
|
// Offline installation is progressive enhancement.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { ImageLimits, PixelPoint } from "./types";
|
||||||
|
|
||||||
|
const MEBIBYTE = 1024 * 1024;
|
||||||
|
|
||||||
|
export const DEFAULT_IMAGE_LIMITS: ImageLimits = Object.freeze({
|
||||||
|
maxBytes: 40 * MEBIBYTE,
|
||||||
|
maxPixels: 24_000_000,
|
||||||
|
maxDimension: 8_192,
|
||||||
|
acceptedMimeTypes: Object.freeze([
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
"image/avif",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
readonly valid: boolean;
|
||||||
|
readonly message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateImageFile = (
|
||||||
|
file: Pick<File, "name" | "size" | "type">,
|
||||||
|
limits: ImageLimits = DEFAULT_IMAGE_LIMITS,
|
||||||
|
): ValidationResult => {
|
||||||
|
if (file.size <= 0) {
|
||||||
|
return { valid: false, message: "The selected image is empty." };
|
||||||
|
}
|
||||||
|
if (file.size > limits.maxBytes) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `The selected image is larger than ${Math.round(limits.maxBytes / MEBIBYTE)} MiB.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
const extension = file.name.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
|
||||||
|
const extensionMimeType: Readonly<Record<string, string>> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
webp: "image/webp",
|
||||||
|
gif: "image/gif",
|
||||||
|
avif: "image/avif",
|
||||||
|
};
|
||||||
|
const detectedType = mimeType || extensionMimeType[extension] || "";
|
||||||
|
if (!limits.acceptedMimeTypes.includes(detectedType)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: "Choose a PNG, JPEG, WebP, GIF, or AVIF image.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateImageDimensions = (
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
limits: ImageLimits = DEFAULT_IMAGE_LIMITS,
|
||||||
|
): ValidationResult => {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(width) ||
|
||||||
|
!Number.isSafeInteger(height) ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0
|
||||||
|
) {
|
||||||
|
return { valid: false, message: "The image has invalid dimensions." };
|
||||||
|
}
|
||||||
|
if (width > limits.maxDimension || height > limits.maxDimension) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `The image is too wide or tall. The limit is ${limits.maxDimension.toLocaleString()} pixels per side.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (width * height > limits.maxPixels) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `The decoded image is too large. The limit is ${limits.maxPixels.toLocaleString()} pixels.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ClientRectLike {
|
||||||
|
readonly left: number;
|
||||||
|
readonly top: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapClientPointToPixel = (
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
rect: ClientRectLike,
|
||||||
|
imageWidth: number,
|
||||||
|
imageHeight: number,
|
||||||
|
): PixelPoint | null => {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(rect.left) ||
|
||||||
|
!Number.isFinite(rect.top) ||
|
||||||
|
!Number.isFinite(rect.width) ||
|
||||||
|
!Number.isFinite(rect.height) ||
|
||||||
|
rect.width <= 0 ||
|
||||||
|
rect.height <= 0 ||
|
||||||
|
!Number.isSafeInteger(imageWidth) ||
|
||||||
|
!Number.isSafeInteger(imageHeight) ||
|
||||||
|
imageWidth <= 0 ||
|
||||||
|
imageHeight <= 0 ||
|
||||||
|
!Number.isFinite(clientX) ||
|
||||||
|
!Number.isFinite(clientY)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeX = (clientX - rect.left) / rect.width;
|
||||||
|
const relativeY = (clientY - rect.top) / rect.height;
|
||||||
|
const x = Math.min(
|
||||||
|
imageWidth - 1,
|
||||||
|
Math.max(0, Math.floor(relativeX * imageWidth)),
|
||||||
|
);
|
||||||
|
const y = Math.min(
|
||||||
|
imageHeight - 1,
|
||||||
|
Math.max(0, Math.floor(relativeY * imageHeight)),
|
||||||
|
);
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SampleRegion {
|
||||||
|
readonly left: number;
|
||||||
|
readonly top: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly centerX: number;
|
||||||
|
readonly centerY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBoundedSampleRegion = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
radius: number,
|
||||||
|
imageWidth: number,
|
||||||
|
imageHeight: number,
|
||||||
|
): SampleRegion | null => {
|
||||||
|
if (
|
||||||
|
imageWidth <= 0 ||
|
||||||
|
imageHeight <= 0 ||
|
||||||
|
!Number.isFinite(x) ||
|
||||||
|
!Number.isFinite(y) ||
|
||||||
|
!Number.isFinite(radius)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeX = Math.min(imageWidth - 1, Math.max(0, Math.round(x)));
|
||||||
|
const safeY = Math.min(imageHeight - 1, Math.max(0, Math.round(y)));
|
||||||
|
const safeRadius = Math.min(64, Math.max(0, Math.round(radius)));
|
||||||
|
const left = Math.max(0, safeX - safeRadius);
|
||||||
|
const top = Math.max(0, safeY - safeRadius);
|
||||||
|
const right = Math.min(imageWidth - 1, safeX + safeRadius);
|
||||||
|
const bottom = Math.min(imageHeight - 1, safeY + safeRadius);
|
||||||
|
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width: right - left + 1,
|
||||||
|
height: bottom - top + 1,
|
||||||
|
centerX: safeX - left,
|
||||||
|
centerY: safeY - top,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export interface DecodedLocalImage {
|
||||||
|
readonly source: CanvasImageSource;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortError = (): DOMException =>
|
||||||
|
new DOMException("Image decoding was cancelled.", "AbortError");
|
||||||
|
|
||||||
|
const decodeWithImageElement = (
|
||||||
|
file: File,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<DecodedLocalImage> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(abortError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = "async";
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = (): void => {
|
||||||
|
image.onload = null;
|
||||||
|
image.onerror = null;
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
const onAbort = (): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
image.src = "";
|
||||||
|
cleanup();
|
||||||
|
reject(abortError());
|
||||||
|
};
|
||||||
|
image.onload = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve({
|
||||||
|
source: image,
|
||||||
|
width: image.naturalWidth,
|
||||||
|
height: image.naturalHeight,
|
||||||
|
dispose: () => {
|
||||||
|
image.src = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
image.onerror = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("The browser could not decode this image."));
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
image.src = objectUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const decodeLocalImage = async (
|
||||||
|
file: File,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<DecodedLocalImage> => {
|
||||||
|
if (signal?.aborted) throw abortError();
|
||||||
|
if (typeof createImageBitmap === "function") {
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file, {
|
||||||
|
imageOrientation: "from-image",
|
||||||
|
});
|
||||||
|
if (signal?.aborted) {
|
||||||
|
bitmap.close();
|
||||||
|
throw abortError();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
source: bitmap,
|
||||||
|
width: bitmap.width,
|
||||||
|
height: bitmap.height,
|
||||||
|
dispose: () => bitmap.close(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
(error instanceof DOMException && error.name === "AbortError")
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
// Older browsers sometimes expose createImageBitmap without supporting every format.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decodeWithImageElement(file, signal);
|
||||||
|
};
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { rgbaToCss, rgbaToHex } from "./format";
|
||||||
|
import {
|
||||||
|
oklabDistanceSquared,
|
||||||
|
oklabToRgb,
|
||||||
|
rgbToOklab,
|
||||||
|
type Oklab,
|
||||||
|
} from "./oklab";
|
||||||
|
import type {
|
||||||
|
ExtractedPaletteColour,
|
||||||
|
PaletteExtractionOptions,
|
||||||
|
PixelBuffer,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface ColourPoint extends Oklab {
|
||||||
|
readonly key: number;
|
||||||
|
readonly weight: number;
|
||||||
|
readonly population: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableCluster {
|
||||||
|
l: number;
|
||||||
|
a: number;
|
||||||
|
b: number;
|
||||||
|
weight: number;
|
||||||
|
population: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampInteger = (
|
||||||
|
value: number | undefined,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
): number => {
|
||||||
|
if (value === undefined || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(maximum, Math.max(minimum, Math.round(value)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const makePoints = (
|
||||||
|
pixels: PixelBuffer,
|
||||||
|
maximumSamples: number,
|
||||||
|
minimumAlpha: number,
|
||||||
|
): ColourPoint[] => {
|
||||||
|
const totalPixels = pixels.width * pixels.height;
|
||||||
|
const stride = Math.max(1, Math.ceil(totalPixels / maximumSamples));
|
||||||
|
const histogram = new Map<number, { weight: number; population: number }>();
|
||||||
|
|
||||||
|
for (let pixel = 0; pixel < totalPixels; pixel += stride) {
|
||||||
|
const offset = pixel * 4;
|
||||||
|
const alpha = pixels.data[offset + 3] ?? 0;
|
||||||
|
if (alpha < minimumAlpha || alpha === 0) continue;
|
||||||
|
const red = pixels.data[offset] ?? 0;
|
||||||
|
const green = pixels.data[offset + 1] ?? 0;
|
||||||
|
const blue = pixels.data[offset + 2] ?? 0;
|
||||||
|
const key = (red << 16) | (green << 8) | blue;
|
||||||
|
const entry = histogram.get(key) ?? { weight: 0, population: 0 };
|
||||||
|
entry.weight += alpha / 255;
|
||||||
|
entry.population += 1;
|
||||||
|
histogram.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...histogram.entries()]
|
||||||
|
.sort(([left], [right]) => left - right)
|
||||||
|
.map(([key, entry]) => {
|
||||||
|
const red = (key >> 16) & 0xff;
|
||||||
|
const green = (key >> 8) & 0xff;
|
||||||
|
const blue = key & 0xff;
|
||||||
|
return { key, ...rgbToOklab(red, green, blue), ...entry };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectInitialCentroids = (
|
||||||
|
points: readonly ColourPoint[],
|
||||||
|
count: number,
|
||||||
|
): Oklab[] => {
|
||||||
|
if (points.length === 0) return [];
|
||||||
|
const first = points.reduce((best, point) => {
|
||||||
|
if (point.weight > best.weight) return point;
|
||||||
|
return point.weight === best.weight && point.key < best.key ? point : best;
|
||||||
|
});
|
||||||
|
const centroids: Oklab[] = [{ l: first.l, a: first.a, b: first.b }];
|
||||||
|
const selected = new Set([first.key]);
|
||||||
|
|
||||||
|
while (centroids.length < count) {
|
||||||
|
let bestPoint: ColourPoint | null = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (const point of points) {
|
||||||
|
if (selected.has(point.key)) continue;
|
||||||
|
let closestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (const centroid of centroids) {
|
||||||
|
closestDistance = Math.min(
|
||||||
|
closestDistance,
|
||||||
|
oklabDistanceSquared(point, centroid),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const score = closestDistance * Math.sqrt(point.weight);
|
||||||
|
if (
|
||||||
|
score > bestScore ||
|
||||||
|
(score === bestScore &&
|
||||||
|
(bestPoint === null || point.key < bestPoint.key))
|
||||||
|
) {
|
||||||
|
bestPoint = point;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestPoint === null) break;
|
||||||
|
selected.add(bestPoint.key);
|
||||||
|
centroids.push({ l: bestPoint.l, a: bestPoint.a, b: bestPoint.b });
|
||||||
|
}
|
||||||
|
return centroids;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nearestCentroid = (point: Oklab, centroids: readonly Oklab[]): number => {
|
||||||
|
let nearest = 0;
|
||||||
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
for (let index = 0; index < centroids.length; index += 1) {
|
||||||
|
const centroid = centroids[index];
|
||||||
|
if (!centroid) continue;
|
||||||
|
const distance = oklabDistanceSquared(point, centroid);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearest = index;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyClusters = (count: number): MutableCluster[] =>
|
||||||
|
Array.from({ length: count }, () => ({
|
||||||
|
l: 0,
|
||||||
|
a: 0,
|
||||||
|
b: 0,
|
||||||
|
weight: 0,
|
||||||
|
population: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const accumulate = (
|
||||||
|
points: readonly ColourPoint[],
|
||||||
|
centroids: readonly Oklab[],
|
||||||
|
): MutableCluster[] => {
|
||||||
|
const clusters = emptyClusters(centroids.length);
|
||||||
|
for (const point of points) {
|
||||||
|
const cluster = clusters[nearestCentroid(point, centroids)];
|
||||||
|
if (!cluster) continue;
|
||||||
|
cluster.l += point.l * point.weight;
|
||||||
|
cluster.a += point.a * point.weight;
|
||||||
|
cluster.b += point.b * point.weight;
|
||||||
|
cluster.weight += point.weight;
|
||||||
|
cluster.population += point.population;
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractPalette = (
|
||||||
|
pixels: PixelBuffer,
|
||||||
|
options: PaletteExtractionOptions,
|
||||||
|
): ExtractedPaletteColour[] => {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(pixels.width) ||
|
||||||
|
!Number.isSafeInteger(pixels.height) ||
|
||||||
|
pixels.width <= 0 ||
|
||||||
|
pixels.height <= 0 ||
|
||||||
|
!Number.isSafeInteger(pixels.width * pixels.height) ||
|
||||||
|
pixels.data.length < pixels.width * pixels.height * 4
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid pixel buffer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedCount = clampInteger(options.count, 6, 2, 12);
|
||||||
|
const maximumSamples = clampInteger(options.maxSamples, 80_000, 1, 200_000);
|
||||||
|
const maximumIterations = clampInteger(options.maxIterations, 24, 1, 64);
|
||||||
|
const minimumAlpha = clampInteger(options.minimumAlpha, 16, 0, 255);
|
||||||
|
const points = makePoints(pixels, maximumSamples, minimumAlpha);
|
||||||
|
if (points.length === 0) return [];
|
||||||
|
|
||||||
|
let centroids = selectInitialCentroids(
|
||||||
|
points,
|
||||||
|
Math.min(requestedCount, points.length),
|
||||||
|
);
|
||||||
|
for (let iteration = 0; iteration < maximumIterations; iteration += 1) {
|
||||||
|
const clusters = accumulate(points, centroids);
|
||||||
|
let maximumShift = 0;
|
||||||
|
const nextCentroids = centroids.map((centroid, index) => {
|
||||||
|
const cluster = clusters[index];
|
||||||
|
if (!cluster || cluster.weight <= 0) return centroid;
|
||||||
|
const next = {
|
||||||
|
l: cluster.l / cluster.weight,
|
||||||
|
a: cluster.a / cluster.weight,
|
||||||
|
b: cluster.b / cluster.weight,
|
||||||
|
};
|
||||||
|
maximumShift = Math.max(
|
||||||
|
maximumShift,
|
||||||
|
oklabDistanceSquared(centroid, next),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
centroids = nextCentroids;
|
||||||
|
if (maximumShift < 1e-10) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters = accumulate(points, centroids);
|
||||||
|
const totalWeight = clusters.reduce(
|
||||||
|
(sum, cluster) => sum + cluster.weight,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return clusters
|
||||||
|
.map((cluster, index): ExtractedPaletteColour | null => {
|
||||||
|
const centroid = centroids[index];
|
||||||
|
if (!centroid || cluster.weight <= 0 || totalWeight <= 0) return null;
|
||||||
|
const rgb = oklabToRgb(centroid);
|
||||||
|
const colour = { ...rgb, a: 255 };
|
||||||
|
return {
|
||||||
|
...colour,
|
||||||
|
hex: rgbaToHex(colour),
|
||||||
|
css: rgbaToCss(colour),
|
||||||
|
coverage: cluster.weight / totalWeight,
|
||||||
|
population: cluster.population,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((colour): colour is ExtractedPaletteColour => colour !== null)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.coverage - left.coverage || left.hex.localeCompare(right.hex),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Rgba8 } from "./types";
|
||||||
|
|
||||||
|
const byteToHex = (value: number): string =>
|
||||||
|
Math.round(Math.max(0, Math.min(255, value)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
export const rgbaToHex = (
|
||||||
|
colour: Rgba8,
|
||||||
|
includeAlpha = colour.a < 255,
|
||||||
|
): string =>
|
||||||
|
`#${byteToHex(colour.r)}${byteToHex(colour.g)}${byteToHex(colour.b)}${includeAlpha ? byteToHex(colour.a) : ""}`;
|
||||||
|
|
||||||
|
export const rgbaToCss = (colour: Rgba8): string => {
|
||||||
|
const alpha = Math.max(0, Math.min(255, colour.a)) / 255;
|
||||||
|
if (alpha >= 1) {
|
||||||
|
return `rgb(${Math.round(colour.r)} ${Math.round(colour.g)} ${Math.round(colour.b)})`;
|
||||||
|
}
|
||||||
|
return `rgb(${Math.round(colour.r)} ${Math.round(colour.g)} ${Math.round(colour.b)} / ${Number(alpha.toFixed(3))})`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export {
|
||||||
|
DEFAULT_IMAGE_LIMITS,
|
||||||
|
getBoundedSampleRegion,
|
||||||
|
mapClientPointToPixel,
|
||||||
|
validateImageDimensions,
|
||||||
|
validateImageFile,
|
||||||
|
} from "./bounds";
|
||||||
|
export { extractPalette } from "./extract";
|
||||||
|
export { decodeLocalImage } from "./decode";
|
||||||
|
export { rgbaToCss, rgbaToHex } from "./format";
|
||||||
|
export { samplePixels } from "./sample";
|
||||||
|
export type {
|
||||||
|
ExtractedPaletteColour,
|
||||||
|
ImageLimits,
|
||||||
|
LoadedImageDetails,
|
||||||
|
PaletteExtractionOptions,
|
||||||
|
PixelBuffer,
|
||||||
|
PixelPoint,
|
||||||
|
PixelSample,
|
||||||
|
Rgba8,
|
||||||
|
SamplingMethod,
|
||||||
|
} from "./types";
|
||||||
|
export { extractPaletteSafely } from "./worker-client";
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
interface Oklab {
|
||||||
|
readonly l: number;
|
||||||
|
readonly a: number;
|
||||||
|
readonly b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const srgbToLinear = (channel: number): number => {
|
||||||
|
const value = Math.max(0, Math.min(255, channel)) / 255;
|
||||||
|
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
|
||||||
|
const linearToSrgb = (channel: number): number => {
|
||||||
|
const value =
|
||||||
|
channel <= 0.0031308
|
||||||
|
? 12.92 * channel
|
||||||
|
: 1.055 * channel ** (1 / 2.4) - 0.055;
|
||||||
|
return Math.round(Math.max(0, Math.min(1, value)) * 255);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rgbToOklab = (red: number, green: number, blue: number): Oklab => {
|
||||||
|
const r = srgbToLinear(red);
|
||||||
|
const g = srgbToLinear(green);
|
||||||
|
const b = srgbToLinear(blue);
|
||||||
|
|
||||||
|
const lRoot = Math.cbrt(
|
||||||
|
0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
|
||||||
|
);
|
||||||
|
const mRoot = Math.cbrt(
|
||||||
|
0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
|
||||||
|
);
|
||||||
|
const sRoot = Math.cbrt(
|
||||||
|
0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
l: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
|
||||||
|
a: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
|
||||||
|
b: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const oklabToRgb = (lab: Oklab): { r: number; g: number; b: number } => {
|
||||||
|
const lRoot = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b;
|
||||||
|
const mRoot = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b;
|
||||||
|
const sRoot = lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b;
|
||||||
|
const l = lRoot ** 3;
|
||||||
|
const m = mRoot ** 3;
|
||||||
|
const s = sRoot ** 3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
||||||
|
g: linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
||||||
|
b: linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const oklabDistanceSquared = (left: Oklab, right: Oklab): number => {
|
||||||
|
const dl = left.l - right.l;
|
||||||
|
const da = left.a - right.a;
|
||||||
|
const db = left.b - right.b;
|
||||||
|
return dl * dl + da * da + db * db;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { Oklab };
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { rgbaToCss, rgbaToHex } from "./format";
|
||||||
|
import type { PixelBuffer, PixelSample, SamplingMethod } from "./types";
|
||||||
|
|
||||||
|
const median = (values: number[]): number => {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
values.sort((left, right) => left - right);
|
||||||
|
const middle = Math.floor(values.length / 2);
|
||||||
|
if (values.length % 2 === 1) return values[middle] ?? 0;
|
||||||
|
return ((values[middle - 1] ?? 0) + (values[middle] ?? 0)) / 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const samplePixels = (
|
||||||
|
pixels: PixelBuffer,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
radius: number,
|
||||||
|
method: SamplingMethod,
|
||||||
|
): PixelSample | null => {
|
||||||
|
const { data, width, height } = pixels;
|
||||||
|
const safeX = Math.round(x);
|
||||||
|
const safeY = Math.round(y);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(width) ||
|
||||||
|
!Number.isSafeInteger(height) ||
|
||||||
|
width <= 0 ||
|
||||||
|
height <= 0 ||
|
||||||
|
!Number.isSafeInteger(width * height) ||
|
||||||
|
data.length < width * height * 4 ||
|
||||||
|
!Number.isFinite(safeX) ||
|
||||||
|
!Number.isFinite(safeY) ||
|
||||||
|
safeX < 0 ||
|
||||||
|
safeX >= width ||
|
||||||
|
safeY < 0 ||
|
||||||
|
safeY >= height
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRadius = Math.min(64, Math.max(0, Math.round(radius)));
|
||||||
|
const red: number[] = [];
|
||||||
|
const green: number[] = [];
|
||||||
|
const blue: number[] = [];
|
||||||
|
const alpha: number[] = [];
|
||||||
|
|
||||||
|
let alphaWeight = 0;
|
||||||
|
let weightedRed = 0;
|
||||||
|
let weightedGreen = 0;
|
||||||
|
let weightedBlue = 0;
|
||||||
|
let alphaTotal = 0;
|
||||||
|
const radiusSquared = safeRadius * safeRadius;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let row = Math.max(0, safeY - safeRadius);
|
||||||
|
row <= Math.min(height - 1, safeY + safeRadius);
|
||||||
|
row += 1
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let column = Math.max(0, safeX - safeRadius);
|
||||||
|
column <= Math.min(width - 1, safeX + safeRadius);
|
||||||
|
column += 1
|
||||||
|
) {
|
||||||
|
const dx = column - safeX;
|
||||||
|
const dy = row - safeY;
|
||||||
|
if (safeRadius > 0 && dx * dx + dy * dy > radiusSquared) continue;
|
||||||
|
|
||||||
|
const offset = (row * width + column) * 4;
|
||||||
|
const r = data[offset] ?? 0;
|
||||||
|
const g = data[offset + 1] ?? 0;
|
||||||
|
const b = data[offset + 2] ?? 0;
|
||||||
|
const a = data[offset + 3] ?? 0;
|
||||||
|
const weight = a / 255;
|
||||||
|
|
||||||
|
alpha.push(a);
|
||||||
|
alphaTotal += a;
|
||||||
|
alphaWeight += weight;
|
||||||
|
weightedRed += r * weight;
|
||||||
|
weightedGreen += g * weight;
|
||||||
|
weightedBlue += b * weight;
|
||||||
|
if (a > 0) {
|
||||||
|
red.push(r);
|
||||||
|
green.push(g);
|
||||||
|
blue.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alpha.length === 0) return null;
|
||||||
|
|
||||||
|
const colour =
|
||||||
|
method === "median"
|
||||||
|
? {
|
||||||
|
r: Math.round(median(red)),
|
||||||
|
g: Math.round(median(green)),
|
||||||
|
b: Math.round(median(blue)),
|
||||||
|
a: Math.round(median(alpha)),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
r: Math.round(alphaWeight > 0 ? weightedRed / alphaWeight : 0),
|
||||||
|
g: Math.round(alphaWeight > 0 ? weightedGreen / alphaWeight : 0),
|
||||||
|
b: Math.round(alphaWeight > 0 ? weightedBlue / alphaWeight : 0),
|
||||||
|
a: Math.round(alphaTotal / alpha.length),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...colour,
|
||||||
|
x: safeX,
|
||||||
|
y: safeY,
|
||||||
|
radius: safeRadius,
|
||||||
|
method,
|
||||||
|
pixelCount: alpha.length,
|
||||||
|
hex: rgbaToHex(colour),
|
||||||
|
css: rgbaToCss(colour),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type SamplingMethod = "average" | "median";
|
||||||
|
|
||||||
|
export interface Rgba8 {
|
||||||
|
readonly r: number;
|
||||||
|
readonly g: number;
|
||||||
|
readonly b: number;
|
||||||
|
readonly a: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PixelBuffer {
|
||||||
|
readonly data: Uint8ClampedArray;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PixelPoint {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PixelSample extends Rgba8, PixelPoint {
|
||||||
|
readonly method: SamplingMethod;
|
||||||
|
readonly radius: number;
|
||||||
|
readonly pixelCount: number;
|
||||||
|
readonly hex: string;
|
||||||
|
readonly css: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaletteExtractionOptions {
|
||||||
|
readonly count: number;
|
||||||
|
readonly maxSamples?: number;
|
||||||
|
readonly maxIterations?: number;
|
||||||
|
readonly minimumAlpha?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractedPaletteColour extends Rgba8 {
|
||||||
|
readonly hex: string;
|
||||||
|
readonly css: string;
|
||||||
|
readonly coverage: number;
|
||||||
|
readonly population: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageLimits {
|
||||||
|
readonly maxBytes: number;
|
||||||
|
readonly maxPixels: number;
|
||||||
|
readonly maxDimension: number;
|
||||||
|
readonly acceptedMimeTypes: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadedImageDetails {
|
||||||
|
readonly name: string;
|
||||||
|
readonly mimeType: string;
|
||||||
|
readonly bytes: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { extractPalette } from "./extract";
|
||||||
|
import type {
|
||||||
|
ExtractedPaletteColour,
|
||||||
|
PaletteExtractionOptions,
|
||||||
|
PixelBuffer,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface PaletteWorkerSuccess {
|
||||||
|
readonly id: number;
|
||||||
|
readonly ok: true;
|
||||||
|
readonly palette: ExtractedPaletteColour[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaletteWorkerFailure {
|
||||||
|
readonly id: number;
|
||||||
|
readonly ok: false;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaletteWorkerResponse = PaletteWorkerSuccess | PaletteWorkerFailure;
|
||||||
|
|
||||||
|
let requestSequence = 0;
|
||||||
|
|
||||||
|
const abortError = (): DOMException =>
|
||||||
|
new DOMException("Palette extraction was cancelled.", "AbortError");
|
||||||
|
|
||||||
|
const extractWithWorker = (
|
||||||
|
pixels: PixelBuffer,
|
||||||
|
options: PaletteExtractionOptions,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ExtractedPaletteColour[]> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (typeof Worker === "undefined") {
|
||||||
|
reject(new Error("Web Workers are unavailable."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(abortError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ++requestSequence;
|
||||||
|
const bytes = new Uint8ClampedArray(pixels.data);
|
||||||
|
const worker = new Worker(
|
||||||
|
new URL("../workers/palette.worker.ts", import.meta.url),
|
||||||
|
{ type: "module" },
|
||||||
|
);
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
worker.terminate();
|
||||||
|
reject(new Error("Palette worker timed out."));
|
||||||
|
}, 12_000);
|
||||||
|
|
||||||
|
const finish = (): void => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
worker.terminate();
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const onAbort = (): void => {
|
||||||
|
finish();
|
||||||
|
reject(abortError());
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.onmessage = (event: MessageEvent<PaletteWorkerResponse>) => {
|
||||||
|
if (event.data.id !== id) return;
|
||||||
|
finish();
|
||||||
|
if (event.data.ok) resolve(event.data.palette);
|
||||||
|
else reject(new Error(event.data.message));
|
||||||
|
};
|
||||||
|
worker.onerror = () => {
|
||||||
|
finish();
|
||||||
|
reject(new Error("Palette worker failed."));
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
worker.postMessage(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
width: pixels.width,
|
||||||
|
height: pixels.height,
|
||||||
|
bytes: bytes.buffer,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
[bytes.buffer],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const extractPaletteSafely = async (
|
||||||
|
pixels: PixelBuffer,
|
||||||
|
options: PaletteExtractionOptions,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ExtractedPaletteColour[]> => {
|
||||||
|
try {
|
||||||
|
return await extractWithWorker(pixels, options, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
signal?.aborted ||
|
||||||
|
(error instanceof DOMException && error.name === "AbortError")
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
// Extraction inputs are deliberately bounded, so a synchronous fallback remains safe.
|
||||||
|
return extractPalette(pixels, options);
|
||||||
|
}
|
||||||
|
};
|
||||||
+1760
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
if (!HTMLDialogElement.prototype.showModal) {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() {
|
||||||
|
this.setAttribute("open", "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HTMLDialogElement.prototype.close) {
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.removeAttribute("open");
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!URL.createObjectURL) URL.createObjectURL = () => "blob:test";
|
||||||
|
if (!URL.revokeObjectURL) URL.revokeObjectURL = () => undefined;
|
||||||
|
|
||||||
|
if (!Element.prototype.scrollIntoView) {
|
||||||
|
Element.prototype.scrollIntoView = () => undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "de.add-ideas.colour-tools",
|
||||||
|
"name": "Colour Tools",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Convert, composite, compare and build colours locally in the browser.",
|
||||||
|
"entry": "./",
|
||||||
|
"icon": "./favicon.svg",
|
||||||
|
"categories": ["graphics", "design", "developer"],
|
||||||
|
"tags": [
|
||||||
|
"colour",
|
||||||
|
"color",
|
||||||
|
"rgb",
|
||||||
|
"rgba",
|
||||||
|
"oklch",
|
||||||
|
"palette",
|
||||||
|
"gradient",
|
||||||
|
"contrast",
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"integration": {
|
||||||
|
"contextVersion": 1,
|
||||||
|
"launchModes": ["navigate", "new-tab"],
|
||||||
|
"embedding": "unsupported"
|
||||||
|
},
|
||||||
|
"requirements": {
|
||||||
|
"secureContext": false,
|
||||||
|
"workers": true,
|
||||||
|
"indexedDb": false,
|
||||||
|
"crossOriginIsolated": false,
|
||||||
|
"topLevelContext": false
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"processing": "local",
|
||||||
|
"fileUploads": false,
|
||||||
|
"telemetry": false,
|
||||||
|
"label": "Colours and images stay in this browser; nothing is uploaded."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"repository": "https://git.add-ideas.de/lotobo/colour-tools",
|
||||||
|
"license": "GPL-3.0-or-later"
|
||||||
|
},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "source",
|
||||||
|
"label": "Source",
|
||||||
|
"url": "https://git.add-ideas.de/lotobo/colour-tools"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||||
|
import source from "./manifest.source.json";
|
||||||
|
|
||||||
|
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = "0.1.0";
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { extractPalette } from "../palette/extract";
|
||||||
|
import type { PaletteExtractionOptions } from "../palette/types";
|
||||||
|
|
||||||
|
interface PaletteWorkerRequest {
|
||||||
|
readonly id: number;
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly bytes: ArrayBuffer;
|
||||||
|
readonly options: PaletteExtractionOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerScope = self as DedicatedWorkerGlobalScope;
|
||||||
|
|
||||||
|
workerScope.onmessage = (event: MessageEvent<PaletteWorkerRequest>) => {
|
||||||
|
const { id, width, height, bytes, options } = event.data;
|
||||||
|
try {
|
||||||
|
const palette = extractPalette(
|
||||||
|
{ data: new Uint8ClampedArray(bytes), width, height },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
workerScope.postMessage({ id, ok: true, palette });
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Palette extraction failed.";
|
||||||
|
workerScope.postMessage({ id, ok: false, message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const APP_ORIGIN = "http://127.0.0.1:4173";
|
||||||
|
const FOUR_COLOUR_PNG =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAE0lEQVR4nGP4z8DwHwwZGP6DAQBJyAn3FGMynQAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
async function keepNetworkLocal(page: Page): Promise<string[]> {
|
||||||
|
const externalRequests: string[] = [];
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const requestUrl = new URL(route.request().url());
|
||||||
|
if (requestUrl.origin !== APP_ORIGIN) {
|
||||||
|
externalRequests.push(requestUrl.href);
|
||||||
|
await route.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
return externalRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordRuntimeErrors(page: Page): string[] {
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => runtimeErrors.push(error.message));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") runtimeErrors.push(message.text());
|
||||||
|
});
|
||||||
|
return runtimeErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("converts, composites and interpolates without transient empty results", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const runtimeErrors = recordRuntimeErrors(page);
|
||||||
|
const externalRequests = await keepNetworkLocal(page);
|
||||||
|
await page.goto("/deep/nested/colour/");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Colour workbench" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Convert colours" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("Colour", { exact: true }).fill("rgb(255 0 0 / 50%)");
|
||||||
|
const alphaHex = page
|
||||||
|
.locator(".conversion-row")
|
||||||
|
.filter({ hasText: "HEX + alpha" });
|
||||||
|
await expect(alphaHex).toContainText("#ff000080");
|
||||||
|
|
||||||
|
await page.getByLabel("Colour", { exact: true }).fill("rgb(255 0");
|
||||||
|
await expect(
|
||||||
|
page.getByText(
|
||||||
|
"Keeping the last valid conversion while you finish editing.",
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(alphaHex).toContainText("#ff000080");
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: /Composite/u }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Composite translucent colours" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator(".layer-row")).toHaveCount(3);
|
||||||
|
await page.getByRole("button", { name: "+ Add layer" }).click();
|
||||||
|
await expect(page.locator(".layer-row")).toHaveCount(4);
|
||||||
|
await expect(page.locator(".result-values code").first()).not.toBeEmpty();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: /Steps/u }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Step between colours" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator(".step-swatch")).toHaveCount(9);
|
||||||
|
await page.getByLabel(/Swatches/u).fill("5");
|
||||||
|
await expect(page.locator(".step-swatch")).toHaveCount(5);
|
||||||
|
|
||||||
|
expect(externalRequests).toEqual([]);
|
||||||
|
expect(runtimeErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps picking, accessibility analysis and palette data browser-local", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const runtimeErrors = recordRuntimeErrors(page);
|
||||||
|
const externalRequests = await keepNetworkLocal(page);
|
||||||
|
await page.goto("/deep/nested/colour/#pick");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Pick a colour" }),
|
||||||
|
).toBeVisible();
|
||||||
|
const picker = page.getByRole("application");
|
||||||
|
await expect(picker).toHaveAccessibleName(/Saturation 62 percent/u);
|
||||||
|
await picker.focus();
|
||||||
|
await picker.press("ArrowRight");
|
||||||
|
await expect(picker).toHaveAccessibleName(/Saturation 63 percent/u);
|
||||||
|
await page.getByLabel("Exact CSS colour").fill("oklch(70% 0.2 30)");
|
||||||
|
await page.getByRole("button", { name: "Add to palette" }).click();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: /Contrast/u }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Contrast & compare" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Foreground", exact: true })
|
||||||
|
.fill("#000000");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Background", exact: true })
|
||||||
|
.fill("#ffffff");
|
||||||
|
await expect(page.locator(".ratio-heading strong")).toHaveText("21.00");
|
||||||
|
await expect(
|
||||||
|
page.getByText("AAA normal text", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: /Palette/u }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Build a palette" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByLabel("Palette data")
|
||||||
|
.fill("primary: #123456\naccent: oklch(76% 0.17 87)");
|
||||||
|
await page.getByRole("button", { name: "Import colours" }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("Added 2 colours");
|
||||||
|
await expect(page.locator(".saved-colour-list > li")).toHaveCount(3);
|
||||||
|
await expect(page.locator(".export-preview")).toContainText(
|
||||||
|
"--brand-primary",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("tab", { name: /Palette/u }).click();
|
||||||
|
await expect(page.locator(".saved-colour-list > li")).toHaveCount(3);
|
||||||
|
|
||||||
|
expect(externalRequests).toEqual([]);
|
||||||
|
expect(runtimeErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads, samples and extracts a palette from an in-memory image", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const runtimeErrors = recordRuntimeErrors(page);
|
||||||
|
const externalRequests = await keepNetworkLocal(page);
|
||||||
|
await page.goto("/deep/nested/colour/#image");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Pick and extract colours" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.locator('input[type="file"]').evaluate((element, encoded) => {
|
||||||
|
const bytes = Uint8Array.from(atob(encoded), (character) =>
|
||||||
|
character.charCodeAt(0),
|
||||||
|
);
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
transfer.items.add(
|
||||||
|
new File([bytes], "four-colours.png", { type: "image/png" }),
|
||||||
|
);
|
||||||
|
const input = element as HTMLInputElement;
|
||||||
|
input.files = transfer.files;
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}, FOUR_COLOUR_PNG);
|
||||||
|
await expect(page.locator(".image-picker-lab__status")).toContainText(
|
||||||
|
"four-colours.png loaded: 2 by 2 pixels",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Sample coordinate" }).click();
|
||||||
|
await expect(page.getByLabel("Sampled colour #FF0000")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Extract palette" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("list", { name: "Extracted image palette" }),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
expect(
|
||||||
|
await page
|
||||||
|
.getByRole("list", { name: "Extracted image palette" })
|
||||||
|
.getByRole("listitem")
|
||||||
|
.count(),
|
||||||
|
).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Remove image" }).click();
|
||||||
|
await expect(page.locator(".image-picker-lab__status")).toContainText(
|
||||||
|
"Image removed",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(externalRequests).toEqual([]);
|
||||||
|
expect(runtimeErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serves a relocatable production artifact with hardened headers", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const index = await request.get("/deep/nested/colour/");
|
||||||
|
expect(index.ok()).toBe(true);
|
||||||
|
expect(index.headers()["content-security-policy"]).toContain(
|
||||||
|
"default-src 'self'",
|
||||||
|
);
|
||||||
|
expect(index.headers()["x-content-type-options"]).toBe("nosniff");
|
||||||
|
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||||
|
|
||||||
|
const manifest = await request.get("/deep/nested/colour/toolbox-app.json");
|
||||||
|
expect(manifest.headers()["content-type"]).toContain("application/json");
|
||||||
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
|
id: "de.add-ideas.colour-tools",
|
||||||
|
version: "0.1.0",
|
||||||
|
entry: "./",
|
||||||
|
icon: "./favicon.svg",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
colourHarmony,
|
||||||
|
deltaE,
|
||||||
|
exportPalette,
|
||||||
|
formatColour,
|
||||||
|
normaliseTokenName,
|
||||||
|
parsePaletteList,
|
||||||
|
shades,
|
||||||
|
simulateColourVision,
|
||||||
|
simulateColourVisionSet,
|
||||||
|
tints,
|
||||||
|
tones,
|
||||||
|
} from "../../src/colour";
|
||||||
|
|
||||||
|
describe("comparison and colour-vision tools", () => {
|
||||||
|
it("provides standard Delta-E methods", () => {
|
||||||
|
expect(deltaE("#f8f5ee", "#f8f5ee", "2000")).toBe(0);
|
||||||
|
expect(deltaE("red", "blue", "76")).toBeGreaterThan(100);
|
||||||
|
expect(deltaE("#777", "#787878", "ok")).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("simulates four deficiencies while preserving alpha", () => {
|
||||||
|
const original = "rgba(200, 40, 20, .4)";
|
||||||
|
const unchanged = simulateColourVision(original, "deuteranopia", {
|
||||||
|
severity: 0,
|
||||||
|
});
|
||||||
|
expect(formatColour(unchanged, "hex8")).toBe("#c8281466");
|
||||||
|
const simulated = simulateColourVision(original, "deuteranopia");
|
||||||
|
expect(simulated.alpha).toBeCloseTo(0.4);
|
||||||
|
expect(formatColour(simulated, "hex")).not.toBe("#c82814");
|
||||||
|
expect(Object.keys(simulateColourVisionSet(original))).toEqual([
|
||||||
|
"protanopia",
|
||||||
|
"deuteranopia",
|
||||||
|
"tritanopia",
|
||||||
|
"achromatopsia",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns achromatopsia output neutral", () => {
|
||||||
|
const simulated = simulateColourVision("#ff8000", "achromatopsia");
|
||||||
|
expect(simulated.coords[0]).toBeCloseTo(simulated.coords[1]!, 8);
|
||||||
|
expect(simulated.coords[1]).toBeCloseTo(simulated.coords[2]!, 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("palette construction and export", () => {
|
||||||
|
it("builds useful hue harmonies", () => {
|
||||||
|
expect(colourHarmony("oklch(60% .15 20)", "complementary")).toHaveLength(2);
|
||||||
|
expect(colourHarmony("oklch(60% .15 20)", "analogous")).toHaveLength(3);
|
||||||
|
expect(colourHarmony("oklch(60% .15 20)", "square")).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds deterministic tint, shade and tone scales", () => {
|
||||||
|
expect(tints("#336699", 3)).toHaveLength(3);
|
||||||
|
expect(shades("#336699", 3, { includeBase: true })).toHaveLength(4);
|
||||||
|
expect(tones("#336699", 2, { includeEndpoint: true })).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
formatColour(tints("black", 1, { includeEndpoint: true })[0]!, "hex"),
|
||||||
|
).toBe("#ffffff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses named text, CSS variables, arrays and token JSON", () => {
|
||||||
|
const text = parsePaletteList("Primary: #f8f5ee\n--accent: rgb(20 40 60)");
|
||||||
|
expect(text.map((entry) => entry.name)).toEqual(["Primary", "accent"]);
|
||||||
|
expect(parsePaletteList('["red", "blue"]')).toHaveLength(2);
|
||||||
|
const tokens = parsePaletteList(
|
||||||
|
'{"brand":{"$type":"color","$value":"#123456"}}',
|
||||||
|
);
|
||||||
|
expect(formatColour(tokens[0]!.colour, "hex")).toBe("#123456");
|
||||||
|
const nested = parsePaletteList(
|
||||||
|
'{"brand":{"light":{"$type":"color","$value":"#eee"},"dark":{"$value":"#111"}}}',
|
||||||
|
);
|
||||||
|
expect(nested.map((entry) => entry.name)).toEqual([
|
||||||
|
"brand-light",
|
||||||
|
"brand-dark",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
parsePaletteList(":root { --paper: #f8f5ee; --ink: #111; }"),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises and de-duplicates portable token names", () => {
|
||||||
|
expect(normaliseTokenName(" Crème brûlée / 100 ")).toBe(
|
||||||
|
"creme-brulee-100",
|
||||||
|
);
|
||||||
|
const entries = parsePaletteList("Brand: red\nBrand: blue");
|
||||||
|
expect(exportPalette(entries, "css", { prefix: "App" })).toContain(
|
||||||
|
"--app-brand-2: #0000ff;",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports CSS, Sass, JSON, DTCG, Tailwind and CSV", () => {
|
||||||
|
const entries = parsePaletteList("paper: #f8f5ee\nink: #111");
|
||||||
|
expect(exportPalette(entries, "css")).toContain("--colour-paper: #f8f5ee;");
|
||||||
|
expect(exportPalette(entries, "scss")).toContain("$colour-ink: #111111;");
|
||||||
|
expect(JSON.parse(exportPalette(entries, "json"))).toEqual({
|
||||||
|
paper: "#f8f5ee",
|
||||||
|
ink: "#111111",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(exportPalette(entries, "tokens")).paper).toEqual({
|
||||||
|
$type: "color",
|
||||||
|
$value: "#f8f5ee",
|
||||||
|
});
|
||||||
|
expect(exportPalette(entries, "tailwind")).toMatch(/^export default /);
|
||||||
|
expect(exportPalette(entries, "csv")).toBe(
|
||||||
|
"name,value\npaper,#f8f5ee\nink,#111111",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import fc from "fast-check";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { compositeLayers, compositeSourceOver } from "../../src/colour";
|
||||||
|
import type { BlendMode } from "../../src/colour";
|
||||||
|
|
||||||
|
describe("source-over compositing", () => {
|
||||||
|
it("matches the canonical translucent-red-over-white result", () => {
|
||||||
|
const result = compositeSourceOver("rgba(255, 0, 0, .5)", "white");
|
||||||
|
expect(result.colour.coords).toEqual([1, 0.5, 0.5]);
|
||||||
|
expect(result.colour.alpha).toBe(1);
|
||||||
|
expect(result.hex).toBe("#ff8080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composites an arbitrary RGBA reference with the source-over equation", () => {
|
||||||
|
const result = compositeSourceOver(
|
||||||
|
"rgba(0, 129, 255, .42)",
|
||||||
|
"rgb(255, 255, 255)",
|
||||||
|
);
|
||||||
|
expect(result.colour.coords[0]).toBeCloseTo(0.58, 12);
|
||||||
|
expect(result.colour.coords[1]).toBeCloseTo(0.7924705882352941, 12);
|
||||||
|
expect(result.colour.coords[2]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports N layers and an explicit stack order", () => {
|
||||||
|
const layers = [
|
||||||
|
{ colour: "white" },
|
||||||
|
{ colour: "rgba(255,0,0,.5)" },
|
||||||
|
{ colour: "rgba(0,0,255,.5)" },
|
||||||
|
];
|
||||||
|
expect(compositeLayers(layers).hex).toBe("#8040bf");
|
||||||
|
expect(
|
||||||
|
compositeLayers([...layers].reverse(), { order: "top-to-bottom" }).hex,
|
||||||
|
).toBe("#8040bf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers visibly different sRGB and linear-light workflows", () => {
|
||||||
|
const srgb = compositeSourceOver("rgba(255,0,0,.5)", "#00ff00", {
|
||||||
|
space: "srgb",
|
||||||
|
});
|
||||||
|
const linear = compositeSourceOver("rgba(255,0,0,.5)", "#00ff00", {
|
||||||
|
space: "linear-srgb",
|
||||||
|
});
|
||||||
|
expect(srgb.hex).toBe("#808000");
|
||||||
|
expect(linear.hex).toBe("#bcbc00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("implements opacity and common blend modes", () => {
|
||||||
|
expect(
|
||||||
|
compositeSourceOver("#ff0000", "#808080", { blendMode: "multiply" }).hex,
|
||||||
|
).toBe("#800000");
|
||||||
|
expect(
|
||||||
|
compositeSourceOver("#ff0000", "#808080", { blendMode: "screen" }).hex,
|
||||||
|
).toBe("#ff8080");
|
||||||
|
expect(compositeSourceOver("#ff0000", "#ffffff", { opacity: 0 }).hex).toBe(
|
||||||
|
"#ffffff",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every exposed blend mode renderable", () => {
|
||||||
|
const modes: BlendMode[] = [
|
||||||
|
"normal",
|
||||||
|
"multiply",
|
||||||
|
"screen",
|
||||||
|
"overlay",
|
||||||
|
"darken",
|
||||||
|
"lighten",
|
||||||
|
"color-dodge",
|
||||||
|
"color-burn",
|
||||||
|
"hard-light",
|
||||||
|
"soft-light",
|
||||||
|
"difference",
|
||||||
|
"exclusion",
|
||||||
|
"hue",
|
||||||
|
"saturation",
|
||||||
|
"color",
|
||||||
|
"luminosity",
|
||||||
|
];
|
||||||
|
for (const blendMode of modes) {
|
||||||
|
const result = compositeSourceOver("rgba(230,30,80,.7)", "#2878c8", {
|
||||||
|
blendMode,
|
||||||
|
});
|
||||||
|
expect(result.colour.coords.every(Number.isFinite), blendMode).toBe(true);
|
||||||
|
expect(result.hex, blendMode).toMatch(/^#[0-9a-f]{6}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps arbitrary composites bounded and alpha-correct", () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.tuple(
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
),
|
||||||
|
fc.tuple(
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
fc.double({ min: 0, max: 1, noNaN: true }),
|
||||||
|
),
|
||||||
|
(foreground, background) => {
|
||||||
|
const result = compositeSourceOver(
|
||||||
|
{
|
||||||
|
space: "srgb",
|
||||||
|
coords: foreground.slice(0, 3) as [number, number, number],
|
||||||
|
alpha: foreground[3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
space: "srgb",
|
||||||
|
coords: background.slice(0, 3) as [number, number, number],
|
||||||
|
alpha: background[3],
|
||||||
|
},
|
||||||
|
).colour;
|
||||||
|
expect(
|
||||||
|
result.coords.every((channel) => channel >= 0 && channel <= 1),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.alpha).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.alpha).toBeLessThanOrEqual(1);
|
||||||
|
expect(result.alpha).toBeCloseTo(
|
||||||
|
foreground[3] + background[3] * (1 - foreground[3]),
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ numRuns: 100 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
contrastRatio,
|
||||||
|
contrastReport,
|
||||||
|
formatColour,
|
||||||
|
gamutReport,
|
||||||
|
interpolateColourStops,
|
||||||
|
interpolateStops,
|
||||||
|
mapToGamut,
|
||||||
|
nearestPassingColour,
|
||||||
|
relativeLuminance,
|
||||||
|
} from "../../src/colour";
|
||||||
|
|
||||||
|
describe("multi-stop interpolation", () => {
|
||||||
|
it("interpolates across positioned stops", () => {
|
||||||
|
const stops = [
|
||||||
|
{ colour: "#ff0000", position: 0 },
|
||||||
|
{ colour: "#00ff00", position: 0.5 },
|
||||||
|
{ colour: "#0000ff", position: 1 },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
formatColour(
|
||||||
|
interpolateColourStops(stops, 0.25, { space: "srgb" }),
|
||||||
|
"hex",
|
||||||
|
),
|
||||||
|
).toBe("#808000");
|
||||||
|
expect(
|
||||||
|
formatColour(
|
||||||
|
interpolateColourStops(stops, 0.75, { space: "srgb" }),
|
||||||
|
"hex",
|
||||||
|
),
|
||||||
|
).toBe("#008080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distributes omitted positions and includes exact endpoints", () => {
|
||||||
|
const steps = interpolateStops(
|
||||||
|
[{ colour: "black" }, { colour: "red" }, { colour: "white" }],
|
||||||
|
5,
|
||||||
|
{
|
||||||
|
space: "srgb",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(steps.map((step) => step.hex)).toEqual([
|
||||||
|
"#000000",
|
||||||
|
"#800000",
|
||||||
|
"#ff0000",
|
||||||
|
"#ff8080",
|
||||||
|
"#ffffff",
|
||||||
|
]);
|
||||||
|
expect(steps.map((step) => step.position)).toEqual([0, 0.25, 0.5, 0.75, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the shorter hue path across zero", () => {
|
||||||
|
const middle = interpolateColourStops(
|
||||||
|
[{ colour: "hsl(350 100% 50%)" }, { colour: "hsl(10 100% 50%)" }],
|
||||||
|
0.5,
|
||||||
|
{ space: "hsl", hue: "shorter" },
|
||||||
|
);
|
||||||
|
expect(formatColour(middle, "hex")).toBe("#ff0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports deterministic easing", () => {
|
||||||
|
const linear = interpolateColourStops(
|
||||||
|
[{ colour: "black" }, { colour: "white" }],
|
||||||
|
0.5,
|
||||||
|
{ space: "srgb" },
|
||||||
|
);
|
||||||
|
const eased = interpolateColourStops(
|
||||||
|
[{ colour: "black" }, { colour: "white" }],
|
||||||
|
0.5,
|
||||||
|
{
|
||||||
|
space: "srgb",
|
||||||
|
easing: "ease-in",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(linear.coords[0]).toBeCloseTo(0.5);
|
||||||
|
expect(eased.coords[0]).toBeCloseTo(0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("premultiplies alpha by default to avoid transparent colour fringes", () => {
|
||||||
|
const premultiplied = interpolateColourStops(
|
||||||
|
[{ colour: "rgba(255, 0, 0, 0)" }, { colour: "rgba(0, 0, 255, 1)" }],
|
||||||
|
0.5,
|
||||||
|
{ space: "srgb" },
|
||||||
|
);
|
||||||
|
const straight = interpolateColourStops(
|
||||||
|
[{ colour: "rgba(255, 0, 0, 0)" }, { colour: "rgba(0, 0, 255, 1)" }],
|
||||||
|
0.5,
|
||||||
|
{ space: "srgb", premultiplied: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(premultiplied.alpha).toBeCloseTo(0.5);
|
||||||
|
expect(premultiplied.coords).toEqual([0, 0, 1]);
|
||||||
|
expect(straight.coords).toEqual([0.5, 0, 0.5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gamut and accessibility", () => {
|
||||||
|
it("reports and maps wide-gamut colours", () => {
|
||||||
|
const report = gamutReport("color(display-p3 1 0 0)");
|
||||||
|
expect(report.spaces.find((space) => space.space === "srgb")?.inGamut).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(report.spaces.find((space) => space.space === "p3")?.inGamut).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
report.spaces.find((space) => space.space === "p3")?.mappedCss,
|
||||||
|
).toMatch(/^color\(display-p3 /);
|
||||||
|
const mapped = mapToGamut("color(display-p3 1 0 0)");
|
||||||
|
expect(
|
||||||
|
mapped.coords.every((channel) => channel >= -1e-9 && channel <= 1 + 1e-9),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses WCAG relative luminance and contrast thresholds", () => {
|
||||||
|
expect(relativeLuminance("black")).toBe(0);
|
||||||
|
expect(relativeLuminance("white")).toBe(1);
|
||||||
|
expect(contrastRatio("black", "white")).toBe(21);
|
||||||
|
const report = contrastReport("#777", "white");
|
||||||
|
expect(report.ratio).toBeCloseTo(4.478089, 5);
|
||||||
|
expect(report.passes.aaNormal).toBe(false);
|
||||||
|
expect(report.passes.aaLarge).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattens alpha against the visible background", () => {
|
||||||
|
expect(contrastRatio("rgba(0,0,0,.5)", "white")).toBeCloseTo(3.976653, 5);
|
||||||
|
expect(
|
||||||
|
contrastReport("black", "rgba(255,255,255,.5)", { canvas: "black" })
|
||||||
|
.ratio,
|
||||||
|
).toBeCloseTo(5.280823, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a nearby foreground that passes a requested target", () => {
|
||||||
|
const suggestion = nearestPassingColour("#777", "white", 4.5);
|
||||||
|
expect(suggestion).not.toBeNull();
|
||||||
|
expect(suggestion?.direction).toBe("darker");
|
||||||
|
expect(suggestion?.ratio).toBeGreaterThanOrEqual(4.5);
|
||||||
|
expect(suggestion?.deltaEOK).toBeLessThan(0.02);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import fc from "fast-check";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
conversionRows,
|
||||||
|
formatColour,
|
||||||
|
parseColour,
|
||||||
|
parseColourList,
|
||||||
|
toSrgbPreview,
|
||||||
|
tryParseColour,
|
||||||
|
} from "../../src/colour";
|
||||||
|
|
||||||
|
describe("colour parsing and conversion", () => {
|
||||||
|
it("converts the RapidTables reference colour", () => {
|
||||||
|
const colour = parseColour("#f8f5ee");
|
||||||
|
expect(colour.space).toBe("srgb");
|
||||||
|
expect(colour.coords).toEqual([248 / 255, 245 / 255, 238 / 255]);
|
||||||
|
expect(formatColour(colour, "hex")).toBe("#f8f5ee");
|
||||||
|
expect(formatColour(colour, "rgb")).toBe("rgb(248, 245, 238)");
|
||||||
|
expect(formatColour(colour, "hsl")).toBe("hsl(42 41.6667% 95.2941%)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts CSS Color 4, named, bare hex and numeric RGB input", () => {
|
||||||
|
expect(formatColour("rebeccapurple", "hex")).toBe("#663399");
|
||||||
|
expect(formatColour("f8f5ee", "hex")).toBe("#f8f5ee");
|
||||||
|
expect(formatColour("0xff000080", "hex8")).toBe("#ff000080");
|
||||||
|
expect(formatColour("248, 245, 238", "hex")).toBe("#f8f5ee");
|
||||||
|
const p3 = parseColour("color(display-p3 1 0.2 0.1 / 75%)");
|
||||||
|
expect(p3.space).toBe("p3");
|
||||||
|
expect(p3.alpha).toBeCloseTo(0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses custom HSV/HSB with angles and alpha", () => {
|
||||||
|
expect(formatColour("hsv(120 50% 60% / 25%)", "rgba")).toBe(
|
||||||
|
"rgba(77, 153, 77, 0.25)",
|
||||||
|
);
|
||||||
|
expect(formatColour("hsb(.333333turn, .5, .6)", "hex")).toBe("#4d994d");
|
||||||
|
expect(parseColour("hsv(-30deg 100% 100%)").coords[0]).toBe(330);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses custom CMYK and converts it deterministically", () => {
|
||||||
|
expect(formatColour("cmyk(0% 100% 100% 0% / .5)", "hex8")).toBe(
|
||||||
|
"#ff000080",
|
||||||
|
);
|
||||||
|
expect(formatColour("cmyk(100%, 0%, 100%, 25%)", "rgb")).toBe(
|
||||||
|
"rgb(0, 191, 0)",
|
||||||
|
);
|
||||||
|
expect(formatColour("#000", "cmyk")).toBe("cmyk(0% 0% 0% 100%)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns structured failures without throwing", () => {
|
||||||
|
expect(tryParseColour("")).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "empty" },
|
||||||
|
});
|
||||||
|
expect(tryParseColour("hsv(20 200% 20%)")).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "out-of-range" },
|
||||||
|
});
|
||||||
|
expect(tryParseColour("definitely-not-a-colour").ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits lists only at top-level delimiters", () => {
|
||||||
|
const colours = parseColourList("rgb(1, 2, 3), hsl(20 30% 40%);\n#fff");
|
||||||
|
expect(colours).toHaveLength(3);
|
||||||
|
expect(formatColour(colours[0]!, "rgb")).toBe("rgb(1, 2, 3)");
|
||||||
|
expect(parseColourList('["#f00", "#0f0"]')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers copy-ready conversion rows and a safe preview", () => {
|
||||||
|
const rows = conversionRows("#f8f5ee", ["hex", "rgb", "oklch"]);
|
||||||
|
expect(rows.map((row) => row.label)).toEqual(["HEX", "RGB", "OKLCH"]);
|
||||||
|
expect(rows[0]?.copyValue).toBe("#f8f5ee");
|
||||||
|
const preview = toSrgbPreview("color(display-p3 1 0 0)");
|
||||||
|
expect(preview.wasMapped).toBe(true);
|
||||||
|
expect(preview.css).toMatch(/^rgb\(/);
|
||||||
|
expect(preview.rgb.every((channel) => channel >= 0 && channel <= 1)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips arbitrary 8-bit RGB colours through HEX", () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(
|
||||||
|
fc.integer({ min: 0, max: 255 }),
|
||||||
|
fc.integer({ min: 0, max: 255 }),
|
||||||
|
fc.integer({ min: 0, max: 255 }),
|
||||||
|
(red, green, blue) => {
|
||||||
|
const value = {
|
||||||
|
space: "srgb",
|
||||||
|
coords: [red / 255, green / 255, blue / 255] as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
],
|
||||||
|
alpha: 1,
|
||||||
|
};
|
||||||
|
expect(parseColour(formatColour(value, "hex")).coords).toEqual(
|
||||||
|
value.coords,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ numRuns: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CompositeLab } from "../../src/components/CompositeLab";
|
||||||
|
import { ConverterLab } from "../../src/components/ConverterLab";
|
||||||
|
import { PickerLab } from "../../src/components/PickerLab";
|
||||||
|
import { Workbench } from "../../src/components/Workbench";
|
||||||
|
|
||||||
|
describe("colour workbench", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
history.replaceState(null, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes all seven workspaces as accessible tabs", () => {
|
||||||
|
render(<Workbench />);
|
||||||
|
|
||||||
|
const tabs = screen.getAllByRole("tab");
|
||||||
|
expect(tabs).toHaveLength(7);
|
||||||
|
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Convert colours" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports keyboard navigation between workspaces", async () => {
|
||||||
|
render(<Workbench />);
|
||||||
|
const convert = screen.getByRole("tab", { name: /Convert/ });
|
||||||
|
convert.focus();
|
||||||
|
await userEvent.keyboard("{ArrowRight}");
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: /Composite/ })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Composite translucent colours" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("interactive colour labs", () => {
|
||||||
|
it("keeps the last valid conversion visible while an input is incomplete", async () => {
|
||||||
|
render(<ConverterLab onAddColour={vi.fn()} />);
|
||||||
|
const source = screen.getByLabelText("Colour");
|
||||||
|
await userEvent.clear(source);
|
||||||
|
await userEvent.type(source, "oklch(");
|
||||||
|
|
||||||
|
expect(source).toHaveAttribute("aria-invalid", "true");
|
||||||
|
expect(screen.getByText(/Keeping the last valid conversion/)).toBeVisible();
|
||||||
|
expect(screen.getByText("HEX + alpha")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces an opaque composite over the enabled matte", () => {
|
||||||
|
render(<CompositeLab onAddColour={vi.fn()} />);
|
||||||
|
const result = screen.getByText("Result").closest("aside");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(within(result!).getByText(/^#[0-9a-f]{6}$/i)).toBeVisible();
|
||||||
|
expect(screen.getByText(/Produces an opaque RGB result/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the exact picker field in sync with visual controls", () => {
|
||||||
|
render(<PickerLab onAddColour={vi.fn()} />);
|
||||||
|
const hue = screen.getByLabelText(/Hue ·/);
|
||||||
|
fireEvent.change(hue, { target: { value: "0" } });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Exact CSS colour")).toHaveValue("#d14f4f");
|
||||||
|
expect(screen.getByText("#d14f4f")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getBoundedSampleRegion,
|
||||||
|
mapClientPointToPixel,
|
||||||
|
validateImageDimensions,
|
||||||
|
validateImageFile,
|
||||||
|
} from "../../src/palette/bounds";
|
||||||
|
import type { ImageLimits } from "../../src/palette/types";
|
||||||
|
|
||||||
|
const limits: ImageLimits = {
|
||||||
|
maxBytes: 1_000,
|
||||||
|
maxPixels: 100,
|
||||||
|
maxDimension: 20,
|
||||||
|
acceptedMimeTypes: ["image/png"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("local image bounds", () => {
|
||||||
|
it("rejects empty, oversized, and unsupported files", () => {
|
||||||
|
expect(
|
||||||
|
validateImageFile(
|
||||||
|
{ name: "empty.png", size: 0, type: "image/png" },
|
||||||
|
limits,
|
||||||
|
).valid,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
validateImageFile(
|
||||||
|
{ name: "large.png", size: 1_001, type: "image/png" },
|
||||||
|
limits,
|
||||||
|
).valid,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
validateImageFile(
|
||||||
|
{ name: "vector.svg", size: 10, type: "image/svg+xml" },
|
||||||
|
limits,
|
||||||
|
).valid,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bounded raster file", () => {
|
||||||
|
expect(
|
||||||
|
validateImageFile(
|
||||||
|
{ name: "pixel.png", size: 999, type: "IMAGE/PNG" },
|
||||||
|
limits,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
valid: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
validateImageFile({ name: "pixel.png", size: 999, type: "" }, limits)
|
||||||
|
.valid,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks decoded dimensions and pixel area", () => {
|
||||||
|
expect(validateImageDimensions(10, 10, limits).valid).toBe(true);
|
||||||
|
expect(validateImageDimensions(21, 2, limits).valid).toBe(false);
|
||||||
|
expect(validateImageDimensions(11, 10, limits).valid).toBe(false);
|
||||||
|
expect(validateImageDimensions(Number.NaN, 10, limits).valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps CSS-scaled canvas coordinates to bounded image pixels", () => {
|
||||||
|
const rect = { left: 100, top: 50, width: 200, height: 100 };
|
||||||
|
expect(mapClientPointToPixel(200, 100, rect, 1_000, 500)).toEqual({
|
||||||
|
x: 500,
|
||||||
|
y: 250,
|
||||||
|
});
|
||||||
|
expect(mapClientPointToPixel(500, -20, rect, 1_000, 500)).toEqual({
|
||||||
|
x: 999,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
mapClientPointToPixel(200, 100, { ...rect, width: 0 }, 1_000, 500),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clips sampling regions at image edges", () => {
|
||||||
|
expect(getBoundedSampleRegion(0, 0, 2, 10, 8)).toEqual({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
centerX: 0,
|
||||||
|
centerY: 0,
|
||||||
|
});
|
||||||
|
expect(getBoundedSampleRegion(Number.NaN, 0, 2, 10, 8)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { decodeLocalImage } from "../../src/palette/decode";
|
||||||
|
|
||||||
|
const nativeUrl = globalThis.URL;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const installImageElementDecoder = (loadImmediately: boolean) => {
|
||||||
|
const createObjectURL = vi.fn(() => "blob:local-image");
|
||||||
|
const revokeObjectURL = vi.fn();
|
||||||
|
class MockUrl extends nativeUrl {
|
||||||
|
static createObjectURL = createObjectURL;
|
||||||
|
static revokeObjectURL = revokeObjectURL;
|
||||||
|
}
|
||||||
|
class MockImage {
|
||||||
|
decoding = "auto";
|
||||||
|
naturalWidth = 320;
|
||||||
|
naturalHeight = 180;
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
private source = "";
|
||||||
|
|
||||||
|
set src(value: string) {
|
||||||
|
this.source = value;
|
||||||
|
if (value && loadImmediately) queueMicrotask(() => this.onload?.());
|
||||||
|
}
|
||||||
|
|
||||||
|
get src(): string {
|
||||||
|
return this.source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal("URL", MockUrl);
|
||||||
|
vi.stubGlobal("Image", MockImage);
|
||||||
|
vi.stubGlobal("createImageBitmap", undefined);
|
||||||
|
return { createObjectURL, revokeObjectURL };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("local image decoding", () => {
|
||||||
|
it("revokes its object URL as soon as the image has decoded", async () => {
|
||||||
|
const spies = installImageElementDecoder(true);
|
||||||
|
const image = await decodeLocalImage(
|
||||||
|
new File(["image"], "sample.png", { type: "image/png" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(image).toMatchObject({ width: 320, height: 180 });
|
||||||
|
expect(spies.createObjectURL).toHaveBeenCalledOnce();
|
||||||
|
expect(spies.revokeObjectURL).toHaveBeenCalledWith("blob:local-image");
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokes its object URL when decoding is cancelled", async () => {
|
||||||
|
const spies = installImageElementDecoder(false);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const decoding = decodeLocalImage(
|
||||||
|
new File(["image"], "sample.png", { type: "image/png" }),
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(decoding).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(spies.revokeObjectURL).toHaveBeenCalledWith("blob:local-image");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { extractPalette } from "../../src/palette/extract";
|
||||||
|
import { extractPaletteSafely } from "../../src/palette/worker-client";
|
||||||
|
|
||||||
|
const makePixels = (
|
||||||
|
colours: readonly (readonly [number, number, number, number])[],
|
||||||
|
) => ({
|
||||||
|
width: colours.length,
|
||||||
|
height: 1,
|
||||||
|
data: new Uint8ClampedArray(colours.flat()),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deterministic Oklab palette extraction", () => {
|
||||||
|
it("returns dominant colours in coverage order", () => {
|
||||||
|
const input = makePixels([
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[0, 0, 255, 255],
|
||||||
|
]);
|
||||||
|
const palette = extractPalette(input, { count: 2 });
|
||||||
|
|
||||||
|
expect(palette.map((colour) => colour.hex)).toEqual(["#FF0000", "#0000FF"]);
|
||||||
|
expect(palette[0]?.coverage).toBeCloseTo(0.75, 8);
|
||||||
|
expect(palette[1]?.coverage).toBeCloseTo(0.25, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces byte-for-byte stable results for repeated runs", () => {
|
||||||
|
const input = makePixels([
|
||||||
|
[248, 245, 238, 255],
|
||||||
|
[20, 80, 180, 255],
|
||||||
|
[245, 140, 20, 255],
|
||||||
|
[248, 245, 238, 255],
|
||||||
|
[25, 85, 175, 220],
|
||||||
|
[240, 145, 25, 255],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const first = extractPalette(input, { count: 3 });
|
||||||
|
const second = extractPalette(input, { count: 3 });
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores fully transparent pixels and respects the alpha threshold", () => {
|
||||||
|
const input = makePixels([
|
||||||
|
[255, 0, 255, 0],
|
||||||
|
[255, 0, 0, 15],
|
||||||
|
[0, 255, 0, 255],
|
||||||
|
[0, 0, 255, 255],
|
||||||
|
]);
|
||||||
|
const palette = extractPalette(input, { count: 3, minimumAlpha: 16 });
|
||||||
|
|
||||||
|
expect(palette.map((colour) => colour.hex)).toEqual(["#0000FF", "#00FF00"]);
|
||||||
|
expect(
|
||||||
|
palette.reduce((sum, colour) => sum + colour.coverage, 0),
|
||||||
|
).toBeCloseTo(1, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a fully transparent image without inventing colours", () => {
|
||||||
|
expect(extractPalette(makePixels([[10, 20, 30, 0]]), { count: 6 })).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed pixel buffers", () => {
|
||||||
|
expect(() =>
|
||||||
|
extractPalette(
|
||||||
|
{ width: 2, height: 2, data: new Uint8ClampedArray(4) },
|
||||||
|
{ count: 2 },
|
||||||
|
),
|
||||||
|
).toThrow("Invalid pixel buffer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back safely when Web Workers are unavailable", async () => {
|
||||||
|
const input = makePixels([
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[0, 0, 255, 255],
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.stubGlobal("Worker", undefined);
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
extractPaletteSafely(input, { count: 2 }),
|
||||||
|
).resolves.toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { samplePixels } from "../../src/palette/sample";
|
||||||
|
|
||||||
|
const pixels = (
|
||||||
|
width: number,
|
||||||
|
colours: readonly (readonly [number, number, number, number])[],
|
||||||
|
) => ({
|
||||||
|
width,
|
||||||
|
height: colours.length / width,
|
||||||
|
data: new Uint8ClampedArray(colours.flat()),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pixel sampling", () => {
|
||||||
|
it("returns the exact pixel for a zero-radius sample", () => {
|
||||||
|
const result = samplePixels(
|
||||||
|
pixels(2, [
|
||||||
|
[12, 34, 56, 255],
|
||||||
|
[200, 100, 50, 128],
|
||||||
|
]),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"average",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
r: 200,
|
||||||
|
g: 100,
|
||||||
|
b: 50,
|
||||||
|
a: 128,
|
||||||
|
hex: "#C8643280",
|
||||||
|
pixelCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a circular neighbourhood and alpha-weighted RGB average", () => {
|
||||||
|
const data = pixels(3, [
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[255, 0, 0, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 255, 0, 255],
|
||||||
|
[0, 0, 255, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
]);
|
||||||
|
const result = samplePixels(data, 1, 1, 1, "average");
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
r: 170,
|
||||||
|
g: 170,
|
||||||
|
b: 85,
|
||||||
|
a: 153,
|
||||||
|
pixelCount: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates channel medians from visible samples", () => {
|
||||||
|
const result = samplePixels(
|
||||||
|
pixels(3, [
|
||||||
|
[10, 90, 200, 255],
|
||||||
|
[200, 50, 10, 128],
|
||||||
|
[100, 10, 90, 0],
|
||||||
|
]),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
"median",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
r: 105,
|
||||||
|
g: 70,
|
||||||
|
b: 105,
|
||||||
|
a: 128,
|
||||||
|
pixelCount: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid coordinates and truncated buffers", () => {
|
||||||
|
expect(
|
||||||
|
samplePixels(pixels(1, [[1, 2, 3, 4]]), 1, 0, 0, "average"),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
samplePixels(
|
||||||
|
{ width: 2, height: 2, data: new Uint8ClampedArray(4) },
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"average",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"playwright.config.ts",
|
||||||
|
"eslint.config.mjs",
|
||||||
|
"scripts/**/*.mjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "./",
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./src/test/setup.ts",
|
||||||
|
css: true,
|
||||||
|
maxWorkers: 2,
|
||||||
|
restoreMocks: true,
|
||||||
|
exclude: ["tests/browser/**", "node_modules/**", "dist/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user