Skip to content

v3#67

Open
dy wants to merge 183 commits into
masterfrom
v3
Open

v3#67
dy wants to merge 183 commits into
masterfrom
v3

Conversation

@dy

@dy dy commented Nov 20, 2025

Copy link
Copy Markdown
Member

Targets the v3 milestone.


Resolves the open-issue backlog on merge. Each issue has a status comment; they auto-close when this lands on master.

Fixed in v3 — closes #55, closes #54, closes #53, closes #51, closes #48, closes #47, closes #42, closes #40, closes #39, closes #38, closes #36, closes #33, closes #32, closes #28, closes #7

Declined-with-reason (proprietary catalogs / out-of-scope notation / alpha-not-a-channel — all documented in the README) — closes #17, closes #16, closes #45, closes #41

Supersedes #68 — its hcg negative-hue fix is already in v3 (hcg.js); the contributor's test assertion was ported with credit.

Left open: #6 (WebGL/WebGPU shader exports) — a genuine future direction tracked in docs/todo.md.

@dy dy marked this pull request as ready for review January 7, 2026 19:42
dy added 12 commits January 7, 2026 14:44
Removed duplicate logo image from README.
Phase 0 (make it load): close 35 unclosed object literals, fix hwb
duplicate import + hcy missing export. Suite runs again: 111 pass, 1 skip.

Correctness (Opus audit, all findings adversarially confirmed; each fix
verified vs colorjs.io/culori with a regression test):
- oklab: linearize sRGB before the LMS matrix (reuse lrgb transfer);
  fixes oklab/oklch/okhsl/okhsv -- gray L 79.47 -> 59.99 (matches colorjs 2e-6)
- lab-d50: fix 100x scale (white L 522 -> 100); matches colorjs D50 lab
- wiring: bracket-hyphenated hub keys -> 7 spaces reachable from rgb,
  pinned by an integrity/reachability test

Docs reconciled to truth (71 spaces, not 72); full verified defect
inventory + tiered plan in docs/todo.md.
All from the Opus audit, each proven against a reference / first principles
and pinned by a regression test:
- yuv: inverse blue coeff 2.02311 -> 2.03211 = (1-Kb)/Umax (digit transposition)
- jzczhz: drop the double x100 (jzazbz already conventional); pure polar
  transform, roundtrip 3e-15, hue matches colorjs
- tsl: inverse recovers theta=atan2(g',r') via cos/sin (sign preserved) +
  black guard; exact roundtrip (red was [124,83,-83])
- hcl: inverse uses frac() not JS % (Chilliant formula); full 360-hue sweep
  roundtrips with 0 failures (green was [255,255,0])
- labh: D65 white-point ratios instead of Illuminant-C; white -> [100,0,0]
  (was a=-5.34)
hcy was a verbatim copy of hsi (Y = arithmetic mean). Replaced with the
Chilliant RGB-HCY model: Y is the color's Rec.601 luma (red 29.9, green 58.7,
blue 11.4 -- was 33.3 for all), chroma renormalized by the hue's luma so equal
Y means equal brightness. Achromatic inputs are NaN-safe; exact roundtrip.
Verified + regression test.
dy added 30 commits July 9, 2026 00:27
- hwb: white + black ≥ 100 now returns an exact gray (CSS Color 4), killing
  the ~1e-14 inter-channel residue that made neighbouring hue sliders jump as
  whiteness was dragged; pinned by a regression test
- docs: docs/landing/ modules moved to docs/js/; removed the unused engine.js
  and the scratch study pages
- sliders: channel value centres vertically and slides aside only when the
  picker actually reaches it; picker shows whole at the min/max edges; bar
  labels adopt the plot axis-label style
- dossier: one shared tag recipe for the hero label, title pills and gamut
  chips; transfer curve extended to perceptual spaces with a live luminance
  marker; reach shown as % of visible; historical spaces no longer faded
… pure WGSL translate, ~4 kB bundles vs the ~200 kB catalog
The shipped Hunt inverse ran a plain Newton in XYZ and diverged for saturated colours — a purple round-tripped with error ~19. Root cause: the cone nonlinearity f_n(x)=40x^0.73/(x^0.73+2) has an infinite slope at x=0, so the XYZ->(J,C,h) Jacobian is singular near black; the Newton lurched to J<0 on iteration 0 and bailed.

Fix: invert in ADAPTED-CONE space (r_a,g_a,b_a) where black is the regular point (1,1,1), not the origin singularity, and use Levenberg-Marquardt (damping handles the still-ill-scaled chroma directions). XYZ is recovered analytically: the per-channel f_n inverse gives cone signals, HPE^-1 gives XYZ. Forward refactored to share a correlate(rgbA,Y) helper; forward math and the colour-science doctest are byte-identical.

Result over the full sRGB cube: machine-exact (<6e-12) for every colour with lightness J>=1 (was: purple err 19). Colours below J=1 (essentially black, <1% lightness) sit in a residual landscape whose true preimage is well-conditioned but has a microscopic basin (0/400 random seeds hit it) — they return a bounded near-black value; documented as a known limitation.

