diff --git a/.github/workflows/javascript-test.yml b/.github/workflows/javascript-test.yml
new file mode 100644
index 0000000..550cd71
--- /dev/null
+++ b/.github/workflows/javascript-test.yml
@@ -0,0 +1,57 @@
+name: JavaScript Tests
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'packages/javascript/**'
+ - 'packages/rust/wasm/**'
+ - 'packages/rust/core/**'
+ - '.github/workflows/javascript-test.yml'
+ pull_request:
+ paths:
+ - 'packages/javascript/**'
+ - 'packages/rust/wasm/**'
+ - 'packages/rust/core/**'
+ - '.github/workflows/javascript-test.yml'
+ workflow_dispatch:
+
+defaults:
+ run:
+ working-directory: packages/javascript
+
+jobs:
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: packages/rust
+
+ - name: Install wasm-pack
+ run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
+
+ - name: Build WASM
+ working-directory: .
+ run: wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core
+
+ - uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Type check
+ run: bun run type-check
+
+ - name: Run tests
+ run: bun run test
+
+ - name: Build
+ run: bun run build
diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml
index 9bb4022..bc15ef7 100644
--- a/.github/workflows/python-lint.yml
+++ b/.github/workflows/python-lint.yml
@@ -26,7 +26,7 @@ jobs:
run: uv python install 3.11
- name: Install dependencies
- run: uv --project packages/python sync --all-extras
+ run: uv --project packages/python sync --all-extras --no-install-project
- name: Run prek
run: uv --project packages/python run prek run --all-files --config packages/python/.pre-commit-config.yaml
\ No newline at end of file
diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml
index b6ae4a0..b06991e 100644
--- a/.github/workflows/python-test.yml
+++ b/.github/workflows/python-test.yml
@@ -5,10 +5,12 @@ on:
branches: [main]
paths:
- 'packages/python/**'
+ - 'packages/rust/**'
- '.github/workflows/python-test.yml'
pull_request:
paths:
- 'packages/python/**'
+ - 'packages/rust/**'
- '.github/workflows/python-test.yml'
workflow_dispatch:
@@ -33,8 +35,17 @@ jobs:
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: packages/python
+
- name: Install dependencies
- run: uv sync --all-extras
+ run: uv sync --all-extras --no-install-project
+
+ - name: Build and install package
+ run: uv run maturin develop --release
- name: Run tests
run: uv run pytest tests/ -v --tb=short
@@ -49,4 +60,4 @@ jobs:
with:
file: ./packages/python/coverage.xml
fail_ci_if_error: false
- flags: python
\ No newline at end of file
+ flags: python
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cc7ee75..0c8bd9e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,78 +15,145 @@ jobs:
release-please:
runs-on: ubuntu-latest
outputs:
- python-release-created: ${{ steps.release.outputs['packages/python--release_created'] }}
- python-tag: ${{ steps.release.outputs['packages/python--tag_name'] }}
- javascript-release-created: ${{ steps.release.outputs['packages/javascript--release_created'] }}
- javascript-tag: ${{ steps.release.outputs['packages/javascript--tag_name'] }}
+ release-created: ${{ steps.release.outputs['packages/python--release_created'] }}
+ tag: ${{ steps.release.outputs['packages/python--tag_name'] }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
- # Use manifest-based config for monorepo
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- # Publish Python package to PyPI
- publish-python:
+ # ── Rust: publish epaper-dithering-core to crates.io ────────────────────────
+
+ publish-rust:
needs: release-please
- if: ${{ needs.release-please.outputs.python-release-created }}
+ if: ${{ needs.release-please.outputs.release-created }}
runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: packages/python
steps:
- - name: Checkout code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
- - name: Set up Python
- uses: actions/setup-python@v5
+ - uses: Swatinem/rust-cache@v2
with:
- python-version: "3.11"
+ workspaces: packages/rust
- - name: Install uv
- uses: astral-sh/setup-uv@v4
+ - name: Publish to crates.io
+ run: cargo publish -p epaper-dithering-core
+ env:
+ CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- - name: Install dependencies
- run: uv sync
+ # ── Python: build wheels for each platform ──────────────────────────────────
- - name: Build package
- run: uv build
+ build-python-wheels:
+ needs: release-please
+ if: ${{ needs.release-please.outputs.release-created }}
+ name: Build wheels (${{ matrix.target }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ include:
+ - os: ubuntu-latest
+ target: x86_64
+ - os: ubuntu-latest
+ target: aarch64
+ - os: ubuntu-latest
+ target: armv7
+ - os: macos-latest
+ target: universal2-apple-darwin
+ - os: windows-latest
+ target: x86_64
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: PyO3/maturin-action@v1
+ with:
+ working-directory: packages/python
+ target: ${{ matrix.target }}
+ manylinux: auto
+ args: --release --out dist --find-interpreter
+ sccache: true
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: wheels-${{ matrix.target }}
+ path: packages/python/dist
+
+ build-python-sdist:
+ needs: release-please
+ if: ${{ needs.release-please.outputs.release-created }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
- - name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
+ - uses: PyO3/maturin-action@v1
with:
- packages-dir: packages/python/dist/
+ working-directory: packages/python
+ command: sdist
+ args: --out dist
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: wheels-sdist
+ path: packages/python/dist
+
+ publish-python:
+ needs: [build-python-wheels, build-python-sdist]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: wheels-*
+ merge-multiple: true
+ path: dist
+
+ - uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: dist/
+
+ # ── JavaScript ───────────────────────────────────────────────────────────────
- # Publish JavaScript package to npm
publish-javascript:
needs: release-please
- if: ${{ needs.release-please.outputs.javascript-release-created }}
+ if: ${{ needs.release-please.outputs.release-created }}
runs-on: ubuntu-latest
permissions:
- id-token: write # Required for npm provenance
+ id-token: write
contents: read
defaults:
run:
working-directory: packages/javascript
steps:
- - name: Checkout code
- uses: actions/checkout@v4
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: packages/rust
+
+ - name: Install wasm-pack
+ run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
+
+ - name: Build WASM
+ working-directory: ${{ github.workspace }}
+ run: wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core
- - name: Setup Node.js
- uses: actions/setup-node@v4
+ - uses: actions/setup-node@v4
with:
node-version: '25'
registry-url: 'https://registry.npmjs.org'
- - name: Setup Bun
- uses: oven-sh/setup-bun@v2
+ - uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Run tests
- run: bun test
+ run: bun run test
- name: Build package
run: bun run build
diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml
new file mode 100644
index 0000000..97afc2b
--- /dev/null
+++ b/.github/workflows/rust-test.yml
@@ -0,0 +1,36 @@
+name: Rust Tests
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'packages/rust/**'
+ - '.github/workflows/rust-test.yml'
+ pull_request:
+ paths:
+ - 'packages/rust/**'
+ - '.github/workflows/rust-test.yml'
+ workflow_dispatch:
+
+defaults:
+ run:
+ working-directory: packages/rust
+
+jobs:
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: packages/rust
+
+ - name: Test
+ run: cargo test --workspace
+
+ - name: Clippy
+ run: cargo clippy --workspace -- -D warnings
diff --git a/.gitignore b/.gitignore
index 62a5514..c419eea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,4 +77,11 @@ yarn.lock
*.gif
*.bmp
!fixtures/images/
-uv.lock
\ No newline at end of file
+uv.lock
+
+# Python scratch / dev files
+packages/python/test.py
+packages/python/test_timed.py
+packages/python/benchmark.py
+packages/python/np_test.py
+packages/python/*.pdf
\ No newline at end of file
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 200d86e..e2d298f 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,4 +1,3 @@
{
- "packages/python": "0.6.4",
- "packages/javascript": "2.2.1"
+ "packages/python": "3.0.0"
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 227562b..a0a391a 100644
--- a/README.md
+++ b/README.md
@@ -2,146 +2,111 @@
[](https://pypi.org/project/epaper-dithering/)
[](https://www.npmjs.com/package/@opendisplay/epaper-dithering)
-[](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/python-test.yml)
-[](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/python-lint.yml)
+[](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/python-test.yml)
+[](https://github.com/OpenDisplay-org/epaper-dithering/actions/workflows/javascript-test.yml)
[](https://github.com/astral-sh/ruff)
-[](https://mypy.readthedocs.io/)
-A monorepo containing dithering algorithm implementations for e-paper/e-ink displays in multiple languages.
+Dithering algorithms for e-paper/e-ink displays. Rust core with Python and JavaScript/TypeScript bindings.
+
+## Examples
+
+
+
+**Dithering algorithms** (Spectra 6-color, Marienplatz U-Bahn station):
+
+
+
+**Color schemes** (Burkes, river scene):
+
+
## Packages
### Python (`packages/python/`)
-Dithering algorithms for e-paper displays. Published to PyPI as `epaper-dithering`.
+Published to PyPI as [`epaper-dithering`](https://pypi.org/project/epaper-dithering/).
-**Installation:**
```bash
pip install epaper-dithering
```
-**Usage:**
```python
from PIL import Image
-from epaper_dithering import dither_image, ColorScheme, DitherMode
+from epaper_dithering import dither_image, ColorScheme, DitherMode, SPECTRA_7_3_6COLOR_V2
img = Image.open("photo.jpg")
-dithered = dither_image(img, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG)
-dithered.save("output.png")
+
+# Idealized palette
+dithered = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG)
+
+# Measured palette — auto tone + gamut compression
+dithered = dither_image(img, SPECTRA_7_3_6COLOR_V2)
```
-See [`packages/python/README.md`](packages/python/README.md) for detailed documentation.
+See [`packages/python/README.md`](packages/python/README.md) for full documentation.
### JavaScript/TypeScript (`packages/javascript/`)
-Dithering algorithms for e-paper displays in TypeScript. Published to npm as `@opendisplay/epaper-dithering`.
+Published to npm as [`@opendisplay/epaper-dithering`](https://www.npmjs.com/package/@opendisplay/epaper-dithering).
-**Installation:**
```bash
npm install @opendisplay/epaper-dithering
-# or
-bun add @opendisplay/epaper-dithering
```
-**Usage (Browser):**
```typescript
-import { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering';
-
-// Create ImageBuffer from Canvas
-const canvas = document.createElement('canvas');
-const ctx = canvas.getContext('2d')!;
-ctx.drawImage(img, 0, 0);
-const imageData = ctx.getImageData(0, 0, img.width, img.height);
+import { ditherImage, ColorScheme, DitherMode, SPECTRA_7_3_6COLOR_V2 } from '@opendisplay/epaper-dithering';
-const imageBuffer = {
- width: imageData.width,
- height: imageData.height,
- data: imageData.data,
-};
+// ImageBuffer from Canvas API or Node.js (sharp, etc.)
+const dithered = ditherImage(imageBuffer, ColorScheme.BWR, { mode: DitherMode.BURKES });
-const dithered = ditherImage(imageBuffer, ColorScheme.BWR, DitherMode.FLOYD_STEINBERG);
+// Measured palette — auto tone + gamut compression
+const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR_V2);
```
-**Usage (Node.js with sharp):**
-```typescript
-import sharp from 'sharp';
-import { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering';
-
-const { data, info } = await sharp('photo.jpg')
- .ensureAlpha()
- .raw()
- .toBuffer({ resolveWithObject: true });
-
-const imageBuffer = {
- width: info.width,
- height: info.height,
- data: new Uint8ClampedArray(data),
-};
-
-const dithered = ditherImage(imageBuffer, ColorScheme.BWR, DitherMode.BURKES);
-```
-
-See [`packages/javascript/README.md`](packages/javascript/README.md) for detailed documentation.
+See [`packages/javascript/README.md`](packages/javascript/README.md) for full documentation.
## Features
-- **9 Dithering Algorithms**: NONE, BURKES, ORDERED, FLOYD_STEINBERG, ATKINSON, STUCKI, SIERRA, SIERRA_LITE, JARVIS_JUDICE_NINKE
+- **Rust Core**: All dithering logic in `packages/rust/core/` — shared by both packages
+- **9 Dithering Algorithms**: NONE, ORDERED, BURKES, FLOYD_STEINBERG, ATKINSON, STUCKI, SIERRA, SIERRA_LITE, JARVIS_JUDICE_NINKE
- **8 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE_4, GRAYSCALE_8, GRAYSCALE_16
-- **Multiple Languages**: Python and JavaScript/TypeScript implementations
-- **Universal**: Works in Python, Node.js, and browser environments
-- **Type-Safe**: Full type coverage in both languages
+- **Measured Palettes**: Calibrated RGB values for real displays with tone + gamut compression
+- **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug of LCH-weighted approaches
+- **Pre-dither Knobs**: Per-image exposure, saturation, shadows, highlights, and gamut compression — all orthogonal
-## Development
+## Repository Structure
-### Python Development
+```
+epaper-dithering/
+├── packages/
+│ ├── rust/
+│ │ ├── core/ # Shared Rust algorithms (OKLab, dithering, tone/gamut)
+│ │ └── wasm/ # wasm-bindgen bindings for JavaScript
+│ ├── python/ # PyO3/maturin extension + Python wrapper
+│ └── javascript/ # TypeScript wrapper + bundled WASM
+├── docs/ # Calibration and color science documentation
+└── .github/workflows/ # CI for all packages
+```
+
+## Development
```bash
+# Python (requires Rust toolchain: https://rustup.rs)
cd packages/python
uv sync --all-extras
+uv run maturin develop --release
uv run pytest tests/ -v
-uv run ruff check src/ tests/
-uv run mypy src/epaper_dithering
-```
-### JavaScript Development
-
-```bash
+# JavaScript
cd packages/javascript
bun install
-bun test
-bun run build
-```
-
-### Repository Structure
+bun run test
-```
-epaper-dithering/
-├── packages/
-│ ├── python/ # Python implementation
-│ │ ├── src/
-│ │ ├── tests/
-│ │ └── pyproject.toml
-│ └── javascript/ # TypeScript/JavaScript implementation
-│ ├── src/
-│ ├── tests/
-│ └── package.json
-├── fixtures/ # Shared test fixtures
-│ ├── images/ # Input test images
-│ └── expected/ # Expected dithered outputs
-├── .github/
-│ └── workflows/ # CI/CD for both packages
-├── docs/ # Shared documentation
-└── README.md
+# Rust
+cd packages/rust
+cargo test --workspace
```
## Related Projects
- **py-opendisplay**: [Python library for OpenDisplay BLE e-paper devices](https://github.com/OpenDisplay-org/py-opendisplay)
-
-## Future Plans
-
-- [ ] Python and js feature parity
-- [x] Add s-curve tone mapping for better contrast
-- [ ] Rust implementation with bindings for Python/JavaScript/other languages
-- [ ] Web-based demo/playground
-
diff --git a/docs/examples/algorithms_grid.png b/docs/examples/algorithms_grid.png
new file mode 100644
index 0000000..b765ebc
Binary files /dev/null and b/docs/examples/algorithms_grid.png differ
diff --git a/docs/examples/color_schemes_grid.png b/docs/examples/color_schemes_grid.png
new file mode 100644
index 0000000..74c0478
Binary files /dev/null and b/docs/examples/color_schemes_grid.png differ
diff --git a/docs/examples/frankfurt_before_after.png b/docs/examples/frankfurt_before_after.png
new file mode 100644
index 0000000..f8511d9
Binary files /dev/null and b/docs/examples/frankfurt_before_after.png differ
diff --git a/docs/examples/generate.py b/docs/examples/generate.py
new file mode 100644
index 0000000..5da1226
--- /dev/null
+++ b/docs/examples/generate.py
@@ -0,0 +1,116 @@
+"""Generate example images for README documentation."""
+
+from pathlib import Path
+from PIL import Image, ImageDraw, ImageFont
+from epaper_dithering import dither_image, ColorScheme, DitherMode, SPECTRA_7_3_6COLOR_V2
+
+FIXTURES = Path(__file__).parent.parent.parent / "packages/rust/core/tests/fixtures/images"
+OUT = Path(__file__).parent
+
+def load(name: str) -> Image.Image:
+ return Image.open(FIXTURES / name).convert("RGB")
+
+
+def dithered_rgb(img: Image.Image, palette, mode: DitherMode) -> Image.Image:
+ return dither_image(img, palette, mode=mode).convert("RGB")
+
+
+def label(img: Image.Image, text: str, size: int = 20) -> Image.Image:
+ """Add a label bar at the bottom of an image."""
+ bar_h = 32
+ out = Image.new("RGB", (img.width, img.height + bar_h), (30, 30, 30))
+ out.paste(img, (0, 0))
+ draw = ImageDraw.Draw(out)
+ try:
+ font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
+ except Exception:
+ font = ImageFont.load_default()
+ draw.text((img.width // 2, img.height + bar_h // 2), text, fill=(220, 220, 220), font=font, anchor="mm")
+ return out
+
+
+def grid(cells: list[list[Image.Image]]) -> Image.Image:
+ rows = len(cells)
+ cols = max(len(r) for r in cells)
+ w = cells[0][0].width
+ h = cells[0][0].height
+ out = Image.new("RGB", (cols * w, rows * h), (15, 15, 15))
+ for r, row in enumerate(cells):
+ for c, cell in enumerate(row):
+ out.paste(cell, (c * w, r * h))
+ return out
+
+
+# ── 1. Frankfurt night before/after ───────────────────────────────────────────
+
+print("Generating frankfurt night before/after...")
+frankfurt_orig = Image.open(FIXTURES / "frankfurt_nacht.png").convert("RGB")
+frankfurt_no_pre = dither_image(frankfurt_orig, SPECTRA_7_3_6COLOR_V2,
+ mode=DitherMode.BURKES, tone=0.0, gamut=0.0).convert("RGB")
+frankfurt_auto = dither_image(frankfurt_orig, SPECTRA_7_3_6COLOR_V2,
+ mode=DitherMode.BURKES).convert("RGB")
+
+p1 = label(frankfurt_orig, "Original")
+p2 = label(frankfurt_no_pre, "Spectra 6-color · Burkes · no preprocessing")
+p3 = label(frankfurt_auto, "Spectra 6-color · Burkes · auto tone + gamut")
+
+ba = Image.new("RGB", (p1.width + p2.width + p3.width + 8, p1.height), (15, 15, 15))
+ba.paste(p1, (0, 0))
+ba.paste(p2, (p1.width + 4, 0))
+ba.paste(p3, (p1.width + p2.width + 8, 0))
+ba.save(OUT / "frankfurt_before_after.png")
+
+# ── 2. All algorithms grid ────────────────────────────────────────────────────
+
+print("Generating algorithms grid...")
+src = load("ubahn_station.png")
+
+ALGOS = [
+ (DitherMode.NONE, "None (direct map)"),
+ (DitherMode.ORDERED, "Ordered (Bayer 4×4)"),
+ (DitherMode.FLOYD_STEINBERG, "Floyd-Steinberg"),
+ (DitherMode.ATKINSON, "Atkinson"),
+ (DitherMode.BURKES, "Burkes"),
+ (DitherMode.SIERRA_LITE, "Sierra Lite"),
+ (DitherMode.SIERRA, "Sierra"),
+ (DitherMode.STUCKI, "Stucki"),
+ (DitherMode.JARVIS_JUDICE_NINKE,"Jarvis-Judice-Ninke"),
+]
+
+algo_cells = []
+for i in range(0, len(ALGOS), 3):
+ row = []
+ for mode, name in ALGOS[i:i+3]:
+ cell = dithered_rgb(src, SPECTRA_7_3_6COLOR_V2, mode)
+ row.append(label(cell, name))
+ algo_cells.append(row)
+
+grid(algo_cells).save(OUT / "algorithms_grid.png")
+
+# ── 3. All color schemes grid ─────────────────────────────────────────────────
+
+print("Generating color schemes grid...")
+src2 = load("river.png")
+
+SCHEMES = [
+ (ColorScheme.MONO, "Mono"),
+ (ColorScheme.BWR, "BWR"),
+ (ColorScheme.BWY, "BWY"),
+ (ColorScheme.BWRY, "BWRY"),
+ (ColorScheme.BWGBRY, "BWGBRY (Spectra 6)"),
+ (ColorScheme.GRAYSCALE_4, "Grayscale 4"),
+ (ColorScheme.GRAYSCALE_8, "Grayscale 8"),
+ (ColorScheme.GRAYSCALE_16,"Grayscale 16"),
+]
+
+scheme_cells = []
+for i in range(0, len(SCHEMES), 4):
+ row = []
+ for scheme, name in SCHEMES[i:i+4]:
+ cell = dithered_rgb(src2, scheme, DitherMode.BURKES)
+ row.append(label(cell, name))
+ scheme_cells.append(row)
+
+grid(scheme_cells).save(OUT / "color_schemes_grid.png")
+
+print("Done. Output in", OUT)
diff --git a/docs/index.html b/docs/index.html
index b8f5d7f..b15c99d 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -184,6 +184,65 @@
color: var(--text);
}
+ /* Slider */
+ .slider-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .slider-row input[type=range] {
+ flex: 1;
+ -webkit-appearance: none;
+ appearance: none;
+ height: 3px;
+ background: var(--border-hi);
+ outline: none;
+ cursor: pointer;
+ }
+
+ .slider-row input[type=range]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 12px;
+ height: 12px;
+ background: var(--accent);
+ cursor: pointer;
+ border-radius: 0;
+ }
+
+ .slider-row input[type=range]:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+
+ .slider-row input[type=range]:disabled::-webkit-slider-thumb {
+ cursor: not-allowed;
+ }
+
+ .slider-val {
+ font-size: 10px;
+ color: var(--accent);
+ width: 32px;
+ text-align: right;
+ flex-shrink: 0;
+ }
+
+ .slider-val.muted { color: var(--muted); }
+
+ /* Compression section hidden when not applicable */
+ #compressionSection { display: none; }
+
+ .sub-group-label {
+ font-size: 9px;
+ font-weight: 500;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: 6px;
+ margin-top: 10px;
+ }
+
.toggle { position: relative; width: 30px; height: 16px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
@@ -506,6 +565,7 @@