colorsbymax 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,138 +1,164 @@
1
- # colorsbymax™
2
-
3
- **by mrmaxdesigns**
4
-
5
- A floating theme switcher for websites. Visitors (or the site's owner) can re-colour the whole site instantly: pick one of the site's own themes, a hand-tuned pick, or one of 715 library themes in 14 categories; build and share custom palettes; or override single colours. Every theme is checked against the Web Content Accessibility Guidelines (WCAG) contrast rules, with one-click fixes.
6
-
7
- ## Features
8
-
9
- - **Site themes first.** The panel opens on a group named after the site, holding its own colours. If those fail contrast, an accessible version is generated automatically.
10
- - **Scan the site.** Press "Scan site" and colorsbymax reads the colours actually painted on the page (ignoring any theme it has applied, and including gradients). It works out the page background, surfaces, text and brand colours, then adds themes named after the site: Scanned (as found), Accessible, Soft, Bold, Complementary, and the closest library matches. Scans run only when asked, and the results are remembered.
11
- - **Max’s picks and library.** 5 hand-tuned picks, plus 715 library themes (Bright, Fun, Pastel, Earth tones, Summer, Autumn, Winter, Spring, Ocean, Warm, Nature, Moody, Monochrome, Eclectic) with search and "Surprise me". The library loads only when the panel opens.
12
- - **Custom palettes, single-colour overrides, JSON import/export.**
13
- - **Palettes from images and PDFs.** In Import / export, upload or drop a mood board, screenshot, photo or brand guide. colorsbymax picks out its main colours (hex codes written in a PDF take priority), lets you leave any out, previews the palette it builds around them and saves it as a custom palette. Nothing leaves the browser; PDFs are read with [PDF.js](https://mozilla.github.io/pdf.js/), downloaded only when a PDF is picked.
14
- - **Contrast checks.** Problems show as a badge on the theme; the breakdown offers per-item fixes or "Fix all automatically", which changes lightness only.
15
- - **No flash on reload.** An inline pre-paint script applies the saved theme before the page draws.
16
- - **Works on any site.** The panel carries its own stylesheet inside a shadow root, so it needs no Tailwind or other CSS from the site, and the site's CSS can't restyle it.
17
- - **Movable colour button.** The floating button starts in the corner; visitors can drag it anywhere (mouse or touch; a tooltip says so on hover, and the panel closes while it moves) and it stays there, remembered across reloads. The panel then opens beside it, on whichever side has room. Its dot cycles through the current theme's colours.
18
- - **Light and dark mode.** Every built-in theme is designed light; dark mode lists a generated dark twin of each (dark surfaces, light text, brand colours lifted to read on dark, then contrast-fixed) and switches the current theme to its twin. The panel turns dark with it. Custom palettes stay as they were made.
19
- - **Visitor settings.** The gear in the panel header opens settings: theme mode (Light, Dark, Auto), panel size (Compact, Standard, Large), which groups and sections to show, whether the button can be dragged or its dot animates, and moving the button back to its corner. Saved per site.
20
- - **Resizable panel.** Drag the panel's free edges or corner (the ones away from the colour button) to any size, or pick a size in settings; double-click an edge to reset it. The layout follows the panel's width, so a large panel shows three theme cards a row.
21
- - **Clear groups and feedback.** The site's own group (globe), Max’s picks (paintbrush) and Yours (person) sit in their own row, apart from the library's categories. Toasts confirm what just happened; saving, importing or building a palette says it went to Yours and offers "Show" to jump straight to it. Tooltips are drawn in the panel's colours.
22
- - **Themed scrollbars.** The page's scrollbars and the panel's slim one take the selected theme's primary colour. Turn the page's off with `scrollbars: false`.
23
- - **Accessible panel.** A labelled dialog with focus trap, Escape, the colour button or an outside click to close, keyboard operable, and reduced-motion support.
24
-
25
- ## How it works
26
-
27
- Every colour is one of 35 tokens (`primary`, `surface`, `ink`, `data-1`…), exposed as `--color-<token>` CSS variables. Applying a theme just sets those variables on `<html>`, so anything the site paints with `var(--color-primary)` (directly, or through Tailwind CSS v4 utilities like `bg-primary`) changes instantly. There's no rebuild and no re-render.
28
-
29
- The switcher renders into a `<colorsbymax-root>` element on `<body>` with its own shadow root and stylesheet. Only the `--color-*` variables cross into it. With reduced motion, the colour button's dot holds still on the theme's primary colour.
30
-
31
- ## Try the demo
32
-
33
- ```bash
34
- npm install
35
- npm run dev
36
- ```
37
-
38
- This serves `demo/`: colorsbymax's own landing page, built with Tailwind, where every colour is a token (it uses every token group, and a strip shows the live values), and `/plain.html`, a plain-CSS bakery site with deliberately careless global styles to show they don't reach the panel.
39
-
40
- ## Add it to a site
41
-
42
- colorsbymax needs React 18 or 19:
43
-
44
- ```bash
45
- npm install colorsbymax
46
- ```
47
-
48
- It ships as plain JavaScript (`dist/`), so Vite, Next.js, webpack and other bundlers use it without extra setup. Installing changes nothing on its own; these steps add the colour button:
49
-
50
- 1. **Paint the site with the token variables.** Use `var(--color-<token>)` wherever the site sets a colour, with your own colours as the starting values.
51
-
52
- With Tailwind CSS v4, import the defaults, override them, and use the token utilities (`bg-primary`, `text-ink`, …) instead of hard-coded colours:
53
-
54
- ```css
55
- @import "tailwindcss";
56
- @import "colorsbymax/tokens.css";
57
-
58
- @theme static {
59
- --color-primary: #c67cde; /* your colours */
60
- }
61
- ```
62
-
63
- With plain CSS, define the variables yourself (`demo/plain.html` shows this):
64
-
65
- ```css
66
- :root {
67
- --color-primary: #c67cde;
68
- }
69
- .button {
70
- background: var(--color-primary);
71
- }
72
- ```
73
-
74
- 2. **Wrap the app and render the switcher:**
75
-
76
- ```jsx
77
- import { ThemeProvider, ThemeSwitcher } from 'colorsbymax'
78
-
79
- <ThemeProvider config={config}>
80
- <App />
81
- <ThemeSwitcher />
82
- </ThemeProvider>
83
- ```
84
-
85
- The switcher needs React but not a React site: `demo/plain.jsx` mounts it on its own next to a static page.
86
-
87
- 3. **Add the pre-paint script** to `<head>`, as a classic inline `<script>`, using the same storage key. `prePaintScript(storageKey)` returns its source, and `demo/index.html` shows it in place.
88
-
89
- Without a `siteName`, the site group is named from the page's `og:site_name`, its title or its host name.
90
-
91
- ### Config
92
-
93
- ```js
94
- {
95
- siteName: 'Tsungi', // name of the first theme group
96
- storageKey: 'tsungi-theme', // localStorage key (match the pre-paint script)
97
- defaultTheme: { name, tokens }, // the site's own colours; missing tokens are filled in
98
- themes: [{ id, name, tokens }], // optional extra themes made for the site
99
- usage: { primary: 'Buttons…' }, // optional notes shown in the colour editors
100
- scrollbars: true, // colour the page's scrollbars from the theme (default)
101
- }
102
- ```
103
-
104
- `examples/tsungi.config.js` is a complete example for tsungi.online, the first site to use colorsbymax.
105
-
106
- ## Building the package
107
-
108
- `src/` is the source; `dist/` is what sites install: plain JavaScript with the JSX compiled away. `dist/` is committed so installs straight from GitHub work even when npm skips install scripts, so rebuild it before committing changes to `src/`. `npm publish` also rebuilds it first:
109
-
110
- ```bash
111
- npm run build
112
- ```
113
-
114
- ## Panel styles
115
-
116
- The panel is styled with Tailwind classes in `src/ThemePanel.jsx` and `src/ThemeSwitcher.jsx`. `scripts/build-css.mjs` compiles them, with `src/panel.css`, into `src/panel-css.generated.js`. The demo server does this automatically as you edit; otherwise run:
117
-
118
- ```bash
119
- npm run css
120
- ```
121
-
122
- ## Regenerating the library
123
-
124
- ```bash
125
- npm run presets
126
- ```
127
-
128
- `scripts/generate-presets.mjs` turns each source palette into a full theme, auto-fixes contrast, drops anything that still fails or duplicates another theme, names it and tags its categories. Adjust the category rules at the top of the script.
129
-
130
- ## Notices
131
-
132
- The library palettes come from [nice-color-palettes](https://github.com/Jam3/nice-color-palettes) (MIT). Its licence notice is in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and must ship with every copy.
133
-
134
- PDF reading uses [pdfjs-dist](https://github.com/mozilla/pdf.js) (Apache-2.0), installed as a dependency rather than bundled into colorsbymax.
135
-
136
- ## Licence
137
-
138
- colorsbymax is released under the [MIT Licence](LICENSE). The names colorsbymax™ and mrmaxdesigns are marks of Max Muyalwa and aren't covered by the code licence.
1
+ # colorsbymax™
2
+
3
+ **by mrmaxdesigns**
4
+
5
+ A floating theme switcher for websites. Visitors (or the site's owner) can re-colour the whole site instantly: pick one of the site's own themes, a hand-tuned pick, or one of 715 library themes in 14 categories; build and share custom palettes; or override single colours. Every theme is checked against the Web Content Accessibility Guidelines (WCAG) contrast rules, with one-click fixes.
6
+
7
+ **At a glance**
8
+
9
+ - **React 18 or 19.** `npm install colorsbymax`, then wrap your app (see [Add it to a site](#add-it-to-a-site)).
10
+ - **Tailwind optional.** The panel carries its own styles, so it works with Tailwind v4, older Tailwind or plain CSS. Your site only needs to paint its colours with `var(--color-…)` variables. The optional `colorsbymax/tokens.css` helper is for Tailwind v4 (`@theme` syntax); without Tailwind v4, define the variables yourself.
11
+ - **TypeScript types included.**
12
+ - **No runtime dependencies** besides React. PDF uploads are opt-in and need `pdfjs-dist` (see [PDF uploads](#pdf-uploads)).
13
+ - **ESM only.** Import it from a bundler or `import()`; `require('colorsbymax')` from CommonJS isn't supported.
14
+ - **The colour tools work on their own too.** `contrastRatio`, `checkTheme`, `suggestFix`, `fixAll`, `themeFromPalette`, `darkTokens` and the rest are plain functions with no UI.
15
+
16
+ ## Features
17
+
18
+ - **Site themes first.** The panel opens on a group named after the site, holding its own colours. If those fail contrast, an accessible version is generated automatically.
19
+ - **Scan the site.** Press "Scan site" and colorsbymax reads the colours actually painted on the page (ignoring any theme it has applied, and including gradients). It works out the page background, surfaces, text and brand colours, then adds themes named after the site: Scanned (as found), Accessible, Soft, Bold, Complementary, and the closest library matches. Scans run only when asked, and the results are remembered.
20
+ - **Max’s picks and library.** 5 hand-tuned picks, plus 715 library themes (Bright, Fun, Pastel, Earth tones, Summer, Autumn, Winter, Spring, Ocean, Warm, Nature, Moody, Monochrome, Eclectic) with search and "Surprise me". The library loads only when the panel opens.
21
+ - **Custom palettes, single-colour overrides, JSON import/export.**
22
+ - **Palettes from images and PDFs.** In Import / export, upload or drop a mood board, screenshot or photo. colorsbymax picks out its main colours, lets you leave any out, previews the palette it builds around them and saves it as a custom palette. Nothing leaves the browser. PDFs, such as brand guides, work where the site [turns them on](#pdf-uploads); hex codes written in a PDF take priority.
23
+ - **Contrast checks.** Problems show as a badge on the theme; the breakdown offers per-item fixes or "Fix all automatically", which changes lightness only.
24
+ - **No flash on reload.** An inline pre-paint script applies the saved theme before the page draws.
25
+ - **Works on any site.** The panel carries its own stylesheet inside a shadow root, so it needs no Tailwind or other CSS from the site, and the site's CSS can't restyle it.
26
+ - **Movable colour button.** The floating button starts in the corner; visitors can drag it anywhere (mouse or touch; a tooltip says so on hover, and the panel closes while it moves) and it stays there, remembered across reloads. The panel then opens beside it, on whichever side has room. Its dot cycles through the current theme's colours.
27
+ - **Light and dark mode.** Every built-in theme is designed light; dark mode lists a generated dark twin of each (dark surfaces, light text, brand colours lifted to read on dark, then contrast-fixed) and switches the current theme to its twin. The panel turns dark with it. Custom palettes stay as they were made.
28
+ - **Visitor settings.** The gear in the panel header opens settings: theme mode (Light, Dark, Auto), panel size (Compact, Standard, Large), which groups and sections to show, whether the button can be dragged or its dot animates, and moving the button back to its corner. Saved per site.
29
+ - **Resizable panel.** Drag the panel's free edges or corner (the ones away from the colour button) to any size, or pick a size in settings; double-click an edge to reset it. The layout follows the panel's width, so a large panel shows three theme cards a row.
30
+ - **Clear groups and feedback.** The site's own group (globe), Max’s picks (paintbrush) and Yours (person) sit in their own row, apart from the library's categories. Toasts confirm what just happened; saving, importing or building a palette says it went to Yours and offers "Show" to jump straight to it. Tooltips are drawn in the panel's colours.
31
+ - **Themed scrollbars.** The page's scrollbars and the panel's slim one take the selected theme's primary colour. Turn the page's off with `scrollbars: false`.
32
+ - **Accessible panel.** A labelled dialog with focus trap, Escape, the colour button or an outside click to close, keyboard operable, and reduced-motion support.
33
+
34
+ ## How it works
35
+
36
+ Every colour is one of 35 tokens (`primary`, `surface`, `ink`, `data-1`…), exposed as `--color-<token>` CSS variables. Applying a theme just sets those variables on `<html>`, so anything the site paints with `var(--color-primary)` (directly, or through Tailwind CSS v4 utilities like `bg-primary`) changes instantly. There's no rebuild and no re-render.
37
+
38
+ The switcher renders into a `<colorsbymax-root>` element on `<body>` with its own shadow root and stylesheet. Only the `--color-*` variables cross into it. With reduced motion, the colour button's dot holds still on the theme's primary colour.
39
+
40
+ ## Try the demo
41
+
42
+ ```bash
43
+ npm install
44
+ npm run dev
45
+ ```
46
+
47
+ This serves `demo/`: colorsbymax's own landing page, built with Tailwind, where every colour is a token (it uses every token group, and a strip shows the live values), and `/plain.html`, a plain-CSS bakery site with deliberately careless global styles to show they don't reach the panel.
48
+
49
+ ## Add it to a site
50
+
51
+ colorsbymax needs React 18 or 19:
52
+
53
+ ```bash
54
+ npm install colorsbymax
55
+ ```
56
+
57
+ It ships as plain JavaScript with TypeScript types, so Vite, Next.js, webpack and other bundlers use it without extra setup. Installing changes nothing on its own; these steps add the colour button:
58
+
59
+ 1. **Paint the site with the token variables.** Use `var(--color-<token>)` wherever the site sets a colour, with your own colours as the starting values.
60
+
61
+ With Tailwind CSS v4, import the defaults (`colorsbymax/tokens.css` is Tailwind v4 syntax), override them, and use the token utilities (`bg-primary`, `text-ink`, …) instead of hard-coded colours:
62
+
63
+ ```css
64
+ @import "tailwindcss";
65
+ @import "colorsbymax/tokens.css";
66
+
67
+ @theme static {
68
+ --color-primary: #c67cde; /* your colours */
69
+ }
70
+ ```
71
+
72
+ With plain CSS, or Tailwind before v4, define the variables yourself (`demo/plain.html` shows this):
73
+
74
+ ```css
75
+ :root {
76
+ --color-primary: #c67cde;
77
+ }
78
+ .button {
79
+ background: var(--color-primary);
80
+ }
81
+ ```
82
+
83
+ 2. **Wrap the app and render the switcher:**
84
+
85
+ ```jsx
86
+ import { ThemeProvider, ThemeSwitcher } from 'colorsbymax'
87
+
88
+ <ThemeProvider config={config}>
89
+ <App />
90
+ <ThemeSwitcher />
91
+ </ThemeProvider>
92
+ ```
93
+
94
+ The switcher needs React but not a React site: `demo/plain.jsx` mounts it on its own next to a static page.
95
+
96
+ 3. **Add the pre-paint script** to `<head>`, as a classic inline `<script>`, using the same storage key. `prePaintScript(storageKey)` returns its source, and `demo/index.html` shows it in place.
97
+
98
+ Without a `siteName`, the site group is named from the page's `og:site_name`, its title or its host name.
99
+
100
+ ### Config
101
+
102
+ ```js
103
+ {
104
+ siteName: 'Tsungi', // name of the first theme group
105
+ storageKey: 'tsungi-theme', // localStorage key (match the pre-paint script)
106
+ defaultTheme: { name, tokens }, // the site's own colours; missing tokens are filled in
107
+ themes: [{ id, name, tokens }], // optional extra themes made for the site
108
+ usage: { primary: 'Buttons…' }, // optional notes shown in the colour editors
109
+ scrollbars: true, // colour the page's scrollbars from the theme (default)
110
+ pdf: loadPdf, // optional: allow PDF uploads (see below)
111
+ }
112
+ ```
113
+
114
+ ### PDF uploads
115
+
116
+ Building a palette from an image works out of the box. PDFs, such as brand guides, need [PDF.js](https://mozilla.github.io/pdf.js/), which is large, so it's opt-in: install it and pass the loader from `colorsbymax/pdf`.
117
+
118
+ ```bash
119
+ npm install pdfjs-dist
120
+ ```
121
+
122
+ ```jsx
123
+ import { loadPdf } from 'colorsbymax/pdf'
124
+
125
+ <ThemeProvider config={{ ...config, pdf: loadPdf }}>
126
+ ```
127
+
128
+ PDF.js still downloads only when a visitor picks a PDF. Sites that don't opt in never install or bundle it, and the upload offers images only.
129
+
130
+ `examples/tsungi.config.js` is a complete example for tsungi.online, the first site to use colorsbymax.
131
+
132
+ ## Building the package
133
+
134
+ `src/` is the source; `dist/` is what sites install: plain JavaScript with the JSX compiled away. `types/` holds the hand-written TypeScript declarations; `npm run typecheck` compiles `types/check.tsx` against them and checks they match the built exports. The panel's icons are copied from Lucide into `src/icons.jsx` by `npm run icons`. `dist/` is committed so installs straight from GitHub work even when npm skips install scripts, so rebuild it before committing changes to `src/`. `npm publish` also rebuilds it first:
135
+
136
+ ```bash
137
+ npm run build
138
+ ```
139
+
140
+ ## Panel styles
141
+
142
+ The panel is styled with Tailwind classes in `src/ThemePanel.jsx` and `src/ThemeSwitcher.jsx`. `scripts/build-css.mjs` compiles them, with `src/panel.css`, into `src/panel-css.generated.js`. The demo server does this automatically as you edit; otherwise run:
143
+
144
+ ```bash
145
+ npm run css
146
+ ```
147
+
148
+ ## Regenerating the library
149
+
150
+ ```bash
151
+ npm run presets
152
+ ```
153
+
154
+ `scripts/generate-presets.mjs` turns each source palette into a full theme, auto-fixes contrast, drops anything that still fails or duplicates another theme, names it and tags its categories. Adjust the category rules at the top of the script.
155
+
156
+ ## Notices
157
+
158
+ The library palettes come from [nice-color-palettes](https://github.com/Jam3/nice-color-palettes) (MIT). Its licence notice is in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) and must ship with every copy.
159
+
160
+ PDF reading uses [pdfjs-dist](https://github.com/mozilla/pdf.js) (Apache-2.0), installed as a dependency rather than bundled into colorsbymax.
161
+
162
+ ## Licence
163
+
164
+ colorsbymax is released under the [MIT Licence](LICENSE). The names colorsbymax™ and mrmaxdesigns are marks of Max Muyalwa and aren't covered by the code licence.
@@ -1,6 +1,6 @@
1
1
  # Third-party notices
2
2
 
3
- colorsbymax™ includes material from the following project. Its licence requires this notice to ship with every copy of the product.
3
+ colorsbymax™ includes material from the following projects. Their licences require these notices to ship with every copy of the product.
4
4
 
5
5
  ## nice-color-palettes
6
6
 
@@ -29,3 +29,49 @@ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
29
29
  OR OTHER DEALINGS IN THE SOFTWARE.
30
30
 
31
31
  ```
32
+
33
+ ## Lucide
34
+
35
+ The switcher's icons (`src/icons.jsx`) are copied from [Lucide](https://lucide.dev), generated from lucide-react by `scripts/build-icons.mjs`. Some Lucide icons derive from [Feather](https://feathericons.com); of those used here, that's arrow-left, check, chevron-right, download, monitor, moon, trash, upload and x, covered by the MIT notice below.
36
+
37
+ ```
38
+ ISC License
39
+
40
+ Copyright (c) 2026 Lucide Icons and Contributors
41
+
42
+ Permission to use, copy, modify, and/or distribute this software for any
43
+ purpose with or without fee is hereby granted, provided that the above
44
+ copyright notice and this permission notice appear in all copies.
45
+
46
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
47
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
48
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
49
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
50
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
51
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
52
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
53
+ ```
54
+
55
+ ```
56
+ The MIT License (MIT) (for the icons listed above)
57
+
58
+ Copyright (c) 2013-present Cole Bemis
59
+
60
+ Permission is hereby granted, free of charge, to any person obtaining a copy
61
+ of this software and associated documentation files (the "Software"), to deal
62
+ in the Software without restriction, including without limitation the rights
63
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
64
+ copies of the Software, and to permit persons to whom the Software is
65
+ furnished to do so, subject to the following conditions:
66
+
67
+ The above copyright notice and this permission notice shall be included in all
68
+ copies or substantial portions of the Software.
69
+
70
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
71
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
72
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
73
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
74
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
75
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
76
+ SOFTWARE.
77
+ ```
package/dist/index.js CHANGED
@@ -1,7 +1,6 @@
1
- import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
1
+ import { createContext, createElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { createPortal } from "react-dom";
4
- import { AlertTriangle, ArrowLeft, Check, CheckCircle2, ChevronRight, Copy, Download, Globe, ImageUp, Loader2, Monitor, Moon, Paintbrush, Palette, RotateCcw, ScanLine, Settings, Shuffle, Sun, Trash2, Upload, UserRound, Wand2, X } from "lucide-react";
5
4
  //#region src/color.js
6
5
  var HEX_RE = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
7
6
  /** Normalises "#abc", "abc", "#AABBCC" to "#aabbcc"; returns null for anything else. */
@@ -1062,6 +1061,9 @@ function createColorParser() {
1062
1061
  * colours. Everything happens synchronously, so nothing repaints in between.
1063
1062
  */
1064
1063
  function withoutAppliedTheme(fn) {
1064
+ const freeze = document.createElement("style");
1065
+ freeze.textContent = "*,*::before,*::after{transition:none!important}";
1066
+ document.head.appendChild(freeze);
1065
1067
  const style = document.documentElement.style;
1066
1068
  const saved = [];
1067
1069
  for (let i = style.length - 1; i >= 0; i--) {
@@ -1075,6 +1077,9 @@ function withoutAppliedTheme(fn) {
1075
1077
  return fn();
1076
1078
  } finally {
1077
1079
  for (const [prop, value] of saved) style.setProperty(prop, value);
1080
+ getComputedStyle(document.documentElement).color;
1081
+ document.body.offsetHeight;
1082
+ freeze.remove();
1078
1083
  }
1079
1084
  }
1080
1085
  /**
@@ -1423,6 +1428,7 @@ var newId = () => `custom-${Date.now().toString(36)}-${Math.random().toString(36
1423
1428
  * @property {Partial<Record<import('./tokens.js').TokenKey, string>>} [usage]
1424
1429
  * Where each token is used on this site, shown in the editors
1425
1430
  * @property {boolean} [scrollbars] Colour the page's scrollbars from the theme (default true)
1431
+ * @property {() => Promise<any>} [pdf] Enables PDF uploads: pass `loadPdf` from 'colorsbymax/pdf'
1426
1432
  */
1427
1433
  /** Resolves a config into the site theme group. The shipped default always comes first. */
1428
1434
  function resolveSite(config) {
@@ -1529,6 +1535,7 @@ function ThemeProvider({ config = {}, children }) {
1529
1535
  storageKey,
1530
1536
  siteName,
1531
1537
  usage: config.usage ?? {},
1538
+ loadPdf: config.pdf ?? null,
1532
1539
  siteThemes,
1533
1540
  defaultTheme,
1534
1541
  presets: PRESETS,
@@ -1683,6 +1690,193 @@ function useTheme() {
1683
1690
  if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
1684
1691
  return ctx;
1685
1692
  }
1693
+ //#endregion
1694
+ //#region src/icons.jsx
1695
+ /** A Lucide-style icon: 24×24, drawn with currentColor strokes, sized by className. */
1696
+ function icon(slug, shapes) {
1697
+ const Icon = ({ className = "", ...props }) => /* @__PURE__ */ jsx("svg", {
1698
+ xmlns: "http://www.w3.org/2000/svg",
1699
+ width: "24",
1700
+ height: "24",
1701
+ viewBox: "0 0 24 24",
1702
+ fill: "none",
1703
+ stroke: "currentColor",
1704
+ strokeWidth: "2",
1705
+ strokeLinecap: "round",
1706
+ strokeLinejoin: "round",
1707
+ className: `lucide lucide-${slug} ${className}`.trim(),
1708
+ ...props,
1709
+ children: shapes.map(([tag, attrs], i) => createElement(tag, {
1710
+ key: i,
1711
+ ...attrs
1712
+ }))
1713
+ });
1714
+ Icon.displayName = slug;
1715
+ return Icon;
1716
+ }
1717
+ var AlertTriangle = icon("triangle-alert", [
1718
+ ["path", { "d": "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }],
1719
+ ["path", { "d": "M12 9v4" }],
1720
+ ["path", { "d": "M12 17h.01" }]
1721
+ ]);
1722
+ var ArrowLeft = icon("arrow-left", [["path", { "d": "m12 19-7-7 7-7" }], ["path", { "d": "M19 12H5" }]]);
1723
+ var Check = icon("check", [["path", { "d": "M20 6 9 17l-5-5" }]]);
1724
+ var CheckCircle2 = icon("circle-check", [["circle", {
1725
+ "cx": "12",
1726
+ "cy": "12",
1727
+ "r": "10"
1728
+ }], ["path", { "d": "m16 9-5.5 5.5L8 12" }]]);
1729
+ var ChevronRight = icon("chevron-right", [["path", { "d": "m9 18 6-6-6-6" }]]);
1730
+ var Copy = icon("copy", [["rect", {
1731
+ "width": "14",
1732
+ "height": "14",
1733
+ "x": "8",
1734
+ "y": "8",
1735
+ "rx": "2",
1736
+ "ry": "2"
1737
+ }], ["path", { "d": "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]]);
1738
+ var Download = icon("download", [
1739
+ ["path", { "d": "M12 15V3" }],
1740
+ ["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
1741
+ ["path", { "d": "m7 10 5 5 5-5" }]
1742
+ ]);
1743
+ var Globe = icon("globe", [
1744
+ ["circle", {
1745
+ "cx": "12",
1746
+ "cy": "12",
1747
+ "r": "10"
1748
+ }],
1749
+ ["path", { "d": "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }],
1750
+ ["path", { "d": "M2 12h20" }]
1751
+ ]);
1752
+ var ImageUp = icon("image-up", [
1753
+ ["path", { "d": "M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" }],
1754
+ ["path", { "d": "m14 19.5 3-3 3 3" }],
1755
+ ["path", { "d": "M17 22v-5.5" }],
1756
+ ["circle", {
1757
+ "cx": "9",
1758
+ "cy": "9",
1759
+ "r": "2"
1760
+ }]
1761
+ ]);
1762
+ var Loader2 = icon("loader-circle", [["path", { "d": "M21 12a9 9 0 1 1-6.219-8.56" }]]);
1763
+ var Monitor = icon("monitor", [
1764
+ ["rect", {
1765
+ "width": "20",
1766
+ "height": "14",
1767
+ "x": "2",
1768
+ "y": "3",
1769
+ "rx": "2"
1770
+ }],
1771
+ ["line", {
1772
+ "x1": "8",
1773
+ "x2": "16",
1774
+ "y1": "21",
1775
+ "y2": "21"
1776
+ }],
1777
+ ["line", {
1778
+ "x1": "12",
1779
+ "x2": "12",
1780
+ "y1": "17",
1781
+ "y2": "21"
1782
+ }]
1783
+ ]);
1784
+ var Moon = icon("moon", [["path", { "d": "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" }]]);
1785
+ var Paintbrush = icon("paintbrush", [
1786
+ ["path", { "d": "m14.622 17.897-10.68-2.913" }],
1787
+ ["path", { "d": "M18.376 2.622a1 1 0 1 1 3.002 3.002L17.36 9.643a.5.5 0 0 0 0 .707l.944.944a2.41 2.41 0 0 1 0 3.408l-.944.944a.5.5 0 0 1-.707 0L8.354 7.348a.5.5 0 0 1 0-.707l.944-.944a2.41 2.41 0 0 1 3.408 0l.944.944a.5.5 0 0 0 .707 0z" }],
1788
+ ["path", { "d": "M9 8c-1.804 2.71-3.97 3.46-6.583 3.948a.507.507 0 0 0-.302.819l7.32 8.883a1 1 0 0 0 1.185.204C12.735 20.405 16 16.792 16 15" }]
1789
+ ]);
1790
+ var Palette = icon("palette", [
1791
+ ["path", { "d": "M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" }],
1792
+ ["circle", {
1793
+ "cx": "13.5",
1794
+ "cy": "6.5",
1795
+ "r": ".5",
1796
+ "fill": "currentColor"
1797
+ }],
1798
+ ["circle", {
1799
+ "cx": "17.5",
1800
+ "cy": "10.5",
1801
+ "r": ".5",
1802
+ "fill": "currentColor"
1803
+ }],
1804
+ ["circle", {
1805
+ "cx": "6.5",
1806
+ "cy": "12.5",
1807
+ "r": ".5",
1808
+ "fill": "currentColor"
1809
+ }],
1810
+ ["circle", {
1811
+ "cx": "8.5",
1812
+ "cy": "7.5",
1813
+ "r": ".5",
1814
+ "fill": "currentColor"
1815
+ }]
1816
+ ]);
1817
+ var RotateCcw = icon("rotate-ccw", [["path", { "d": "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }], ["path", { "d": "M3 3v5h5" }]]);
1818
+ var ScanLine = icon("scan-line", [
1819
+ ["path", { "d": "M3 7V5a2 2 0 0 1 2-2h2" }],
1820
+ ["path", { "d": "M17 3h2a2 2 0 0 1 2 2v2" }],
1821
+ ["path", { "d": "M21 17v2a2 2 0 0 1-2 2h-2" }],
1822
+ ["path", { "d": "M7 21H5a2 2 0 0 1-2-2v-2" }],
1823
+ ["path", { "d": "M7 12h10" }]
1824
+ ]);
1825
+ var Settings = icon("settings", [["path", { "d": "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }], ["circle", {
1826
+ "cx": "12",
1827
+ "cy": "12",
1828
+ "r": "3"
1829
+ }]]);
1830
+ var Shuffle = icon("shuffle", [
1831
+ ["path", { "d": "m18 14 4 4-4 4" }],
1832
+ ["path", { "d": "m18 2 4 4-4 4" }],
1833
+ ["path", { "d": "M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22" }],
1834
+ ["path", { "d": "M2 6h1.972a4 4 0 0 1 3.6 2.2" }],
1835
+ ["path", { "d": "M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45" }]
1836
+ ]);
1837
+ var Sun = icon("sun", [
1838
+ ["circle", {
1839
+ "cx": "12",
1840
+ "cy": "12",
1841
+ "r": "4"
1842
+ }],
1843
+ ["path", { "d": "M12 2v2" }],
1844
+ ["path", { "d": "M12 20v2" }],
1845
+ ["path", { "d": "m4.93 4.93 1.41 1.41" }],
1846
+ ["path", { "d": "m17.66 17.66 1.41 1.41" }],
1847
+ ["path", { "d": "M2 12h2" }],
1848
+ ["path", { "d": "M20 12h2" }],
1849
+ ["path", { "d": "m6.34 17.66-1.41 1.41" }],
1850
+ ["path", { "d": "m19.07 4.93-1.41 1.41" }]
1851
+ ]);
1852
+ var Trash2 = icon("trash", [
1853
+ ["path", { "d": "M10 11v6" }],
1854
+ ["path", { "d": "M14 11v6" }],
1855
+ ["path", { "d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }],
1856
+ ["path", { "d": "M3 6h18" }],
1857
+ ["path", { "d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }]
1858
+ ]);
1859
+ var Upload = icon("upload", [
1860
+ ["path", { "d": "M12 3v12" }],
1861
+ ["path", { "d": "m17 8-5-5-5 5" }],
1862
+ ["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }]
1863
+ ]);
1864
+ var UserRound = icon("user-round", [["circle", {
1865
+ "cx": "12",
1866
+ "cy": "8",
1867
+ "r": "5"
1868
+ }], ["path", { "d": "M20 21a8 8 0 0 0-16 0" }]]);
1869
+ var Wand2 = icon("wand-sparkles", [
1870
+ ["path", { "d": "m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72" }],
1871
+ ["path", { "d": "m14 7 3 3" }],
1872
+ ["path", { "d": "M5 6v4" }],
1873
+ ["path", { "d": "M19 14v4" }],
1874
+ ["path", { "d": "M10 2v2" }],
1875
+ ["path", { "d": "M7 8H3" }],
1876
+ ["path", { "d": "M21 16h-4" }],
1877
+ ["path", { "d": "M11 3H9" }]
1878
+ ]);
1879
+ var X = icon("x", [["path", { "d": "M18 6 6 18" }], ["path", { "d": "m6 6 12 12" }]]);
1686
1880
  /** Images are scaled down to at most this many pixels on their longest side before sampling. */
1687
1881
  var SAMPLE_SIZE = 200;
1688
1882
  /** PDF pages read, from the first. */
@@ -1698,12 +1892,16 @@ var hslOf = (hex) => rgbToHsl(hexToRgb(hex));
1698
1892
  /**
1699
1893
  * The main colours in an image or PDF, most important first.
1700
1894
  * @param {File} file
1895
+ * @param {{ loadPdf?: (() => Promise<any>) | null }} [options] PDF support, from 'colorsbymax/pdf'
1701
1896
  * @returns {Promise<{ colours: string[], from: 'image' | 'pdf-text' | 'pdf' }>}
1702
1897
  */
1703
- async function coloursFromFile(file) {
1898
+ async function coloursFromFile(file, { loadPdf = null } = {}) {
1704
1899
  if (file.size > 26214400) throw new Error("That file is over 25 MB. Try a smaller one.");
1705
- if (file.type === "application/pdf" || /\.pdf$/i.test(file.name)) return coloursFromPdf(file);
1706
- if (!file.type.startsWith("image/")) throw new Error("Choose an image (PNG, JPG, WebP, SVG…) or a PDF.");
1900
+ if (file.type === "application/pdf" || /\.pdf$/i.test(file.name)) {
1901
+ if (!loadPdf) throw new Error("PDFs aren’t supported on this site. Try an image of the palette instead.");
1902
+ return coloursFromPdf(file, loadPdf);
1903
+ }
1904
+ if (!file.type.startsWith("image/")) throw new Error(`Choose an image (PNG, JPG, WebP, SVG…)${loadPdf ? " or a PDF" : ""}.`);
1707
1905
  const bitmap = await loadImage(file);
1708
1906
  return {
1709
1907
  colours: dominantColours([pixelsOf(bitmap, bitmap.width, bitmap.height)]),
@@ -1789,11 +1987,10 @@ function isEdgeBlend(m, kept) {
1789
1987
  }
1790
1988
  return false;
1791
1989
  }
1792
- async function coloursFromPdf(file) {
1990
+ async function coloursFromPdf(file, loadPdf) {
1793
1991
  let pdfjs;
1794
1992
  try {
1795
- pdfjs = await import("pdfjs-dist");
1796
- await import("pdfjs-dist/build/pdf.worker.min.mjs");
1993
+ pdfjs = await loadPdf();
1797
1994
  } catch {
1798
1995
  throw new Error("Couldn’t load the PDF reader. Check your connection and try again.");
1799
1996
  }
@@ -2847,7 +3044,7 @@ function PresetGrid() {
2847
3044
  children: loadError ? "Couldn’t load the theme library. Check your connection and reopen the panel." : "Loading themes…"
2848
3045
  }) : visible.length === 0 && !q && category === "yours" ? /* @__PURE__ */ jsx("p", {
2849
3046
  className: "rounded-xl border border-dashed border-zinc-300 px-4 py-5 text-center text-xs text-zinc-600",
2850
- children: "Nothing here yet. Palettes you create in Custom palettes, import, or build from an image or PDF in Import / export are saved here."
3047
+ children: "Nothing here yet. Palettes you create in Custom palettes, import, or build from a file in Import / export are saved here."
2851
3048
  }) : visible.length === 0 ? /* @__PURE__ */ jsxs("p", {
2852
3049
  className: "py-6 text-center text-xs text-zinc-600",
2853
3050
  role: "status",
@@ -3296,9 +3493,10 @@ var FROM_NOTE = {
3296
3493
  pdf: "Picked from the colours on the first pages.",
3297
3494
  "pdf-text": "Found colour codes written in the PDF."
3298
3495
  };
3299
- /** Builds a custom palette from the colours in an uploaded image or PDF. */
3496
+ /** Builds a custom palette from the colours in an uploaded image, or a PDF where the site allows it. */
3300
3497
  function PaletteFromFile() {
3301
- const { addPalette } = useTheme();
3498
+ const { addPalette, loadPdf } = useTheme();
3499
+ const kinds = loadPdf ? "image or PDF" : "image";
3302
3500
  const savedToYours = useSavedToYours();
3303
3501
  const [busy, setBusy] = useState(false);
3304
3502
  const [error, setError] = useState(null);
@@ -3315,7 +3513,7 @@ function PaletteFromFile() {
3315
3513
  setBusy(true);
3316
3514
  setError(null);
3317
3515
  try {
3318
- const { colours, from } = await coloursFromFile(file);
3516
+ const { colours, from } = await coloursFromFile(file, { loadPdf });
3319
3517
  if (!colours.length) throw new Error("Couldn’t find any colours in that file.");
3320
3518
  setFound({
3321
3519
  fileName: file.name,
@@ -3339,9 +3537,9 @@ function PaletteFromFile() {
3339
3537
  return /* @__PURE__ */ jsxs("div", {
3340
3538
  className: "space-y-1.5",
3341
3539
  children: [
3342
- /* @__PURE__ */ jsx("p", {
3540
+ /* @__PURE__ */ jsxs("p", {
3343
3541
  className: "text-xs font-medium text-zinc-700",
3344
- children: "Palette from an image or PDF"
3542
+ children: ["Palette from an ", kinds]
3345
3543
  }),
3346
3544
  /* @__PURE__ */ jsxs("div", {
3347
3545
  onDragOver: (e) => {
@@ -3368,16 +3566,20 @@ function PaletteFromFile() {
3368
3566
  }) : /* @__PURE__ */ jsx(ImageUp, {
3369
3567
  className: "w-3.5 h-3.5",
3370
3568
  "aria-hidden": "true"
3371
- }), busy ? "Reading colours…" : "Choose image or PDF…"]
3569
+ }), busy ? "Reading colours…" : `Choose ${kinds}…`]
3372
3570
  }),
3373
- /* @__PURE__ */ jsx("p", {
3571
+ /* @__PURE__ */ jsxs("p", {
3374
3572
  className: "mt-1.5 text-[11px] text-zinc-600",
3375
- children: "or drop one here. A mood board, screenshot, photo or brand guide works."
3573
+ children: [
3574
+ "or drop one here. A mood board, screenshot or photo",
3575
+ loadPdf ? ", or a brand guide PDF," : "",
3576
+ " works."
3577
+ ]
3376
3578
  }),
3377
3579
  /* @__PURE__ */ jsx("input", {
3378
3580
  ref: fileRef,
3379
3581
  type: "file",
3380
- accept: "image/*,application/pdf,.pdf",
3582
+ accept: loadPdf ? "image/*,application/pdf,.pdf" : "image/*",
3381
3583
  className: "sr-only",
3382
3584
  tabIndex: -1,
3383
3585
  "aria-hidden": "true",
package/dist/pdf.js ADDED
@@ -0,0 +1,9 @@
1
+ //#region src/pdf.js
2
+ /** Loads PDF.js, running it on the page so there's no separate worker file to host. */
3
+ async function loadPdf() {
4
+ const pdfjs = await import("pdfjs-dist");
5
+ await import("pdfjs-dist/build/pdf.worker.min.mjs");
6
+ return pdfjs;
7
+ }
8
+ //#endregion
9
+ export { loadPdf };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "colorsbymax",
3
- "version": "0.1.0",
4
- "description": "colorsbymax™ by mrmaxdesigns: a runtime theme switcher with presets, a 715-theme library, custom palettes and WCAG contrast checks.",
3
+ "version": "0.1.1",
4
+ "description": "A live theme switcher for any website: site themes, 700+ palettes, for light/dark modes and WCAG contrast checks.",
5
5
  "keywords": [
6
6
  "theme",
7
7
  "theme-switcher",
@@ -25,19 +25,38 @@
25
25
  "url": "https://github.com/MaxMuyalwa/colorsbymax/issues"
26
26
  },
27
27
  "type": "module",
28
- "main": "dist/index.js",
29
- "module": "dist/index.js",
28
+ "main": "./dist/index.js",
29
+ "module": "./dist/index.js",
30
+ "types": "./types/index.d.ts",
31
+ "typesVersions": {
32
+ "*": {
33
+ "pdf": [
34
+ "./types/pdf.d.ts"
35
+ ]
36
+ }
37
+ },
30
38
  "exports": {
31
39
  ".": {
40
+ "types": "./types/index.d.ts",
32
41
  "import": "./dist/index.js",
33
42
  "default": "./dist/index.js"
34
43
  },
44
+ "./pdf": {
45
+ "types": "./types/pdf.d.ts",
46
+ "import": "./dist/pdf.js",
47
+ "default": "./dist/pdf.js"
48
+ },
35
49
  "./tokens.css": "./src/tokens.css",
36
50
  "./package.json": "./package.json"
37
51
  },
38
52
  "sideEffects": false,
53
+ "engines": {
54
+ "node": ">=18"
55
+ },
39
56
  "files": [
40
57
  "dist",
58
+ "types/index.d.ts",
59
+ "types/pdf.d.ts",
41
60
  "src/tokens.css",
42
61
  "LICENSE",
43
62
  "THIRD_PARTY_NOTICES.md"
@@ -45,27 +64,36 @@
45
64
  "scripts": {
46
65
  "dev": "vite",
47
66
  "build": "node scripts/build-css.mjs && vite build --config vite.lib.config.js",
48
- "prepare": "vite build --config vite.lib.config.js",
67
+ "typecheck": "tsc -p types && node scripts/check-types.mjs",
68
+ "prepublishOnly": "npm run build && npm run typecheck",
49
69
  "build:demo": "vite build",
50
70
  "css": "node scripts/build-css.mjs",
71
+ "icons": "node scripts/build-icons.mjs",
51
72
  "presets": "node scripts/generate-presets.mjs"
52
73
  },
53
74
  "peerDependencies": {
54
75
  "react": "^18.2.0 || ^19.0.0",
55
- "react-dom": "^18.2.0 || ^19.0.0"
76
+ "react-dom": "^18.2.0 || ^19.0.0",
77
+ "pdfjs-dist": ">=4"
56
78
  },
57
- "dependencies": {
58
- "lucide-react": "^1.48.0",
59
- "pdfjs-dist": "^6.3.289"
79
+ "peerDependenciesMeta": {
80
+ "pdfjs-dist": {
81
+ "optional": true
82
+ }
60
83
  },
61
84
  "devDependencies": {
62
85
  "@tailwindcss/cli": "^4.3.3",
63
86
  "@tailwindcss/vite": "^4.3.3",
87
+ "@types/react": "^19.3.0",
88
+ "@types/react-dom": "^19.3.0",
64
89
  "@vitejs/plugin-react": "^6.1.1",
90
+ "lucide-react": "^1.48.0",
65
91
  "nice-color-palettes": "^4.0.0",
92
+ "pdfjs-dist": "^6.3.289",
66
93
  "react": "^19.3.0",
67
94
  "react-dom": "^19.3.0",
68
95
  "tailwindcss": "^4.3.3",
96
+ "typescript": "^5.9.3",
69
97
  "vite": "^8.3.1"
70
98
  },
71
99
  "license": "MIT"
@@ -0,0 +1,271 @@
1
+ // Type declarations for colorsbymax. The package is written in JavaScript; these describe its
2
+ // public API and are checked against real usage by types/check.tsx (npm run typecheck).
3
+
4
+ import type { ReactElement, ReactNode } from 'react'
5
+
6
+ // ---------------------------------------------------------------- tokens and themes
7
+
8
+ /** Every themeable colour. Each is exposed to CSS as `--color-<key>`. */
9
+ export type TokenKey =
10
+ | 'primary' | 'primary-dark' | 'primary-alt' | 'on-primary'
11
+ | 'background' | 'surface' | 'secondary' | 'on-secondary' | 'accent' | 'on-accent' | 'border' | 'shadow'
12
+ | 'ink' | 'ink-secondary' | 'ink-muted'
13
+ | 'success' | 'warning' | 'danger' | 'info'
14
+ | 'data-1' | 'data-2' | 'data-3' | 'data-4' | 'data-5' | 'data-6' | 'data-7' | 'data-8'
15
+ | 'app-background' | 'app-input' | 'app-border' | 'app-primary' | 'app-shadow-dark' | 'app-shadow-light' | 'app-ink' | 'app-ink-muted'
16
+
17
+ /** Token key → `"#rrggbb"`. */
18
+ export type ThemeTokens = Record<TokenKey, string>
19
+
20
+ export interface Theme {
21
+ id: string
22
+ name: string
23
+ tokens: ThemeTokens
24
+ /** A palette the visitor created or imported. */
25
+ custom?: boolean
26
+ /** From the generated library. */
27
+ library?: boolean
28
+ /** One of the host site's own themes (including scan suggestions). */
29
+ site?: boolean
30
+ /** Suggested by a site scan. */
31
+ scanned?: boolean
32
+ /** A generated dark twin of a light theme. */
33
+ derived?: boolean
34
+ /** Library categories, for library themes. */
35
+ tags?: string[]
36
+ }
37
+
38
+ export interface TokenGroup {
39
+ group: string
40
+ tokens: { key: TokenKey; label: string; usage: string }[]
41
+ }
42
+
43
+ export const TOKEN_GROUPS: TokenGroup[]
44
+ export const TOKEN_KEYS: TokenKey[]
45
+ /** The neutral default theme's tokens (Slate). */
46
+ export const BASE_TOKENS: ThemeTokens
47
+ /** The hand-tuned themes shown as "Max's picks". */
48
+ export const PRESETS: Theme[]
49
+
50
+ /** Fills missing tokens from `base` (the neutral default unless given), deriving the secondary-area ones. */
51
+ export function completeTokens(partial: Partial<ThemeTokens>, base?: ThemeTokens): ThemeTokens
52
+ /** Derives the secondary-area (`app-*`) tokens from a theme's main palette. */
53
+ export function deriveAppTokens(tokens: ThemeTokens): Pick<ThemeTokens, Extract<TokenKey, `app-${string}`>>
54
+
55
+ // ---------------------------------------------------------------- React API
56
+
57
+ export interface ColorsByMaxConfig {
58
+ /** Name of the first theme group, e.g. "Tsungi". Detected from the page if left out. */
59
+ siteName?: string
60
+ /** localStorage key; must match the pre-paint script. Defaults to "colorsbymax". */
61
+ storageKey?: string
62
+ /** The site's own colours. Missing tokens are filled in. */
63
+ defaultTheme?: { name: string; tokens: Partial<ThemeTokens> }
64
+ /** Extra themes made for the site. */
65
+ themes?: { id: string; name: string; tokens: Partial<ThemeTokens> }[]
66
+ /** Where each token is used on the site, shown in the colour editors. */
67
+ usage?: Partial<Record<TokenKey, string>>
68
+ /** Colour the page's scrollbars from the theme. Default true. */
69
+ scrollbars?: boolean
70
+ /** Enables PDF uploads: pass `loadPdf` from 'colorsbymax/pdf' (needs pdfjs-dist installed). */
71
+ pdf?: () => Promise<unknown>
72
+ }
73
+
74
+ export interface ThemeProviderProps {
75
+ config?: ColorsByMaxConfig
76
+ children?: ReactNode
77
+ }
78
+
79
+ /** Applies the saved or chosen theme to the page and provides it to the switcher and useTheme(). */
80
+ export function ThemeProvider(props: ThemeProviderProps): ReactElement
81
+
82
+ /** The floating colour button and panel. Render it once, inside ThemeProvider. */
83
+ export function ThemeSwitcher(): ReactElement | null
84
+
85
+ export interface ScanResult {
86
+ at: number
87
+ palette: string[]
88
+ themes: Theme[]
89
+ }
90
+
91
+ export interface ThemeState {
92
+ activeId: string
93
+ overrides: Partial<ThemeTokens>
94
+ customs: Theme[]
95
+ snapshot: Pick<Theme, 'id' | 'name' | 'tokens'> | null
96
+ scanned: ScanResult | null
97
+ }
98
+
99
+ export interface ThemeApi {
100
+ state: ThemeState
101
+ storageKey: string
102
+ siteName: string
103
+ usage: Partial<Record<TokenKey, string>>
104
+ loadPdf: (() => Promise<unknown>) | null
105
+ /** The site's own themes, then any scan suggestions. */
106
+ siteThemes: Theme[]
107
+ defaultTheme: Theme
108
+ presets: Theme[]
109
+ customs: Theme[]
110
+ /** Site themes, presets and custom palettes (not the library). */
111
+ themes: Theme[]
112
+ /** The applied theme. */
113
+ active: Theme
114
+ /** The applied colours: the active theme plus any overrides. */
115
+ tokens: ThemeTokens
116
+ /** Contrast problems in the applied colours. */
117
+ issues: ContrastIssue[]
118
+
119
+ /** Selects a theme by id; pass the theme itself for library themes and dark twins. */
120
+ selectTheme(id: string, theme?: Theme): void
121
+ /** Creates a custom palette from the current colours and applies it. Returns its id. */
122
+ createCustom(name: string): string
123
+ updateCustomToken(id: string, key: TokenKey, value: string): void
124
+ renameCustom(id: string, name: string): void
125
+ deleteCustom(id: string): void
126
+ /** Adds tokens as a new custom palette and applies it. Returns its id. */
127
+ addPalette(name: string, tokens: Partial<ThemeTokens>): string
128
+
129
+ setOverride(key: TokenKey, value: string): void
130
+ clearOverride(key: TokenKey): void
131
+ clearOverrides(): void
132
+ resetToDefault(): void
133
+
134
+ scanned: ScanResult | null
135
+ /** Reads the page's colours and adds themes built around them. Resolves to counts for the UI. */
136
+ runScan(): Promise<{ colours: number; themes: number }>
137
+ clearScan(): void
138
+
139
+ /** Fixes one issue by adjusting lightness. Returns false if no single change could. */
140
+ fixIssue(issue: ContrastIssue): boolean
141
+ fixAllIssues(): void
142
+
143
+ /** The applied theme as `{ name, tokens }` JSON. */
144
+ exportTheme(): string
145
+ /** Imports `{ name, tokens }` JSON as a custom palette. Returns an error message, or null. */
146
+ importTheme(json: string): string | null
147
+ }
148
+
149
+ /** The theme state and actions. Must be called inside ThemeProvider. */
150
+ export function useTheme(): ThemeApi
151
+
152
+ /** Writes each token to `--color-<key>` on `<html>`. */
153
+ export function applyTokens(tokens: ThemeTokens): void
154
+
155
+ // ---------------------------------------------------------------- pre-paint and settings
156
+
157
+ export const DEFAULT_STORAGE_KEY: 'colorsbymax'
158
+ /** Source of an inline `<script>` for `<head>` that applies the saved theme before first paint. */
159
+ export function prePaintScript(storageKey?: string): string
160
+
161
+ export interface PanelSettings {
162
+ mode: 'light' | 'dark' | 'system'
163
+ showPicks: boolean
164
+ showLibrary: boolean
165
+ showCustom: boolean
166
+ showOverrides: boolean
167
+ showImportExport: boolean
168
+ draggable: boolean
169
+ animateDot: boolean
170
+ panelWidth: number | null
171
+ panelHeight: number | 'full' | null
172
+ libraryCollapsed: boolean
173
+ }
174
+ /** The visitor settings the panel starts with. */
175
+ export const DEFAULT_SETTINGS: PanelSettings
176
+
177
+ // ---------------------------------------------------------------- contrast
178
+
179
+ export type PairingKind = 'text' | 'large-text' | 'non-text'
180
+
181
+ export interface Pairing {
182
+ id: string
183
+ label: string
184
+ kind: PairingKind
185
+ fg(tokens: ThemeTokens): string
186
+ bg(tokens: ThemeTokens): string
187
+ /** Tokens auto-fix may adjust, in preference order. */
188
+ fixable: TokenKey[]
189
+ }
190
+
191
+ export interface ContrastIssue {
192
+ pairing: Pairing
193
+ ratio: number
194
+ required: number
195
+ /** e.g. "Primary text on background: 2.7:1 — text will be hard to read (needs 4.5:1)" */
196
+ message: string
197
+ }
198
+
199
+ /** Every foreground/background pairing colorsbymax checks. */
200
+ export const PAIRINGS: Pairing[]
201
+ export const MIN_CONTRAST_TEXT: number
202
+ export const MIN_CONTRAST_LARGE_TEXT: number
203
+ export const MIN_CONTRAST_NON_TEXT: number
204
+ export const MIN_RAMP_STEP_DELTA_E: number
205
+
206
+ /** Every failing pairing in a theme. */
207
+ export function checkTheme(tokens: ThemeTokens): ContrastIssue[]
208
+ /** The smallest lightness change to one token that makes a pairing pass, or null. */
209
+ export function suggestFix(pairing: Pairing, tokens: ThemeTokens): { key: TokenKey; value: string } | null
210
+ /** Fixes failing pairings until the theme passes or nothing more helps. Returns the changed tokens. */
211
+ export function fixAll(tokens: ThemeTokens): Partial<ThemeTokens>
212
+ /** Problems with an ordered colour ramp drawn on `surface`, as sentences. */
213
+ export function checkRamp(colors: string[], surface: string): string[]
214
+
215
+ // ---------------------------------------------------------------- colour helpers
216
+
217
+ /** WCAG 2 contrast ratio between two hex colours, 1–21. */
218
+ export function contrastRatio(a: string, b: string): number
219
+ /** "#abc", "abc" or "#AABBCC" → "#aabbcc"; null for anything else. */
220
+ export function normalizeHex(input: unknown): string | null
221
+
222
+ // ---------------------------------------------------------------- light and dark
223
+
224
+ /** True when a theme's page background is dark. */
225
+ export function isDarkTheme(tokens: ThemeTokens): boolean
226
+ /** Dark versions of a light theme's tokens, adjusted to pass contrast. */
227
+ export function darkTokens(tokens: ThemeTokens): ThemeTokens
228
+ /** The dark twin of a light theme (themes that are already dark come back unchanged). */
229
+ export function toDark<T extends Theme>(theme: T): T
230
+
231
+ // ---------------------------------------------------------------- site scan
232
+
233
+ export interface ColourTally {
234
+ hex: string
235
+ /** Area painted as a background. */
236
+ bg: number
237
+ /** Weighted amount of text in this colour. */
238
+ text: number
239
+ /** Border length in this colour. */
240
+ border: number
241
+ }
242
+
243
+ export type Roles = Partial<Record<TokenKey, string>>
244
+
245
+ /** The page's painted colours, ignoring any applied theme. `exclude` is a selector to skip. */
246
+ export function collectColors(options?: { exclude?: string }): ColourTally[]
247
+ /** Works out token roles from collected colours. */
248
+ export function inferRoles(colors: ColourTally[]): { roles: Roles; palette: string[] }
249
+ /** A complete theme from detected roles, deriving whatever wasn't found. */
250
+ export function themeFromRoles(
251
+ roles: Roles,
252
+ variant?: { softness?: number; boldness?: number; complementary?: boolean },
253
+ ): ThemeTokens
254
+ /** Themes suggested for a scanned site, named after it, plus the closest library themes. */
255
+ export function suggestThemes(scan: { roles: Roles }, siteName: string, library?: Theme[]): Theme[]
256
+ /** Best guess at the site's name: og:site_name, then the title, then the host. */
257
+ export function detectSiteName(doc?: Document): string
258
+
259
+ // ---------------------------------------------------------------- palettes from files
260
+
261
+ /** The main colours in an image (or a PDF, with `loadPdf`), most important first. */
262
+ export function coloursFromFile(
263
+ file: File,
264
+ options?: { loadPdf?: (() => Promise<unknown>) | null },
265
+ ): Promise<{ colours: string[]; from: 'image' | 'pdf-text' | 'pdf' }>
266
+ /** The dominant colours in sets of RGBA pixels, edge blends removed. */
267
+ export function dominantColours(pixelSets: Uint8ClampedArray[]): string[]
268
+ /** Assigns a palette's colours to theme roles. */
269
+ export function rolesFromPalette(colours: string[]): Roles
270
+ /** A complete, contrast-checked theme built around a palette. */
271
+ export function themeFromPalette(colours: string[]): ThemeTokens
package/types/pdf.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Type declarations for 'colorsbymax/pdf'.
2
+
3
+ /**
4
+ * Loads PDF.js for PDF uploads. Pass it as `pdf` in the ThemeProvider config; needs the
5
+ * pdfjs-dist package installed. PDF.js downloads only when a visitor picks a PDF.
6
+ */
7
+ export function loadPdf(): Promise<unknown>