GL chunk mirrors the new inverse exactly (f64-eval matches scalar to 3e-15); declares tol 2e-2 for the near-black f32 GPU path.
… at -19)

The space output Nayatani's L*_P achromatic lightness, which is anchored at grey=50 and puts black at -19 and white at 113 — so its declared range started at -20 and a colour-picker plane wasted ~15% of the lightness axis on unusable sub-zero black. L*_P is a legitimate correlate but a poor lightness axis.

Switched the lightness channel to Nayatani's NORMALISED achromatic lightness L*_N = 100·B_r/B_rw (white -> 100.0, black -> ~0), the model's own [0,100]-style lightness, using the exact ideal-white-brightness formula (colour-science). L*_P = Q+50 is still computed internally as the chroma anchor ((L*_P/50)^0.7·S), so chroma is unchanged. The switch is an affine remap of Q, so the exact 4-regime inverse is untouched apart from recovering L*_P from L*_N first; round-trip stays 1e-13.

Re-pinned the bonafide value to the doctest's L*_N=50.0039154 (was L*_P=49.9998829 — both are colour-science XYZ_to_Nayatani95 outputs for the same input). Range 0-100; sRGB now spans -5.1..99.2 (the -5 near-black dip documented). GL chunk mirrors it; meta/types regenerated.
…for Hunt purple)

The NaN canary only checks finiteness, so the Hunt XYZ-Newton returning near-grey for a saturated purple (round-trip error ~19) passed silently. Add a round-trip accuracy test over hunt/nayatani95/llab/atd95/rlab/osaucs (incl. the exact purple XYZ that broke) and ryb (its Newton once returned white for saturated red). Guards the whole numerically-inverted class going forward.
…cted, glsl(oklch) both ways, glsl(oklch, p3) cross-space; excluded/no-path errors name their reason; wasm docs state hub-only rationale and real ~30 kB size; gamut panel viewport tuned
…spaces left

cubehelix was the last forward-only space: rgb.cubehelix threw because a 1-parameter colormap can't invert an arbitrary RGB. Now rgb.cubehelix returns the nearest point on the helix — a coarse scan + golden-section refine over the fraction. Colours on the helix recover their fraction exactly (cubehelix->rgb->cubehelix round-trips to 8e-16); off-helix colours project onto it, the way conversions into rgb clamp to the sRGB gamut. Custom forward params can't be recovered from one colour, so the inverse assumes defaults. GL chunk gains the matching inverse edge (scalar math per the chunk contract). Dropped cubehelix from the test's skip sets — every one of the 156 spaces is now bidirectional.
Two new per-space metadata axes the catalog lacked: @method (how a space is computed — transfer/matrix/opponent/cylindrical/luma-chroma/chromaticity/appearance/system/spectral) and @encoding (value domain — linear/gamma/log/pq/hlg/perceptual/chromaticity). Answers 'what kind of thing is this space' and the earlier 'does it have a transfer function' question, and makes the catalog filterable/groupable by method. All 156 classified and covered; generate-meta parses the tags; meta/types regenerated. README meta example updated.
New gamuts.js (like whitepoints.js) holds the primary chromaticities + white for 13 standard RGB gamuts (sRGB, Display-P3, DCI-P3, Rec.2020, Adobe RGB, ProPhoto, AP0, AP1, Apple RGB, NTSC, PAL, SMPTE-C, CIE RGB), values from CSS Color 4 / the defining specs. 25 RGB working spaces carry a one-word @gamut tag; generate-meta resolves it to meta.gamut + meta.primaries {r,g,b} + meta.white. The D65 gamuts' primaries are pinned by a test against each space's live R/G/B->XYZ chromaticities (all match); D50/D60 (ProPhoto/ACES) use cited native-white primaries since the D65 hub adapts them. README meta example updated.
…al now

The explorer's forward-only fallback (locally explored params when rgb->space throws) no longer triggers: cubehelix, its one example, inverts by nearest-fraction projection since 79c4c2a. Comments now describe the mechanism as the safety net it is, and the 'one-way' pill tooltip's direction is corrected (such a space converts TO rgb only — the old text said 'from RGB only', backwards). Behavior verified: valsFor(cubehelix) returns the projected fraction, sliders drive master, pill reads 'bidirectional'. Mechanism kept as a guard.
Every space is bidirectional now, so the explorer's forward-only pathway is deleted rather than kept as a dead safety net: the probeVals/localVals locally-explored-params fallback, the one-way facet + pill + tooltip, the fmt() 'forward-only' placeholder, and the fallback branches in the card/dossier slider handlers (adopt() cannot fail). The dossier code snippet now always shows both directions — for cubehelix it renders rgb.cubehelix(...) -> [fraction] live.

New 'channels' facet in the tag picker: chips 1/2/3/4 filter the catalog by component count (1 = gray/kelvin/wavelength/cubehelix, 4 = cmyk), OR within the facet, AND against the others, sorted numerically.

