feat(palette): adopt imprint — new categorical palette + named API + frontend rebuild#7692
Conversation
The style-guide had two contradicting values for grid/rule opacity: the Theme-adaptive Chrome token table at 0.10 vs the dedicated Grid Guidelines section "opacity 15-25%, very subtle". Per-library prompts (matplotlib, plotly, bokeh, makie, highcharts, altair, plotnine, seaborn rcParams) all inherited the 0.10 token; seaborn's hardcoded ax.yaxis.grid call already used 0.2 (following the verbal rule). Harmonize the spec at 0.15 — the lower edge of the Grid Guidelines band — so the token table and the verbal rule agree, and propagate to every library prompt that referenced the 0.10 token. Generated plots stay subtle but become clearly readable against the warm paper-ink surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… color wheel Second round of palette exploration (#5817 follow-up). v0 (variants A-F) compared against Okabe-Ito; v1 (this) measures everything against live D, which has shipped as ANYPLOT_PALETTE since the v0 round. New deliverable at docs/reference/palette-variants-v1/: - D-baseline.html — live anyplot palette rendered with the same template as candidates, so it sits in the lineup as the bar to beat - D1 tight-chroma — C ∈ [24, 32], position-1 pinned to red band [15°, 35°] so the corridor still yields a true crimson (#AE3030) rather than a rust pick - D3 expand-8 — live D's 7 hues + 1 freely-picked 8th hue (indigo #7981FD), diametrically opposite tan; fills both remaining wheel gaps without forcing a swap - T tetradic — 4 hue anchors 90° apart starting at brand green, 3 mid-quadrant fillers - W warm-pole — D's max-min selection plus a warm-pole scoring bonus centred at 55°, with #B71D27 pinned for semantic-red availability - index.html — hero color wheel with candidate-overlay toggles + baseline-card layout flip + Δ-vs-D coloring - compare.html — side-by-side card grid with D as the reference row New generator scripts/palette-variants-v1.py extends the v0 algorithm with: - forbidden_hue_bands / warm_bonus knobs in select_palette - tetradic + d-* strategy branches in _strategy_bands - n_hues parameter on Variant (D3 uses 8) - render_color_wheel — pre-rendered CAM02-UCS PNG disk (perceptually honest chroma fade, no slice seams) with palette dots placed at their actual (C, H) coords, optional chroma-corridor toggle, overlay live-D comparison scripts/_palette_common.py: bump v1 sample-chart gridlines from 0.06 to 0.15 (catch-up to the new style-guide token), bump PAGE_CSS --rule from 0.10 to 0.15 for the same consistency. v0 (palette-variants/) untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR (1) harmonizes the “grid/rule” opacity design token to 0.15 across generator prompts/style guide, and (2) adds a new v1 palette-variant diagnostic generator under scripts/ intended to produce docs/reference/palette-variants-v1/* HTML pages comparing candidates against the live variant D palette in core/images.py.
Changes:
- Update grid/rule opacity token from
0.10→0.15across the default style guide, plot generator prompt, and multiple per-library prompt snippets. - Add
scripts/palette-variants-v1.py, a new palette-variants v1 generator (baseline = live D) including a CAM02-UCS color wheel visualization. - Align the palette diagnostics shared CSS/sample-chart grid rendering in
scripts/_palette_common.pyto the new0.15rule/grid token.
Reviewed changes
Copilot reviewed 12 out of 19 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/palette-variants-v1.py | New v1 palette diagnostic generator (baseline vs live D) with variant pages, index, compare page, and color wheel rendering. |
| scripts/_palette_common.py | Updates diagnostic sample-chart gridline opacity and CSS --rule token to 0.15 for consistency. |
| prompts/default-style-guide.md | Updates the canonical grid/rule token to 0.15 and keeps reference snippet consistent. |
| prompts/plot-generator.md | Updates matplotlib-style grid alpha token to 0.15 in the generator guidance snippet. |
| prompts/library/seaborn.md | Updates seaborn rcParams grid.alpha guidance token to 0.15. |
| prompts/library/plotnine.md | Updates plotnine panel_grid_major alpha to 0.15. |
| prompts/library/plotly.md | Updates Plotly GRID rgba token to 0.15. |
| prompts/library/matplotlib.md | Updates matplotlib ax.grid alpha token to 0.15. |
| prompts/library/makie.md | Updates Makie grid RGBA alpha to 0.15 in examples. |
| prompts/library/highcharts.md | Updates Highcharts GRID rgba token to 0.15. |
| prompts/library/bokeh.md | Updates Bokeh grid line alpha guidance to 0.15. |
| prompts/library/altair.md | Updates Altair gridOpacity guidance token to 0.15. |
| Five new candidates explore "refine vs. rethink": | ||
|
|
||
| D1 — d-tight-chroma (D's max-min but C ∈ [24, 32] — narrower paper-ink) | ||
| D2 — d-wide-spread (D's max-min with 60° pairwise hue spread target) | ||
| D3 — d-swap-tan (D's max-min but hue band [50°, 90°] banned at pos 6 | ||
| — forces an alternative to the live tan #BA843E) | ||
| T — tetradic (4 anchors 90° apart, brand-green anchored, 3 fillers) | ||
| W — warm-pole (D's max-min plus a warm-hue scoring bonus 30°–80°) | ||
|
|
| """Pick 7 hues for a variant. Greedy max-min ΔE selection under all 4 | ||
| CVD conditions, with per-position hue bands and the per-variant chroma | ||
| corridor as candidate masks. If no candidate matches the strictest band, | ||
| the band half-width is widened in 10° steps until something fits. | ||
|
|
| v1 additions: | ||
| - ``forbidden_hue_bands``: a list of (center_deg, half_width_deg) bands | ||
| to EXCLUDE from every position globally. Used by D3 (d-swap-tan) to | ||
| ban the tan band [50°, 90°] so a different 7th hue gets picked. | ||
| - ``warm_bonus``: (center_deg, half_width_deg, weight) — a soft additive | ||
| score bonus for candidates whose hue is within (half-width) of the | ||
| center. Used by W (warm-pole) to bias picks toward 30°–80°. |
| Variant( | ||
| "D1", "tight-chroma", "d-tight-chroma", | ||
| "d-tight-chroma", | ||
| "live D's max-min ΔE selection but with the paper-ink chroma corridor narrowed to C ∈ [24, 32] — predicts cleaner co-existence in dense charts at the cost of some headroom. live D's semantic red #B71D27 is pinned at position 1 so loss/error/bad can map to the expected colour rather than a tight-corridor brown", | ||
| ), |
| import sys | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import Callable |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
D1-8 mirrors D3's expand-8 approach for the tight-chroma corridor: D1's
7 picks leave a 75° purple→red back-gap, the 8th slot is greedy-picked
there for a matte rosé (#954477) that bridges purple and red while
staying inside C ∈ [24, 32].
Also introduces ``reorder_pure_cvd_greedy`` (opt-in via
USE_PURE_CVD_REORDER) for D1-8 — the original wheel-gap-first heuristic
in ``reorder_first_4`` was picking a 60°-valid but CVD-weak first-4
like {green, blue, tan, mauve} whenever the 8th hue opened new
gap-valid quadruples. Pure-CVD greedy keeps the worst-pair curve high:
n=4 lifts from 10.70 → 17.44 (now beats D3's 15.61 at the same n).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ortings
palette-variants-v2 narrows the field from v1's 5 candidates to the two
real contenders (vivid-8 = D3, muted-8 = D1-8) and adds tooling to
compare slot orderings side-by-side:
- hero with both color wheels (chroma corridor always visible) and
hue-sorted strips for direct hue-by-hue comparison between palettes
- sticky TOC linking the 4 sortings: pure-CVD greedy, wheel-gap-first,
hue-order (rainbow), every-other-hue (interleaved)
- per-sorting scorecard with per-n winner strips for CVD + normal
vision (green = vivid, blue = muted, grey = tie)
- per-cell chart stack: light theme block + dark theme block, each
with lines, bars (all 8), pie (first 4), stocks (first 4),
edge-cluster overlap scatter (centre mix)
- column-major pair grid so strips/tables align between palettes
even when intros wrap to different line counts
- per-n worst-pair ΔE table with normal-vision row + CVD-min row
Reuses v1 utilities via importlib (hyphen in filename).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| _V1_SPEC = importlib.util.spec_from_file_location( | ||
| "palette_variants_v1", REPO_ROOT / "scripts" / "palette-variants-v1.py" | ||
| ) | ||
| assert _V1_SPEC is not None and _V1_SPEC.loader is not None | ||
| v1 = importlib.util.module_from_spec(_V1_SPEC) | ||
| # Register before exec_module so @dataclass can resolve cls.__module__ during | ||
| # class construction (otherwise dataclasses.py raises NoneType.__dict__). | ||
| sys.modules["palette_variants_v1"] = v1 | ||
| _V1_SPEC.loader.exec_module(v1) |
| 1. pure-CVD greedy max-min (slowest possible per-n ΔE degradation; | ||
| pos 0 fixed brand-green; pos 1 fixed at | ||
| muted-8's semantic red for stability) |
| """Palette variants v2 — head-to-head: vivid-8 (D3) vs muted-8 (D1-8). | ||
|
|
||
| v1 produced five candidate variants (D-baseline, D1, D1-8, D3, T, W). Two |
| WARM_BONUS: dict[str, tuple[float, float, float]] = { | ||
| # W — additive bonus centred at 55° (warm orange-red), half-width 30°, | ||
| # weight 3.0 ΔE units at the centre. Strong enough to nudge selection | ||
| # toward warms without overriding the no-clash gap mask. | ||
| "warm-pole": (55.0, 30.0, 3.0), | ||
| } |
- Documented unanimous expert verdict favoring muted-8 over vivid-8 - Highlighted recurring themes and critiques from five independent reviews - Recommended next steps for palette optimization and semantic picking
Final muted-8 slot order via hybrid-v3 algorithm (coarse 6-band hue partition with deferred red anchor). Includes side-by-side viewer (hybrid-v3 vs pure-cvd-greedy) and a full transparency document covering CVD prevalence, per-n ΔE measurements, trade-offs, and comparison to Okabe-Ito / Tol / ColorBrewer / Tableau / D3 / Petroff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| 1. pure-CVD greedy max-min (slowest possible per-n ΔE degradation; | ||
| pos 0 fixed brand-green; pos 1 fixed at | ||
| muted-8's semantic red for stability) |
| from typing import Callable, Sequence | ||
|
|
||
| import numpy as np | ||
|
|
||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parent.parent | ||
| sys.path.insert(0, str(REPO_ROOT / "scripts")) | ||
|
|
||
|
|
||
| def _load(module_name: str, filename: str): | ||
| spec = importlib.util.spec_from_file_location( | ||
| module_name, REPO_ROOT / "scripts" / filename | ||
| ) | ||
| assert spec is not None and spec.loader is not None | ||
| mod = importlib.util.module_from_spec(spec) | ||
| sys.modules[module_name] = mod | ||
| spec.loader.exec_module(mod) | ||
| return mod | ||
|
|
||
|
|
||
| v1 = _load("palette_variants_v1", "palette-variants-v1.py") | ||
| v2 = _load("palette_variants_v2", "palette-variants-v2.py") | ||
|
|
||
| from _palette_common import ( # noqa: E402 | ||
| DARK_THEME_FULL, | ||
| LIGHT_THEME_FULL, | ||
| PAGE_CSS, | ||
| PAGE_JS, | ||
| hex_to_rgb1, | ||
| pairwise_delta_e, | ||
| render_sample_bars, | ||
| render_sample_chart, | ||
| to_jab, | ||
| worst_cvd_pairwise_delta_e, | ||
| ) |
| # Full sorting set for the comparison table (the new hybrid + v2's 4). | ||
| ALL_SORTINGS: list[tuple[str, str, Callable[[list[str]], list[str]]]] = [ | ||
| ("hybrid-v3", "hybrid-v3 (family-diverse first 4, red deferred, CVD-greedy tail)", sort_hybrid_v3), | ||
| ("pure-cvd-greedy", "pure-CVD greedy max-min", v2.sort_pure_cvd_greedy), | ||
| ] |
| "all five sortings use the identical 8 hexes. only the slot order " | ||
| "differs. ★ marks the recommended sort. the per-n table shows the " | ||
| "weakest pair's ΔE under the min of the 3 CVD simulations " |
| v3 introduces a hybrid sort that fixes both: the first ``first_n`` slots are | ||
| constrained to come from distinct hue bins (45° wide by default) AND picked | ||
| by greedy max-min worst-CVD ΔE; the tail is unconstrained pure-CVD greedy. |
| This corroborates the project memory note | ||
| [`palette_must_anchor_semantic_red`](file:///home/meake/.claude/projects/-home-meake-projects-anyplot/memory/feedback_palette_must_anchor_semantic_red.md) | ||
| — the rule that candidate palettes must allow a true red, via explicit | ||
| seeding if needed. |
| This is documented in the project memory | ||
| [`feedback_palette_semantic_exception`](file:///home/meake/.claude/projects/-home-meake-projects-anyplot/memory/feedback_palette_semantic_exception.md) | ||
| — the rule that conventions like "bad → red, good → green, | ||
| warning → amber" are first-class concerns, not categorical-distinctness |
| """Palette variants v2 — head-to-head: vivid-8 (D3) vs muted-8 (D1-8). | ||
|
|
…er + 2 adaptive neutrals) The 8 categorical hues don't cover three semantic roles cleanly: caution/warning (ochre reads as earth/brown, not amber), totals/baseline (needs structural neutral, not a category), and other/rest in stacked charts (needs soft neutral). Add three named anchors that live OUTSIDE palette[:n]: - palette.amber = #DDCC77 (Paul Tol "muted" yellow) — min ΔE_CVD = 14.52 to all 8 categorical hexes. The two saturated alternatives #D4A017 and #D4AF37 both collapse to ΔE_CVD ≈ 2.3 against lime under deuteranopia. - palette.neutral — theme-adaptive ink (#1A1A17 light / #F0EFE8 dark). Already exists as NEUTRAL_LIGHT/NEUTRAL_DARK in _palette_common.py; now exposed via the named API. - palette.muted — theme-adaptive ink-muted (#6B6A63 light / #A8A79F dark). For "other"/disabled series and confidence bands. Updates: - New "Semantic anchors" section on the v3 page (between hero and finalist) with split-swatch visualisation for the two adaptive neutrals - decision-rationale.md: amber distance table, theme-adaptive neutral explanation, corrected named-API surface, semantic.warning now maps to amber (not ochre) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Document the muted-aesthetic / WCAG-3:1 tension honestly. Lighter members of muted-8 (lavender, lime, cyan, ochre) plus the amber semantic anchor fall below 3:1 against the cream bg #F5F3EC — the same limitation Okabe-Ito (#F0E442), Tol "muted" (#DDCC77, #88CCEE), and ColorBrewer Set2 share, since muted palettes earn distinguishability through chroma rather than L-spread. Recommended industry-standard fix: a thin ink-color stroke on affected series. Stroke contrast (#1A1A17 on #F5F3EC = 15.71:1) always passes regardless of fill colour. amber stays a fixed hex (it lives outside the categorical pool, only reached intentionally via palette.amber for caution/warning) — the same outline rule applies wherever it's used. Visual demo in index.html#contrast: every categorical hue + amber as a 48px dot on both theme bgs, with the sub-3:1 members shown both as-is and with a 2px ink ring (light stage rescues 5, dark stage rescues 1). rationale.md gains a "Contrast caveats & the outline pattern" section with full per-theme ratio table. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1px matches the typical stroke width used in real plot rendering (line / scatter edges) and is still clearly visible on the 48px audit dots. Keeps the demo aligned with the guidance it's illustrating. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| """Worst-CVD ΔE of the weakest pair inside the first-n subset, for n=2..N. | ||
| Takes the min across normal + 3 CVD simulations.""" |
| # Full sorting set for the comparison table (the new hybrid + v2's 4). | ||
| ALL_SORTINGS: list[tuple[str, str, Callable[[list[str]], list[str]]]] = [ | ||
| ("hybrid-v3", "hybrid-v3 (family-diverse first 4, red deferred, CVD-greedy tail)", sort_hybrid_v3), | ||
| ("pure-cvd-greedy", "pure-CVD greedy max-min", v2.sort_pure_cvd_greedy), | ||
| ] |
| This corroborates the project memory note | ||
| [`palette_must_anchor_semantic_red`](file:///home/meake/.claude/projects/-home-meake-projects-anyplot/memory/feedback_palette_must_anchor_semantic_red.md) | ||
| — the rule that candidate palettes must allow a true red, via explicit | ||
| seeding if needed. |
| This is documented in the project memory | ||
| [`feedback_palette_semantic_exception`](file:///home/meake/.claude/projects/-home-meake-projects-anyplot/memory/feedback_palette_semantic_exception.md) | ||
| — the rule that conventions like "bad → red, good → green, | ||
| warning → amber" are first-class concerns, not categorical-distinctness | ||
| edge cases. |
| """palette variants v3 — muted-8 finalist with hybrid sort. | ||
|
|
||
| After 5 independent expert reviewers unanimously picked muted-8 over vivid-8 | ||
| (see ``../palette-variants-v2/expert-reviews.md``), only the slot order was | ||
| still open. The v2 head-to-head exposed a real trade-off: pure-CVD-greedy |
The rationale was current on technical decisions (slot order, semantic anchors, contrast audit) but missing three points that came up in later discussion: - "Why n=8?" — the fundamental pool-size choice (live anyplot has 7+1 with documented cyan-or-lavender gap; 8 is the CVD discrimination sweet spot; matches Okabe-Ito/ColorBrewer Set2/Petroff 2021 lineage; 360°/45° = 8 clean hue-wheel bins) - "Red anchor — considered alternatives" — full 5-candidate analysis (#AE3030 / #BE2B2B / #C8322C / #B71D27 / #A41E22) with CAM02-UCS + WCAG + CVD data. Documents that #BE2B2B would marginally improve dark-bg WCAG but the practical impact is small; #AE3030 stays as the shipping choice with the outline pattern handling the gap - Next steps gain entries 6 (per-theme hex sets) and 7 (OKLCH + P3 notation) so future maintainers see what was considered and what's parked Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hard switch from variant-D (PR #7617) to imprint — the v3 hybrid-v3 muted-8 palette designed in feat/palette-variants-v1. Full rationale: docs/reference/palette-variants-v3/decision-rationale.md. Changes - NEW core/palette.py: single source of truth for the 8 categorical hues, 3 semantic anchors (amber + 2 theme-adaptive neutrals), named API (palette.green, palette.semantic.bad, ...), sequential cmap (imprint_seq: green→blue), and diverging cmap (imprint_div_light / imprint_div_dark: red↔neutral↔blue with theme-adaptive midpoint). - core/images.py: drop the 7 variant-D hex constants and ANYPLOT_PALETTE list; re-import from core.palette. ANYPLOT_GREEN keeps its hex (#009E73); ANYPLOT_RED changes from #B71D27 to #AE3030; PURPLE/SKY/ PINK/TAN are gone (replaced by LAVENDER/BLUE/CYAN/OCHRE — exposed with ANYPLOT_* prefix for symmetry). - LIBRARY_COLORS: rebuilt with library-brand-match strategy (matplotlib keeps red, plotly stays blue, bokeh stays purple, seaborn picks rose for its warm statistical mood, highcharts picks cyan to stay distinct from plotly's blue). - matplotlib cmap registration runs at core.images import time. Not touched (intentional, for follow-up PRs) - 7 plot implementations in plots/**/python/*.py hardcode the old variant-D hex list locally — they keep working but show the old palette until regenerated via /regen. - Frontend app/src/pages/PalettePage.tsx and PaletteStrip.tsx still show Okabe-Ito — separate frontend PR. - Prompt system (prompts/default-style-guide.md, prompts/library/*.md, prompts/plot-generator.md) still references the old palette — Phase 2 of the rollout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| from .palette import ( | ||
| IMPRINT as ANYPLOT_PALETTE, | ||
| GREEN as ANYPLOT_GREEN, |
| LIME as ANYPLOT_LIME, | ||
| AMBER as ANYPLOT_AMBER, | ||
| palette, |
| palette, | ||
| register_with_matplotlib as _register_imprint_cmaps, |
| from types import SimpleNamespace | ||
| from typing import Callable | ||
|
|
||
| from matplotlib.colors import LinearSegmentedColormap |
| """palette variants v3 — muted-8 finalist with hybrid sort. | ||
|
|
||
| After 5 independent expert reviewers unanimously picked muted-8 over vivid-8 | ||
| (see ``../palette-variants-v2/expert-reviews.md``), only the slot order was | ||
| still open. The v2 head-to-head exposed a real trade-off: pure-CVD-greedy |
| theme : "light" | "dark", default "light" | ||
| On light bg, midpoint = warm cream #F5F3EC; on dark bg, midpoint | ||
| = warm near-black #1A1A17. | ||
| """ | ||
| midpoint = "#F5F3EC" if theme == "light" else "#1A1A17" | ||
| return LinearSegmentedColormap.from_list( | ||
| f"imprint_div_{theme}", [RED, midpoint, BLUE] |
…attern Phase 2 of the imprint palette rollout: bring the prompt system in line with the new 8-hex palette + 3 semantic anchors so every newly generated implementation inherits the right colours and the n>4 redundant-encoding rules. Changes - default-style-guide.md: full Color Philosophy rewrite — 8-hex hybrid-v3 table, new "Semantic anchors" section (amber + theme-adaptive neutral and muted), "Color Restraint & series-count guidance" table tying concrete CVD ΔE values to per-n redundant-encoding requirements, new "Optional outline pattern (AI judgment)" section, updated continuous cmap definitions (imprint_seq / imprint_div with theme-adaptive midpoint). - plot-generator.md: inline hex string list + cmap definitions updated. - 11 library prompts (matplotlib / seaborn / plotly / bokeh / altair / plotnine / pygal / highcharts / ggplot2 / letsplot / makie): ANYPLOT_PALETTE = [...] list updated to 8 hexes in hybrid-v3 order + ANYPLOT_AMBER constant added; continuous cmap definitions renamed (anyplot_seq → imprint_seq, anyplot_div → imprint_div) with new endpoints (green→blue, red↔midpoint↔blue). - quality-criteria.md + quality-evaluator.md: VQ-07 scoring rules updated for positions 1–8 + the 3 anchors. Legacy variant-D hexes now listed as auto-reject signals at the 0-point tier. - workflow-prompts/ai-quality-review.md, impl-generate-claude.md, impl-repair-claude.md: cmap names + position ranges updated. The 7 plot implementations under plots/**/python/ that hardcode the old palette locally are still untouched on purpose (Phase 4 skipped) — they keep working with the old hexes until regenerated via /regen, at which point the new prompts kick in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 5a of the imprint palette rollout — palette-zentrale frontend files. The /palette page is now the canonical visual reference for the new 8-hex palette + 3 semantic anchors, with all the data from the decision-rationale embedded as live interactive sections. PalettePage.tsx — complete rebuild (~500 LOC) - Hero: chroma wheel SVG with 8 swatch dots positioned at their CAM02 hue angles, brand-green pin on slot 0 - Palette grid: 8 swatches with hex + name + family + role - Semantic anchors: 3 cards (amber + 2 split-swatch adaptive neutrals) - Copy & paste: language tabs (Python / R / Julia / JS / Go / Rust), OKLCH toggle that switches every snippet between hex and oklch() notation, per-language standalone code (no `pip install anyplot`) - CVD safety: per-n ΔE table with color-coded verdict (safe / borderline / fail) and concrete redundant-encoding guidance - WCAG audit (collapsible): full per-theme contrast table for all 9 hexes including amber, with pass/fail markers - History (collapsible): v0 Okabe-Ito → v1 variant D → v2 D1-8 → v3 imprint, each with swatch strip + summary PaletteStrip.tsx — SWATCHES array swapped from 7 Okabe hexes + ink to 8 imprint hexes in hybrid-v3 sort. theme/index.ts — colors.okabe namespace replaced by colors.imprint with all 8 hues + amber. Semantic colors remapped: error #D55E00 → #AE3030 (matte red, slot 4), warning #E69F00 → #DDCC77 (amber anchor), info #0072B2 → #4467A3 (slot 2). tooltipLight #56B4E9 → #2ABCCD (cyan, slot 5). accent #E69F00 → #BD8233 (ochre). tokens.css — --ok-* CSS variables replaced by --imprint-* (green, lavender, blue, ochre, red, cyan, rose, lime, amber). Code-syntax theme rebuilt on imprint hues for both light and dark modes. Not touched (Phase 5b — separate PR) - LandingPage.tsx (CLUSTER_PALETTE + okabe swatch row + analytics tracking event named 'palette_okabe_ito') - MapPage.tsx (PLOT_TYPE_COLORS) - AboutPage.tsx / ScienceNote.tsx (text refs) - CodeShowcase.tsx / CodeHighlighter.tsx (syntax-highlighting hexes) - LandingPage.test.tsx (event name) Validated: yarn lint (0 errors), yarn tsc --noEmit (clean), yarn test --run (507/507 pass), visual smoke test via chrome-devtools at /palette with both collapsibles expanded. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| from types import SimpleNamespace | ||
| from typing import Callable | ||
|
|
||
| from matplotlib.colors import LinearSegmentedColormap |
| from .palette import ( | ||
| IMPRINT as ANYPLOT_PALETTE, | ||
| GREEN as ANYPLOT_GREEN, | ||
| LAVENDER as ANYPLOT_LAVENDER, | ||
| BLUE as ANYPLOT_BLUE, | ||
| OCHRE as ANYPLOT_OCHRE, | ||
| RED as ANYPLOT_RED, | ||
| CYAN as ANYPLOT_CYAN, | ||
| ROSE as ANYPLOT_ROSE, | ||
| LIME as ANYPLOT_LIME, | ||
| AMBER as ANYPLOT_AMBER, | ||
| palette, | ||
| register_with_matplotlib as _register_imprint_cmaps, | ||
| ) |
| | 6 | `#D359A7` | pink | | | ||
| | 7 | `#BA843E` | tan | | | ||
| | 8 | *adaptive neutral* | — | `#1A1A1A` on light theme, `#E8E8E0` on dark theme. Reserved for aggregates, residuals, reference lines. | | ||
| 8 hues in hybrid-v3 sort order. First 4 slots span 4 distinct perceptual hue families (green / purple / blue / yellow), then greedy max-min CVD distance for the tail. Slot 4 is the deferred semantic-red anchor. |
| # Sequential / single-polarity (intensity, magnitude, density) | ||
| anyplot_seq = LinearSegmentedColormap.from_list("anyplot_seq", ["#009E73", "#003D94"]) | ||
| imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["#009E73", "#4467A3"]) | ||
|
|
||
| # Diverging (signed deviations, residuals, correlations) | ||
| anyplot_div = LinearSegmentedColormap.from_list("anyplot_div", ["#BB0D22", "#A2A598", "#007AD9"]) | ||
| imprint_div = LinearSegmentedColormap.from_list("imprint_div", ["#AE3030", "#F5F3EC", "#4467A3"]) | ||
|
|
| # Continuous — only the two anyplot palette-derived cmaps are allowed: | ||
| ANYPLOT_SEQ = [[0.0, "#009E73"], [1.0, "#003D94"]] # sequential / single-polarity | ||
| ANYPLOT_DIV = [[0.0, "#BB0D22"], [0.5, "#A2A598"], [1.0, "#007AD9"]] # diverging | ||
| ANYPLOT_SEQ = [[0.0, "#009E73"], [1.0, "#4467A3"]] # sequential / single-polarity | ||
| ANYPLOT_DIV = [[0.0, "#AE3030"], [0.5, "#F5F3EC"], [1.0, "#4467A3"]] # diverging | ||
| # Sequential: color_continuous_scale=ANYPLOT_SEQ | ||
| # Diverging: color_continuous_scale=ANYPLOT_DIV |
| # Sequential (single-polarity): #009E73 → #4467A3 | ||
| seq_stops = tuple(_lerp_hex("#009E73", "#4467A3", i / (n - 1)) for i in range(n)) | ||
| # Diverging (around a meaningful midpoint): #AE3030 ↔ #F5F3EC ↔ #4467A3 |
| /* Semantic anchors outside the categorical pool */ | ||
| --imprint-amber: #DDCC77; /* warning / caution (fixed hex) */ | ||
| /* --imprint-neutral and --imprint-muted are aliased to --ink / --ink-muted | ||
| so they automatically flip per theme (totals/baseline → ink, other/rest → ink-muted) */ | ||
|
|
| function CodeBlock({ code }: { code: string }) { | ||
| const [copied, setCopied] = useState(false); | ||
| const handleCopy = () => { | ||
| navigator.clipboard.writeText(code); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 1500); | ||
| }; |
| <Tooltip title={copied ? 'copied' : 'copy'} placement="left"> | ||
| <IconButton | ||
| size="small" | ||
| onClick={handleCopy} | ||
| sx={{ | ||
| position: 'absolute', top: 6, right: 6, | ||
| color: copied ? colors.primary : 'var(--ink-muted)', | ||
| }} | ||
| > | ||
| <ContentCopyIcon fontSize="small" /> | ||
| </IconButton> |
| <Box | ||
| component="button" | ||
| onClick={() => setOpen(o => !o)} | ||
| sx={{ |
Iteration 2 incorporating reviewer feedback on the page:
Hero
- Terser opening ("every plot on anyplot uses imprint…") with sober tone
emphasising "easier on the eyes" and "let the data speak" — same family
as palettes shipped at scale in scientific publishing and editorial
dashboards. No marketing copy, no link to the markdown rationale.
- Real chroma wheel (Adobe / Dracula palette-page style): CSS conic-gradient
over OKLCH hues + radial-gradient chroma fade to centre. 8 imprint dots
positioned by their actual (hue, chroma) coords in CAM02-UCS.
- Compare-with toggle: click Okabe-Ito, Tol muted, or anyplot variant D
(previous) and the matching palette overlays as hollow rings on the same
wheel — visualises the chroma + hue differences against imprint.
Palette grid
- slot 1 / slot 2 now carry real role labels (creative / artistic for
lavender, cool / water / info for blue) instead of just "slot N".
Semantic anchors
- Amber hint rewritten to focus on functional rationale (max ΔE against
lime under deuteranopia) — dropped the "Paul Tol muted yellow" name-drop
per the request to keep the copy sober.
Code snippets
- Trimmed to the 4 languages anyplot actually targets: Python, R, Julia,
JavaScript. Go and Rust gone.
- Dropped the "standalone snippets, no pip install" header — copy speaks
for itself, the intro text was overhead.
CVD section
- Added a "show alt. sort" toggle that surfaces the pure-CVD-greedy ordering
alongside imprint (16.34 → 21.45 at n=3, 13.98 → 19.81 at n=4). A short
explainer below clarifies imprint trades a few ΔE points for visually
distinct hue families in the first 4 slots; n=8 floor is identical (8.81)
because both sortings ship the same 8 hexes.
History
- Dropped the dead-end "Used by anyplot until v1" line.
- Each historic palette now has an external link where it leads naturally:
Okabe-Ito → jfly.uni-koeln.de (same source the LandingPage links to),
variant D → Petroff 2021 arXiv (since variant D was selected via a
Petroff-style ΔE search).
Validated: yarn tsc --noEmit (clean), yarn lint (0 errors, 2 pre-existing
warnings), yarn test --run (507/507), visual smoke test via chrome-devtools
including the compare toggle (Okabe-Ito and Tol muted overlays render
correctly with hollow rings, both collapsibles expand cleanly).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Iteration 3 on the /palette page, addressing reviewer feedback that the 8 hex grid was too vertical, per-slot metadata was scattered, and the CVD-optimal sort needed to be reachable for users who actually want max discrimination under colorblindness. Compact slot grid - 8 hexes are now an 8-column compact grid (4 cols on mobile) matching the docs/reference/palette-variants-v3 reference layout. Each cell shows the swatch (hex inside, ink-color picked for legibility), then below the swatch: slot N + name + role + H=…° + norm/cvd ΔE + WCAG ratio per theme (☼ light / ☽ dark). All the per-slot metadata collapsed into one card. - Per-slot min ΔE values pre-computed (vs all other imprint members, under normal vision + worst-of-3 CVD sims). Tooltips on the norm/cvd cells explain what they mean. Sort toggle (imprint default ↔ CVD-optimal) - Pure-CVD-greedy max-min sort surfaced as a one-click toggle alongside the categorical grid. Switching it reorders the grid, the strip, the wheel-dot mapping (slot 0 stays brand), and the copy snippet — so a CVD-first audience can copy the alternative ordering directly. - An inline note below the toggle calls out the trade-off: CVD-optimal sort puts two greens (brand + lime) and two purples (lavender + rose) side by side in the first 4 slots — better ΔE numbers, worse hue-family diversity. Default stays imprint. - The per-n ΔE table now shows BOTH sortings side-by-side (with ↑/↓ arrows so the difference is visible at a glance) instead of a conditional toggle column — the trade-off should be visible without another click. Hero text - "lower-chroma than Tableau" → "low-chroma, warm-tinted for cream paper" — kept the spirit, dropped the specific competitor name-drop to keep the copy sober. - Compare-with palettes are now pill buttons WITH a mini-swatch strip per palette (not just text links), promoted out of the prose into their own block under the description. Easier to scan, more visual. Wheel - Size bumped from 300 → 380px on desktop so the chroma differences are legible when comparing palettes against each other. Continuous cmaps (NEW section between anchors and copy/paste) - imprint_seq (green→blue) and imprint_div (red↔midpoint↔blue) shown as live CSS-gradient strips with their endpoint hexes labelled and a note about the theme-adaptive midpoint (#F5F3EC light / #1A1A17 dark). - Cmap definitions also baked into the copy snippets for all 4 languages — Python uses matplotlib LinearSegmentedColormap, R uses scale_color_gradient/_gradient2, Julia uses cgrad, JS uses two-stop arrays. midpoint is always theme-adaptive in the code. PaletteStrip.tsx now accepts an optional `hexes` prop so the palette page can pass the sorted set without forking the component. Validated: yarn tsc --noEmit (clean), yarn test --run (507/507), visual smoke test via chrome-devtools confirms grid+sort+cmap+history all render and interact correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| from types import SimpleNamespace | ||
| from typing import Callable | ||
|
|
||
| from matplotlib.colors import LinearSegmentedColormap |
| function snippet(lang: Lang, oklch: boolean, sortedPalette: Swatch[]): string { | ||
| const values = oklch ? sortedPalette.map(s => s.oklch) : sortedPalette.map(s => s.hex); | ||
| const amberVal = oklch ? 'oklch(0.841 0.108 98.3)' : '#DDCC77'; | ||
| const seqStart = oklch ? 'oklch(0.620 0.130 165.5)' : '#009E73'; | ||
| const seqEnd = oklch ? 'oklch(0.516 0.104 260.6)' : '#4467A3'; | ||
| const divStart = oklch ? 'oklch(0.503 0.163 25.2)' : '#AE3030'; | ||
| const divEnd = oklch ? 'oklch(0.516 0.104 260.6)' : '#4467A3'; |
| return `const ANYPLOT_PALETTE = [ | ||
| ${values.map(v => oklch ? ` "${v}"` : ` colorant"${v}"`).join(',\n')}, | ||
| ] | ||
| const ANYPLOT_AMBER = ${oklch ? `"${amberVal}"` : `colorant"${amberVal}"`} # warning |
| # continuous data — sequential + diverging cmaps | ||
| from matplotlib.colors import LinearSegmentedColormap | ||
| imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["${seqStart}", "${seqEnd}"]) | ||
| midpoint = "#F5F3EC" if THEME == "light" else "#1A1A17" # theme-adaptive | ||
| imprint_div = LinearSegmentedColormap.from_list("imprint_div", ["${divStart}", midpoint, "${divEnd}"])`; |
| # continuous data — ggplot2 gradient scales | ||
| midpoint <- if (theme == "light") "#F5F3EC" else "#1A1A17" | ||
| scale_color_gradient(low = "${seqStart}", high = "${seqEnd}") # sequential | ||
| scale_color_gradient2(low = "${divStart}", mid = midpoint, high = "${divEnd}", midpoint = 0) # diverging`; |
| # continuous data — Makie cgrad | ||
| using ColorSchemes | ||
| midpoint = theme == "light" ? colorant"#F5F3EC" : colorant"#1A1A17" | ||
| const ANYPLOT_SEQ = cgrad([colorant"${seqStart}", colorant"${seqEnd}"]) | ||
| const ANYPLOT_DIV = cgrad([colorant"${divStart}", midpoint, colorant"${divEnd}"])`; |
| // continuous data — two-stop / three-stop gradients | ||
| const midpoint = theme === "light" ? "#F5F3EC" : "#1A1A17"; // theme-adaptive | ||
| const IMPRINT_SEQ = [[0, "${seqStart}"], [1, "${seqEnd}"]]; | ||
| const IMPRINT_DIV = [[0, "${divStart}"], [0.5, midpoint], [1, "${divEnd}"]];`; |
| const handleCopy = () => { | ||
| navigator.clipboard.writeText(code); | ||
| setCopied(true); | ||
| setTimeout(() => setCopied(false), 1500); | ||
| }; |
| <Box | ||
| component="button" | ||
| onClick={() => setOpen(o => !o)} | ||
| sx={{ |
| ## Color Philosophy | ||
|
|
||
| anyplot uses the **anyplot palette** — a bespoke, colorblind-safe categorical palette tuned for paper-ink rendering on both warm-cream and warm-black surfaces. It is the single consistency rule that spans every library and every plot. | ||
| anyplot uses the **anyplot palette** (codename **imprint**) — a bespoke, colorblind-safe categorical palette tuned for paper-ink rendering on both warm-cream and warm-black surfaces. It is the single consistency rule that spans every library and every plot. Full design rationale: [`docs/reference/palette-variants-v3/decision-rationale.md`](../docs/reference/palette-variants-v3/decision-rationale.md). |
…op redundant strip Iteration 4 on /palette per feedback: Hero - Title "imprint" → "anyplot's imprint palette" (better positioning). - Compare-with palettes now 4: Okabe-Ito, Tol muted, ColorBrewer Set2 (NEW), and the previous anyplot palette renamed from "variant D (previous)" to plain "anyplot (previous)". - "lower-chroma than Tableau" already softened in iteration 3; no competitor name-drops on the page. - The full-width PaletteStrip below the hero is gone — it duplicated the new 8-card grid below and added no information. Chroma wheel - Each imprint dot and overlay ring now has an SVG `<title>` element so hover surfaces the hex code natively (no JS tooltip needed). Dots use `cursor: pointer` to signal interactivity. The 8 categorical hues - Grid expanded from 8-column compact to 4-column × 2-row layout so the per-slot metadata is actually readable (was 9px font, now 11px). Each swatch is now 96px tall instead of 72px. - Click a swatch → hex is copied to clipboard, swatch text flips to "copied ✓" for 1.5s. Tooltip on hover: "click to copy hex". - Removed "no other cmap is used — not viridis, not jet, not rainbow" from the cmaps section. We don't need to enumerate what we don't use. History - Per-version date column removed — "2026" / "May 2026" / "2002 / 2011" added noise without telling the reader anything load-bearing. Title + external link (where present) are enough. PaletteStrip - Component is no longer imported from this page but retains the `hexes` prop it gained in iteration 3 — it's still used by other callers (LandingPage). Validated: yarn tsc --noEmit (clean), visual smoke test confirms script-italic section headers render correctly (font-feature-settings: ss02, MonoLisa italic) — same rendering pipeline as the LandingPage section headers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ded view The "best variant for CVD users" section was just restating per-slot ΔE numbers that were already visible on every card in the 8-hue grid — the only thing it actually added was the trade-off comparison, which is only relevant if someone is *considering* switching to the CVD-optimal sort. So move that content into the inline note that appears when the sort toggle is set to CVD-optimal. When sort = imprint (default), the page is now shorter — palette grid, anchors, cmaps, copy, then directly into WCAG / history. No empty section repeating data. When sort = CVD-optimal, an expanded block appears between the toggle and the grid with: - why-use rationale (CVD-first audience, small-n, colour-only encoding) - compact per-n table showing imprint default vs CVD-optimal with the ΔE gain in green where it's positive (n=2..6) and grey where it's identical (n=7..8) - explicit trade-off explanation (two greens + two purples in first 4) - "when to switch" guidance Cleanup: dropped the unused verdictFor helper that the removed section was the only caller of; renumbered the section comments. Validated: yarn tsc --noEmit (clean), yarn test --run (507/507), visual smoke test confirms the expanded block renders correctly when CVD-optimal is active and disappears when switching back to imprint. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…is enough The hex → "copied ✓" swap on the swatch itself is already a clear interaction signal; the tooltip explaining what the click does was adding noise on hover for a behaviour that's obvious from the result. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| from types import SimpleNamespace | ||
| from typing import Callable | ||
|
|
||
| from matplotlib.colors import LinearSegmentedColormap |
| AMBER as ANYPLOT_AMBER, | ||
| palette, | ||
| register_with_matplotlib as _register_imprint_cmaps, | ||
| ) |
| /* imprint palette — 8 categorical hues in hybrid-v3 sort order */ | ||
| --imprint-green: #009E73; /* slot 0 — brand */ | ||
| --imprint-lavender: #C475FD; /* slot 1 */ | ||
| --imprint-blue: #4467A3; /* slot 2 */ |
| # Sequential / single-polarity (intensity, magnitude, density) | ||
| anyplot_seq = LinearSegmentedColormap.from_list("anyplot_seq", ["#009E73", "#003D94"]) | ||
| imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["#009E73", "#4467A3"]) | ||
|
|
||
| # Diverging (signed deviations, residuals, correlations) | ||
| anyplot_div = LinearSegmentedColormap.from_list("anyplot_div", ["#BB0D22", "#A2A598", "#007AD9"]) | ||
| imprint_div = LinearSegmentedColormap.from_list("imprint_div", ["#AE3030", "#F5F3EC", "#4467A3"]) | ||
|
|
| # Diverging: three-stop range with domainMid at 0 | ||
| alt.Color('delta:Q', scale=alt.Scale( | ||
| range=['#BB0D22', '#A2A598', '#007AD9'], | ||
| range=['#AE3030', '#F5F3EC', '#4467A3'], | ||
| domainMid=0, | ||
| )) |
| 1. pure-CVD greedy max-min (slowest possible per-n ΔE degradation; | ||
| pos 0 fixed brand-green; pos 1 fixed at | ||
| muted-8's semantic red for stability) |
| # anyplot categorical palette — "imprint" (v3 hybrid-v3 ordering). | ||
| # Defined as a separate module so the project's named-API (palette.green, | ||
| # palette.semantic.bad, etc.) plus sequential / diverging cmaps live in one | ||
| # place. Full design rationale: | ||
| # docs/reference/palette-variants-v3/decision-rationale.md |
| <IconButton | ||
| size="small" | ||
| onClick={handleCopy} | ||
| sx={{ |
| <Box | ||
| component="button" | ||
| onClick={() => setOpen(o => !o)} | ||
| sx={{ |
| /* Semantic anchors outside the categorical pool */ | ||
| --imprint-amber: #DDCC77; /* warning / caution (fixed hex) */ | ||
| /* --imprint-neutral and --imprint-muted are aliased to --ink / --ink-muted | ||
| so they automatically flip per theme (totals/baseline → ink, other/rest → ink-muted) */ |
…print
Closes the residual Okabe-Ito footprint after the imprint palette
adoption. Frontend pages, components, tests, and the canonical style
guide now consistently reference the imprint palette and its semantic
anchors.
Phase 5b — frontend consumers
- LandingPage.tsx: CLUSTER_PALETTE swapped (#009E73/#AE3030/#4467A3),
the 7-Okabe PALETTE swatch row replaced by the 8-hex imprint set
with proper names. Click-to-copy now works on each swatch (hex flips
to "copied ✓" for 1.5s, same UX as PalettePage). Palette section
prose rewritten — drops the Okabe & Ito attribution since the
palette is no longer just an Okabe import. The 'palette_okabe_ito'
analytics tracking event is gone with the link it used to attach to.
- MapPage.tsx: CLUSTER_COLORS rebuilt with the 8 imprint hexes in
hybrid-v3 sort order (slots 0..7) — previously 7 Okabe-Ito hexes.
- AboutPage.tsx: palette paragraph reframed around imprint (still
cites Okabe-Ito / Tol / ColorBrewer as neighbours of the same
family).
- ScienceNote.tsx: blockquote attribution changed from "Okabe & Ito,
Color Universal Design (2008)" to "anyplot imprint, design rationale"
— the surrounding values copy still applies, just sourced honestly.
- CodeShowcase.tsx: 19 hardcoded Okabe hexes (#56B4E9/#E69F00/#009E73)
in the syntax-highlighting demo block swapped to imprint dark-theme
syntax tones (#2ABCCD cyan keywords, #99B314 lime functions,
#DDCC77 amber strings) — terminal box is always-dark so theme-aware
CSS vars don't apply. Also fixed a now-broken --ok-green reference
to --imprint-green.
- CodeHighlighter.tsx: `okabeItoTheme` constant renamed to
`imprintTheme`. Body of the theme stays unchanged (uses CSS vars
from tokens.css which Phase 5a already migrated).
- LandingPage.test.tsx: drops the test that asserted the
'palette_okabe_ito' analytics event and the Okabe-Ito link click —
both are gone from the page.
- PalettePage.tsx: drops the "same neighbourhood as palettes that
have shipped at scale in scientific publishing and editorial
dashboards" line per feedback — kept the copy sober.
Phase 3 — docs/reference/style-guide.md
- Section 4.1 ("The Okabe-Ito Palette") fully rewritten as "The
imprint Palette" with the new hue table, semantic-anchors table,
hybrid-v3 sort rationale, and a link to the v3 decision rationale.
- Section 4.5 (Status Colors) remapped onto imprint hues + amber
anchor: error → #AE3030 matte red, warning → #DDCC77 amber, info →
#4467A3 blue, hover → #BD8233 ochre.
- Section 4.6 (Plot-only Colors) updated — lavender / cyan / rose /
lime are the new plot-only set.
- Section 9.1 — replaces the "yellow on white" caveat with a broader
WCAG note linking to the outline pattern in the v3 rationale.
- Section 9.2 — categorical-vs-continuous guidance now points to the
ANYPLOT-shipped `imprint_seq` and `imprint_div` cmaps rather than
recommending viridis/cividis/BrBG (which were never anyplot's
endorsed choice — clarified now).
- Section 9.5 — Python implementation reference updated to the new
`core.palette` module API; CSS variable block renamed --ok-* →
--imprint-* and extended with all 8 categorical slots + the amber
anchor.
- Section 11 — reference CSS skeleton updated with full imprint set.
- All remaining text references to "Okabe-Ito" / `--ok-green` etc.
rewritten to point at imprint. Okabe-Ito only kept in the historical
references list (§13) and where named as a neighbour family.
Validated: yarn tsc --noEmit (clean), yarn lint (0 errors, 2 pre-
existing warnings), yarn test --run (507/507).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…e refs Final cross-cutting cleanup after Phase 5b + Phase 3. Audit grep across *.md / *.py / *.ts / *.tsx / *.css / *.yml / *.yaml for any remaining Okabe-Ito or variant-D hex references; only legitimate historical mentions (comparison families, history sections, release notes, palette-variants exploration scripts) were left intact. Updates - app/src/pages/LandingPage.tsx: file-header comment "the same Okabe-Ito palette" → "the same imprint palette". - core/images.py: 3 doc comments referencing `--ok-green` updated to `--imprint-green` to match the tokens.css migration from Phase 5a. - docs/reference/plausible.md: drop the `palette_okabe_ito` analytics event row — the event was removed in Phase 5b. - docs/reference/anyplot-landing-mockup.html: prepend a HISTORICAL header comment explaining this mockup predates both the MonoLisa-only typography and the imprint palette, and pointing readers at the current design system. Body left unchanged (44 hex refs preserved as design-archaeology context). - scripts/style-variants.yaml: the `palette_tableau` sanity-check variant was mapping from the old variant-D hexes — rewired to the imprint set so the substitution still produces a Tableau-coloured plot. - scripts/palette-analysis.py: docstring extended with a HISTORICAL marker pointing to core/palette.py and the v3 decision rationale as the current sources of truth. - .serena/memories/style_guide.md: "Color — Okabe-Ito palette" section rewritten as "Color — imprint palette" with the 8-slot table and 3 semantic anchors. Broken `--ok-green` refs updated to `--imprint-green`. Continuous-cmap note updated to point at imprint_seq / imprint_div. What stays as legitimate historical refs (no change needed) - core/palette.py module docstring + GREEN constant comment — acknowledge brand green originated with Okabe-Ito - app/AboutPage.tsx, app/PalettePage.tsx, docs/reference/style-guide.md, prompts/default-style-guide.md — cite Okabe-Ito / Tol muted / ColorBrewer Set2 as members of the same academic-publishing family - prompts/quality-criteria.md — lists legacy variant-D hexes as auto-reject signals in VQ-07 scoring - agentic/runs/release-v2.0.0-notes.md — historical v2.0.0 release notes - docs/reference/palette-variants-v1/v2/v3/ — full design history - scripts/palette-variants-v1/v2/v3.py + scripts/_palette_common.py — variant exploration scripts that produced the current spec - .playwright-mcp/ — auto-generated test recording yaml files Validated: yarn tsc --noEmit (clean), yarn lint (0 errors, 2 pre-existing warnings), yarn test --run (507/507). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI lint run flagged the imports in core/images.py: - E402 (module-level import not at top of file): the `from .palette import …` block sat inside the design-tokens section after constants - I001 (unsorted import block): related; ruff prefers alphabetical with one-per-line `as` aliases Fix: lift the palette imports to the top of core/images.py next to the other module imports. Ruff reformatted them to one-per-line `from .palette import X as ANYPLOT_X` style. Added `# noqa: F401` on the three symbols that are re-exported public API (`ANYPLOT_PALETTE`, `ANYPLOT_AMBER`, `palette`) so ruff doesn't flag them as unused. Also picked up `ruff format` on core/palette.py — cosmetic only (line-wrap of the SimpleNamespace `semantic=...` to fit the configured line length). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
|
||
| from types import SimpleNamespace | ||
|
|
| <Box | ||
| className="hex-label" | ||
| key={background} | ||
| component="button" | ||
| onClick={() => handleCopy(hex)} | ||
| className="swatch" |
| from matplotlib.colors import LinearSegmentedColormap | ||
| anyplot_seq = LinearSegmentedColormap.from_list("anyplot_seq", ["#009E73", "#003D94"]) | ||
| anyplot_div = LinearSegmentedColormap.from_list("anyplot_div", ["#BB0D22", "#A2A598", "#007AD9"]) | ||
| # Sequential / single-polarity heatmaps: cmap=anyplot_seq | ||
| # Diverging (signed deviations, residuals, correlations): cmap=anyplot_div | ||
| imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", ["#009E73", "#4467A3"]) | ||
| midpoint = "#F5F3EC" if THEME == "light" else "#1A1A17" # theme-adaptive | ||
| imprint_div = LinearSegmentedColormap.from_list("imprint_div", ["#AE3030", midpoint, "#4467A3"]) |
| # Continuous — only the two anyplot palette-derived cmaps are allowed: | ||
| ANYPLOT_SEQ = [[0.0, "#009E73"], [1.0, "#003D94"]] # sequential / single-polarity | ||
| ANYPLOT_DIV = [[0.0, "#BB0D22"], [0.5, "#A2A598"], [1.0, "#007AD9"]] # diverging | ||
| ANYPLOT_SEQ = [[0.0, "#009E73"], [1.0, "#4467A3"]] # sequential / single-polarity | ||
| ANYPLOT_DIV = [[0.0, "#AE3030"], [0.5, "#F5F3EC"], [1.0, "#4467A3"]] # diverging | ||
| # Sequential: color_continuous_scale=ANYPLOT_SEQ | ||
| # Diverging: color_continuous_scale=ANYPLOT_DIV |
| # palette-v3 — muted-8 finalist (decision rationale) | ||
|
|
||
| > Generated by [`scripts/palette-variants-v3.py`](../../../scripts/palette-variants-v3.py). | ||
| > Companion to [`index.html`](./index.html) (live charts) and the v2 review at | ||
| > [`../palette-variants-v2/expert-reviews.md`](../palette-variants-v2/expert-reviews.md). | ||
|
|
| RESPONSIVE_SIZES = [1200, 800, 400] | ||
| RESPONSIVE_FORMATS: list[tuple[str, str, dict]] = [("png", "PNG", {}), ("webp", "WEBP", {"quality": 80})] | ||
| WEBP_FULL_QUALITY = 85 | ||
|
|
||
| # GCS bucket for static assets (fonts) | ||
| GCS_STATIC_BUCKET = "anyplot-static" | ||
| MONOLISA_FONT_PATH = "fonts/MonoLisaVariableNormal.ttf" | ||
| MONOLISA_ITALIC_FONT_PATH = "fonts/MonoLisaVariableItalic.ttf" | ||
| FONT_CACHE_DIR = Path("/tmp/anyplot-fonts") | ||
|
|
||
| # ============================================================================= | ||
| # Design tokens — match docs/reference/style-guide.md (§4 Color System) | ||
| # ============================================================================= | ||
| # | ||
| # Theme dicts so OG images can be rendered light or dark from the same code. | ||
| # Token names mirror the CSS custom properties defined in the React app | ||
| # (`--bg-page`, `--ink`, `--ok-green`, etc.) so the OG cards read as a direct | ||
| # translation of the in-product surfaces. | ||
|
|
||
| # anyplot categorical palette — variant D ("balanced") from the palette | ||
| # exploration in #5817. Position 1 (#009E73) carries the same bluish green | ||
| # as Okabe-Ito's first slot so the brand identity (`any.plot()` dot, first | ||
| # series) is preserved; positions 2–7 are selected via Petroff-style | ||
| # max-min ΔE search in the CAM02-UCS paper-ink corridor (J' ∈ [45,72], | ||
| # C ∈ [22,36]). See docs/reference/palette-variants/D-balanced.html for | ||
| # the derivation and CVD analysis. | ||
| ANYPLOT_GREEN = "#009E73" # brand anchor — the dot in any.plot(); ALWAYS first series | ||
| ANYPLOT_PURPLE = "#9418DB" | ||
| ANYPLOT_RED = "#B71D27" | ||
| ANYPLOT_SKY = "#16B8F3" | ||
| ANYPLOT_LIME = "#99B314" | ||
| ANYPLOT_PINK = "#D359A7" | ||
| ANYPLOT_TAN = "#BA843E" | ||
|
|
||
| ANYPLOT_PALETTE = [ANYPLOT_GREEN, ANYPLOT_PURPLE, ANYPLOT_RED, ANYPLOT_SKY, ANYPLOT_LIME, ANYPLOT_PINK, ANYPLOT_TAN] | ||
| # (`--bg-page`, `--ink`, `--imprint-green`, etc.) so the OG cards read as a direct | ||
| # translation of the in-product surfaces. The imprint palette itself is | ||
| # re-exported from core/palette at the top of this module. |
| const handleCopy = (hex: string) => { | ||
| navigator.clipboard.writeText(hex); | ||
| setCopiedHex(hex); | ||
| setTimeout(() => setCopiedHex((c) => (c === hex ? null : c)), 1500); | ||
| }; |
| const ANYPLOT_SEQ = cgrad([colorant"#009E73", colorant"#003D94"]) # sequential / single-polarity | ||
| const ANYPLOT_DIV = cgrad([colorant"#BB0D22", colorant"#A2A598", colorant"#007AD9"]) # diverging | ||
| const ANYPLOT_SEQ = cgrad([colorant"#009E73", colorant"#4467A3"]) # sequential / single-polarity | ||
| const ANYPLOT_DIV = cgrad([colorant"#AE3030", colorant"#F5F3EC", colorant"#4467A3"]) # diverging (midpoint #F5F3EC light / #1A1A17 dark) |
| const handleCopy = (hex: string) => { | ||
| navigator.clipboard.writeText(hex); | ||
| setCopiedHex(hex); | ||
| setTimeout(() => setCopiedHex((c) => (c === hex ? null : c)), 1500); | ||
| }; |
| <Box | ||
| className="hex-label" | ||
| key={background} | ||
| component="button" | ||
| onClick={() => handleCopy(hex)} | ||
| className="swatch" |
| - **Lavender (`#C475FD`), cyan (`#2ABCCD`), rose (`#954477`), or lime (`#99B314`) in UI chrome.** These are plot-only colors. Using them in navigation or buttons breaks the color hierarchy. | ||
| - **Brand green in backgrounds, body text emphasis, or non-logo icons.** Reserve `#009E73` for the seven approved contexts (§4.4). | ||
| - **Categorical palettes on continuous data.** Use viridis/cividis/BrBG instead — see §9.2. |
| LIGHT_THEME: dict[str, str] = { | ||
| "bg_page": "#F5F3EC", # warm cream — matches `--bg-page` in app/src/styles/tokens.css |
| Categorical palettes on continuous data produce misleading banding. anyplot ships exactly two palette-derived continuous colormaps; **no other cmap is allowed** — not viridis, not cividis, not BrBG, not Blues/Greens/Reds, and never jet/hsv/rainbow. Both anyplot cmaps are perceptually-uniform in CAM02-UCS: | ||
|
|
||
| - **`imprint_seq` (sequential):** brand green → blue. Use for single-polarity continuous data (intensity, magnitude, density, single-polarity heatmaps). Build with `LinearSegmentedColormap.from_list("imprint_seq", ["#009E73", "#4467A3"])` (matplotlib) or the library's equivalent two-stop gradient API. | ||
| - **`imprint_div` (diverging):** matte-red ↔ near-neutral ↔ blue. Use when the data has a meaningful midpoint (correlations, residuals, signed deviations, diverging heatmaps). The midpoint is theme-adaptive — use `#F5F3EC` on light bg, `#1A1A17` on dark bg. Build with `LinearSegmentedColormap.from_list("imprint_div", ["#AE3030", midpoint, "#4467A3"])`. | ||
|
|
| def diverging(theme: str = "light") -> LinearSegmentedColormap: | ||
| """Diverging cmap: matte-red ↔ near-neutral ↔ blue. | ||
|
|
||
| The midpoint flips per theme to keep the diverging "zero" reading as | ||
| part of the chart bg rather than as an opaque grey blob. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| theme : "light" | "dark", default "light" | ||
| On light bg, midpoint = warm cream #F5F3EC; on dark bg, midpoint | ||
| = warm near-black #1A1A17. | ||
| """ | ||
| midpoint = "#F5F3EC" if theme == "light" else "#1A1A17" | ||
| return LinearSegmentedColormap.from_list(f"imprint_div_{theme}", [RED, midpoint, BLUE]) |
Triaged the 14 inline comments from the two most recent Copilot reviews
(IDs 4367910318 + 4367970615). Applied 9 of them; documented 3 as skip
in the PR thread (Callable already removed, PR-title-mismatch already
fixed by the title rewrite, "rule opacity 0.10/0.15" extended into a
real cross-file sweep below).
Diverging cmap midpoint — switched from #F5F3EC (bg-page) to #FAF8F1
(bg-surface) so the "zero" band fades into the actual plot surface
rather than the page background.
- core/palette.py:diverging("light") midpoint updated.
- prompts/default-style-guide.md, prompts/plot-generator.md,
prompts/workflow-prompts/impl-generate-claude.md, all 11 library
prompts, docs/reference/style-guide.md, app/src/pages/PalettePage.tsx
(gradient strip + 4 language snippets + descriptive paragraph) all
updated to match.
- Dark theme midpoint stays #1A1A17 (also bg-surface in dark).
Theme-adaptive midpoint in cmap snippets — several library prompts
were hardcoding the light midpoint and would render the wrong neutral
in dark theme. Made them theme-adaptive:
- matplotlib.md: already used `midpoint = "#…" if THEME == "light" else "#…"` — only the hex changed.
- seaborn.md, bokeh.md: added the same conditional + use a midpoint
variable in the cmap definition.
- plotly.md: renamed ANYPLOT_SEQ / ANYPLOT_DIV to imprint_seq /
imprint_div for consistency with the rest of the prompt corpus, and
switched to a theme-adaptive midpoint.
- makie.md: added `const _midpoint = THEME == "light" ? … : …` and use
it in the cgrad call.
Frontend interaction polish — clipboard API can throw in insecure
contexts and returns a Promise that can reject:
- LandingPage.tsx and PalettePage.tsx (both copyHex and the CodeBlock
handleCopy) now wrap the writeText call in .then/.catch so the
"copied" state only flips on success and unhandled rejections don't
leak to the console.
- All `<Box component="button">` instances in PalettePage and
LandingPage now set `type="button"` explicitly so they can't
accidentally submit if dropped inside a `<form>`. Swatch buttons
also got aria-label="Copy {hex} to clipboard" for screen readers.
core/images.py — removed the import-time matplotlib colormap
registration side effect. core.images is PIL-only and adding a
matplotlib import on the OG-image router path was excessive. Callers
that render with matplotlib should call
`core.palette.register_with_matplotlib()` themselves. Comment block
in core/images.py documents this. The unused import alias is gone.
Rule-opacity 0.10 → 0.15 cross-file sweep — Copilot flagged that
core/images.py rule tokens were still the old 0.10-flattened values
even though prompts moved to 0.15 in commit 0b4f417. Closing the
loop:
- core/images.py LIGHT_THEME["rule"]: #DFDDD6 → #D4D2CC (0.15
flattened on cream bg)
- core/images.py DARK_THEME["rule"]: #1E1E1B → #33332F (0.15
flattened on warm near-black bg)
- app/src/styles/tokens.css --rule (both themes): 0.10 → 0.15
docs/reference/style-guide.md — anti-pattern section at line 1187
still recommended viridis/cividis/BrBG for continuous data, which
contradicted §9.2's updated rule. Fixed to point at imprint_seq /
imprint_div. Same fix at line 314 (Python library palette listing).
Validated: yarn tsc --noEmit (clean), yarn lint (0 errors, 2
pre-existing warnings unrelated), yarn test --run (511/511 passing),
uv run ruff check . (clean), backend smoke-test confirms
ANYPLOT_PALETTE + palette named API + LIBRARY_COLORS all work after
the registration call was removed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot review feedback — triage & fixesPulled the 14 inline comments from the two most recent Copilot reviews and applied the actionable ones. Push: Applied (9)
Extended pickup — rule opacity 0.10 → 0.15
Skipped (3)
Validated: 🤖 Generated with Claude Code |
…generate (#7713) ## Summary - Fixes a regression from #7692 where `import core.images` started transitively requiring matplotlib via `core.palette`'s module-level `from matplotlib.colors import LinearSegmentedColormap`. - That broke the `Process plot images (light + dark)` step in `impl-generate.yml` for **non-Python** libraries (ggplot2 / makie), where the runner only installs PIL helpers — not matplotlib. raincloud-basic R and Julia auto-failed 3× with `ModuleNotFoundError: No module named 'matplotlib'`. - Keeps `core/palette.py` matplotlib-free at module level. Cmap factory (`diverging`) and `imprint_seq` / `imprint_div_{light,dark}` are constructed lazily on first attribute access via module `__getattr__`. ## Why this is the right layering `core/images.py` is documented as PIL-only — the whole point of the reviewer feedback on #7692 was that importing it must not pull matplotlib in as a side effect. R/Julia plot scripts hardcode hex codes; they don't need matplotlib for rendering. But the workflow calls `python -m core.images process plot.png` afterwards to optimise the rendered PNG with PIL — and that's where the missing matplotlib bites. ## Public API preserved - `from core.palette import imprint_seq` ✅ (lazy) - `core.palette.imprint_seq.name == "imprint_seq"` ✅ - `register_with_matplotlib()` still registers all three cmaps ✅ - Re-import in same process still cached after first build ✅ ## Test plan - [x] `uv run ruff check .` clean - [x] `uv run ruff format --check .` clean - [x] Local smoke test: hex constants + lazy cmaps + register_with_matplotlib - [x] Local smoke test simulating no-matplotlib env: `import core.images` succeeds, `LIBRARY_COLORS` populated - [ ] CI green - [ ] Re-trigger raincloud-basic ggplot2 + makie after merge — confirm green 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Replaces the previous variant D palette (PR #7617) with imprint — anyplot's new 8-hue colourblind-safe categorical palette + 3 semantic anchors (amber for warning, theme-adaptive neutral and muted). Tuned for warm-paper rendering, validated against deuteranopia / protanopia / tritanopia, sits in the academic-publishing family next to Okabe-Ito, Paul Tol "muted", and ColorBrewer Set2.
Full design rationale:
docs/reference/palette-variants-v3/decision-rationale.md. Earlier variants (v1, v2, expert reviews) preserved underdocs/reference/palette-variants-*/for design archaeology.What's in this PR
Phase 1 — Backend foundation (
core/)core/palette.py— single source of truth: 8 categorical hexes in hybrid-v3 sort order, 3 semantic anchors, named API (palette.green,palette.semantic.bad, …), sequential cmapimprint_seq(green→blue) + theme-adaptive diverging cmapimprint_div_{light,dark}(red↔midpoint↔blue), matplotlib registration.core/images.pyrefactored — drops old 7 ANYPLOT_* constants, re-imports fromcore/palette.py.LIBRARY_COLORSrebuilt with library-brand-match strategy.Phase 2 — Prompt system (18 files)
prompts/default-style-guide.md— full Color Philosophy rewrite with 8-hex hybrid-v3 table, new Semantic anchors section, series-count guidance with concrete ΔE_CVD numbers, optional outline pattern.ANYPLOT_PALETTElists updated to 8 hexes +ANYPLOT_AMBER, cmaps renamed with new endpoints.Phase 3 — Style guide doc
docs/reference/style-guide.mdSection 4.1 rewritten as "The imprint Palette" with hue table, semantic-anchors table, hybrid-v3 sort rationale. Status colours (§4.5) and plot-only colours (§4.6) remapped onto imprint. CSS variables + Python implementation reference updated.Phase 5a — Frontend palette page (
/palette)app/src/pages/PalettePage.tsxcompletely rebuilt across 5 iterations: real chroma wheel (CSS conic-gradient OKLCH + chroma fade) with hover tooltips, compare-with toggle (Okabe-Ito / Tol muted / ColorBrewer Set2 / anyplot previous), compact 4×2 slot grid with click-to-copy hex, sort toggle (imprint default ↔ CVD-optimal max-min) with inline trade-off info, semantic anchors cards, continuous cmaps gradient strips, copy snippets in 4 languages (Python / R / Julia / JS) with OKLCH toggle, collapsible WCAG audit + palette history.theme/index.ts—colors.okabe→colors.imprint, semantic colours remapped.styles/tokens.css—--ok-*→--imprint-*, code-syntax theme rebuilt for both light and dark.components/PaletteStrip.tsx— 8-hex imprint set, optionalhexesprop.Phase 5b — Frontend consumers
LandingPage.tsx—CLUSTER_PALETTE+ 7-Okabe swatch row swapped (now click-to-copy),palette_okabe_itoanalytics event gone.MapPage.tsx—CLUSTER_COLORSrebuilt with 8 imprint hexes.AboutPage.tsx,ScienceNote.tsx— palette prose reframed around imprint.CodeShowcase.tsx,CodeHighlighter.tsx— syntax hexes updated,okabeItoTheme→imprintTheme.LandingPage.test.tsx— drops the gone Okabe-Ito tracking-event test.Final audit sweep
docs/reference/plausible.md(gone event row),.serena/memories/style_guide.md(Color section),scripts/style-variants.yaml(palette_tableau map),scripts/palette-analysis.py+docs/reference/anyplot-landing-mockup.html(HISTORICAL markers). Historical / comparison references (Okabe-Ito as family-neighbour, variant-D hexes as auto-reject signals in VQ-07, palette-variants exploration scripts) preserved intact.Intentionally NOT in this PR (Phase 4)
7 plot implementations under
plots/**/implementations/python/*.pystill hardcode the old variant-D palette inline. Per the design discussion these stay untouched and will pick up imprint via the next/regencycle, when the new prompts kick in.Validation
yarn tsc --noEmit— cleanyarn lint— 0 errors (2 pre-existing warnings, neither palette-related)yarn test --run— 507 / 507 passing/,/palette,/map,/aboutTest plan
/palettepage renders the chroma wheel, palette grid, semantic anchors, cmap previews, collapsibles/and/palette🤖 Generated with Claude Code