Verified in-browser (served no-store, fresh profile): zero console errors, channels chips filter and clear correctly, cubehelix dossier opens with working fraction slider driving the master color, hash carries #cubehelix@fraction. Rides along: in-flight hairline-divider and modal-backdrop styling refinements.
…ces get the panel

The dossier's transfer plot was gated on a proxy (archetype + tone channel at
index 0), which silently skipped munsell, coloroid, ral-design, ostwald, cmyk,
cubehelix, xyb, tsl, the YCbCr/luma family, xyy and every device cylinder. Now
every non-hue channel is sampled along the grey axis and the monotonic responder
over >5% of its range is the tone carrier: V for Munsell, K (falling) for CMYK,
W for Ostwald/HWB, luma for YCbCr, fraction for cubehelix. The classified tone
channel wins ties; hue drift on neutrals is noise and skipped. rg, kelvin and
wavelength stay panel-less — the chromaticity/CCT of grey is a constant.

The 'perceptual lightness' caption is now verified, not asserted: a perceptual
encoding AND a measured 18%-grey lift — Ostwald's W is linear on greys, so it
reads plain 'code value'. Endpoint labels snap float dust (K's 1e-14) to 0.

All 120 previously-paneled spaces keep byte-identical curves (differentially
verified old rule vs new across the full registry).
…aved buffers, all 156 spaces, pinned bit-identical to scalar; color-space/lite: the 27 wasm-covered spaces as a compact JS hub; wasm kernels as true multi-value exports
…emit 1D 4096, cross-channel 3D 17/33/65; header carries measured median/max deviation; differential suite + ffmpeg end-to-end check
…ows the live registry, not a deny-list — leakage test-pinned
…er, terminal-black code; mobile categories fold with animation, all names small; rail titles align with leads and clear the end divider; slider letters take ink from where they land; hue strip sheds its marker; masonry keeps catalog order across resizes
…nge claims corrected; migration guide verified against v2.3.2 source
…d site

- lut.js: any pair as a .cube LUT — auto-1D for pure transfers, self-verifying
  header (measured median/max deviation vs the direct conversion), shaper option
  (Resolve 1D+3D combined: shaped 33³ beats plain 65³ for log→display);
  format pinned to Adobe spec + ffmpeg end-to-end test
- icc.js: RGB working spaces as ICC v2 matrix+TRC display profiles, derived
  mechanically from the conversions (Bradford→D50 PCS, diagonal-sampled TRC,
  empirical matrix×transfer gate); pinned to Lindbloom/IEC, ColorSync-verified
- mcp.js: zero-dep MCP stdio server — convert/space/spaces/cube tools for agents
- data.json: the whole registry as one language-neutral artifact (metadata,
  graph edges, gamuts, whitepoints, CIE 1931 CMFs, 129 cited conformance
  triples single-sourced from test/refs.js); meta.js folded into it
- spaces/: the 156 space modules move out of the root; public import paths
  unchanged via the exports map (wildcard → spaces/, exact keys for root)
- source-only repo: types/dist/wasm-binary/data generated by `prepare`;
  website stages into _site (build-site.js) and deploys via pages.yml with
  the suite as deploy gate; docs/ holds sources only; llms/sitemap/robots +
  156 per-space reference pages generated
- site: /<name> deep links (404 bounce + ?s= normalization), export facet,
  dossier LUT block with live measured deviation, lut API tab
- planning docs → .work/
index.html, 404, js/, img/, tokens.css → web/ (staged into _site as before);
build-site/generate-landing/generate-reach/colorsync-test repointed. README's
docs/*.md links unchanged.
…ive purples

xyz.wavelength returned dsh's signed dominant wavelength raw, so purples came
back negative (xyy(0.46, 0.03) → -519) — outside the declared 380-700 range —
and the forward then clamped them to 380, rendering the opposite hue. Purples,
which have no dominant wavelength of their own, now snap to the nearer end of
the line of purples via spectralWavelength (exported from dsh.js beside the
signed dominantWavelength, which DSH keeps for its d channel, per CIE 15).
Spectral-arc hits below 380 nm clamp into range too.

The achromatic 0 sentinel now renders as the D65 neutral instead of 380 nm
violet — mirroring dsh's d=0 — in JS and the GL chunk both. Whole-rgb-cube
regression test pins the domain; the CIE 018:2019 forward pin at 550 nm and
dsh's colour-science pins are untouched.
…he input's baseline, chevrons spin with hold-repeat, copied rows paste back; precision from range span, printed = step, dossier unified; rhombus is a native picker, favicon-sized, value input fits any CSS color; rail titles push off like native sticky; hairlines start at the spaces; 3 cols from 1080; inputs field-size to content
At a non-physical coordinate (xyy y→0 with fixed Y explodes past the linear-light
ceiling) the GPU bar and plane sweeps void to the checkerboard, but the bar knob
and plane crosshair still painted the clamped master hex — a blazing magenta
marker on a voided-dark sweep. The markers now check the same lens-independent
physicality rule the shaders void by (±4× diffuse white SDR, ×500 scene/HDR) and
render hollow — position only, no color claim — the vocabulary the CIE plate's
marker already uses for beyond-MacAdam coordinates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant