diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..18634b86
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,18 @@
+{
+ "name": "TinyColor Go",
+ "image": "mcr.microsoft.com/devcontainers/go:1-1.26-bookworm",
+ "features": {
+ "ghcr.io/devcontainers/features/node:1": {
+ "version": "22"
+ },
+ "ghcr.io/devcontainers-extra/features/deno:1": {}
+ },
+ "postCreateCommand": "make build",
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "golang.Go"
+ ]
+ }
+ }
+}
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..b82e657e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,12 @@
+mod.js text eol=crlf
+test.js text eol=crlf
+tinycolor.js text eol=crlf
+bench/*.jsonl text eol=lf
+
+# Keep upstream oracle and published JavaScript copies in the repository for
+# differential testing, but exclude them from the implementation language chart.
+mod.js linguist-vendored
+test.js linguist-vendored
+tinycolor.js linguist-vendored
+npm/** linguist-vendored
+dist/** linguist-generated
diff --git a/.github/workflows/deno.yml b/.github/workflows/deno.yml
deleted file mode 100644
index 41adb05d..00000000
--- a/.github/workflows/deno.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-# This workflow uses actions that are not certified by GitHub.
-# They are provided by a third-party and are governed by
-# separate terms of service, privacy policy, and support
-# documentation.
-
-# This workflow will install Deno then run `deno lint` and `deno test`.
-# For more information see: https://github.com/denoland/setup-deno
-
-name: Deno
-
-on:
- push:
- branches: ["master"]
- pull_request:
- branches: ["master"]
-
-permissions:
- contents: read
-
-jobs:
- test:
- runs-on: ubuntu-latest
-
- steps:
- - name: Setup repo
- uses: actions/checkout@v3
-
- - name: Setup Deno
- # uses: denoland/setup-deno@v1
- uses: denoland/setup-deno@9db7f66e8e16b5699a514448ce994936c63f0d54
- with:
- deno-version: v1.x
-
- # Uncomment this step to verify the use of 'deno fmt' on each commit.
- # - name: Verify formatting
- # run: deno fmt --check
-
- # - name: Run linter
- # run: deno lint
-
- - name: Build & Run Tests
- run: deno task build
diff --git a/.github/workflows/port.yml b/.github/workflows/port.yml
new file mode 100644
index 00000000..5e7243ff
--- /dev/null
+++ b/.github/workflows/port.yml
@@ -0,0 +1,30 @@
+name: TinyColor Go port
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.26'
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+ - uses: denoland/setup-deno@v2
+ with:
+ deno-version: v2.x
+ - run: make verify
+ - run: deno test test.js
+ - run: make build
+ - uses: actions/upload-artifact@v4
+ with:
+ name: tinycolor-linux-amd64
+ path: bin/tinycolor
diff --git a/.gitignore b/.gitignore
index 05fdc6a2..24a07a3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ node_modules
.vscode
cov_profile
cov_profile.lcov
+.cache/
+bin/
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
new file mode 100644
index 00000000..6a74041e
--- /dev/null
+++ b/.planning/PROJECT.md
@@ -0,0 +1,25 @@
+# TinyColor Go Port
+
+## What This Is
+
+Create a fresh Go port of the local `bgrins/TinyColor` checkout with behavioral
+parity demonstrated through unchanged-source differential testing.
+
+## Core Value
+
+Observable behavior from the pinned TinyColor checkout is the specification;
+the Go port is accepted only when exact differential evidence agrees.
+
+## Requirements
+
+- The upstream JavaScript source is untouched.
+- Go handles every behavior covered by the defined compatibility corpus.
+- A reproducible command reports compatibility results and mismatch details.
+- A documented Go API and CLI are usable from a clean checkout.
+- CI, benchmarks, attribution, and known differences are present.
+
+## Constraints
+
+- Target source: local `mod.js` / `test.js`; MIT attribution retained.
+- Target language: Go 1.26+; standard library first.
+- Node and Deno are available as local oracle runtimes.
diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
new file mode 100644
index 00000000..b6b5144b
--- /dev/null
+++ b/.planning/REQUIREMENTS.md
@@ -0,0 +1,22 @@
+# Requirements
+
+## Functional
+
+- **PAR-01:** Parse all string formats and object forms accepted by local TinyColor.
+- **PAR-02:** Preserve validation, format, original-input, clamping, wrapping, and alpha behavior.
+- **FMT-01:** Reproduce color conversion and every documented output representation.
+- **OPS-01:** Reproduce mutation, utilities, readability, and palette operations.
+- **EQV-01:** Compare Go and local JavaScript behavior through a reusable differential harness.
+- **DEL-01:** Provide Go API, CLI, CI, benchmarks, documentation, and attribution.
+
+## Quality
+
+- **QLT-01:** Do not edit the JavaScript source or source tests to achieve parity.
+- **QLT-02:** Record every mismatch with complete reproducer and owner.
+- **QLT-03:** Keep checks reproducible from a clean checkout.
+
+## Out of scope
+
+New CSS color syntaxes, a web UI, package publication, and compatibility with a
+different TinyColor version.
+
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
new file mode 100644
index 00000000..dffc3cd6
--- /dev/null
+++ b/.planning/ROADMAP.md
@@ -0,0 +1,96 @@
+# Roadmap: TinyColor.js to Go
+
+## Overview
+
+Build a separate, evidence-backed Go port while preserving this checkout as an
+immutable JavaScript behavioral oracle. Work moves from an executable oracle,
+through parsing and observable output, to operations and delivery proof.
+
+## Phases
+
+- [x] **Phase 1: Foundation and Oracle** - Establish the Go module and a reproducible Node-to-Go differential baseline.
+- [x] **Phase 2: Parsing and Color State** - Match accepted inputs and normalized TinyColor state.
+- [x] **Phase 3: Conversion and Representation** - Match conversion, output, and analysis behavior.
+- [x] **Phase 4: Operations and Palettes** - Match manipulation, readability, and color-combination behavior.
+- [x] **Phase 5: Delivery Evidence** - Ship the CLI, CI, benchmarks, and submission documentation.
+
+## Phase Details
+
+### Phase 1: Foundation and Oracle
+**Goal**: A contributor can run the same JSONL request against local `mod.js` and the Go runner, obtain a structured comparison report, and see the upstream files remain untouched.
+**Depends on**: Nothing (first phase)
+**Requirements**: [EQV-01, QLT-01, QLT-03]
+**Success Criteria**:
+ 1. `src/` builds as an independent module without changing JavaScript source files.
+ 2. Node and Go runners accept and emit the documented JSONL request/response protocol.
+ 3. A differential command reports case count, mismatch count, and complete mismatch records.
+**Plans**: 1 plan (complete 2026-08-01)
+
+Plans:
+- [x] 01-01: Create the Go module, JSONL runners, smoke corpus, and baseline report.
+
+### Phase 2: Parsing and Color State
+**Goal**: Go accepts the same supported TinyColor inputs and retains the same validity, format, alpha, and normalized color state.
+**Depends on**: Phase 1
+**Requirements**: [PAR-01, PAR-02, QLT-02]
+**Success Criteria**:
+ 1. Hex, RGB(A), HSL(A), HSV(A), CSS names, transparent, and object inputs produce matching state.
+ 2. Permissive syntax, boundary values, invalid input, and alpha normalization match the oracle corpus.
+ 3. Every discovered parser mismatch has a deterministic regression record.
+**Plans**: 2 plans (complete 2026-08-01)
+
+Plans:
+- [x] 02-01: Replace Phase 1 fixed inputs with normalized color/model and HEX/RGB/name parsing.
+- [x] 02-02: Add HSL/HSV/object parsing and parser differential corpus.
+
+### Phase 3: Conversion and Representation
+**Goal**: Go exposes TinyColor-equivalent conversion, string formatting, format fallback, and color analysis.
+**Depends on**: Phase 2
+**Requirements**: [FMT-01, QLT-02]
+**Success Criteria**:
+ 1. RGB, percentage RGB, HSL, HSV, hex, hex8, name, filter, and generic string outputs match exactly.
+ 2. Brightness, luminance, equality, cloning, and random behavior have documented parity cases.
+ 3. No undocumented floating-point tolerance masks an observable mismatch.
+**Plans**: 2 plans (complete 2026-08-01)
+
+Plans:
+- [x] 03-01-PLAN.md — Implement B's source-equivalent conversion, representation, and analysis facade.
+- [x] 03-02-PLAN.md — Add C's JSONL dispatch, differential corpus, and compatibility evidence.
+
+### Phase 4: Operations and Palettes
+**Goal**: Go matches TinyColor's stateful modifiers, static utilities, readability checks, and palette ordering.
+**Depends on**: Phase 3
+**Requirements**: [OPS-01, QLT-02]
+**Success Criteria**:
+ 1. Modifiers preserve default, zero, clamping, wrapping, alpha, and mutation behavior.
+ 2. Readability and most-readable operations match WCAG option defaults.
+ 3. Every palette operation returns source-equivalent colors in source order.
+**Plans**: 6 plans (complete 2026-08-01)
+
+Plans:
+- [x] 04-01 through 04-06: Typed operations, WCAG readability, palettes, compatibility dispatch, and differential evidence.
+
+### Phase 5: Delivery Evidence
+**Goal**: A judge can clone the project, verify parity evidence, use a Go CLI, and understand the migration and its limitations.
+**Depends on**: Phase 4
+**Requirements**: [DEL-01, QLT-03]
+**Success Criteria**:
+ 1. CI runs format, vet, Go tests, and the differential smoke report.
+ 2. The CLI supports parse, convert, lighten, palette, and contrast, including JSON output.
+ 3. Benchmarks, documentation, license attribution, team work, and known differences are complete.
+**Plans**: 3 plans (complete 2026-08-01)
+
+Plans:
+- [x] 05-01: Add the human CLI, reproducible build/verification gate, Docker artifact, and CI.
+- [x] 05-02: Publish differential fuzz and shared-workload benchmark evidence.
+- [x] 05-03: Publish judge-facing documentation and verify local/public delivery evidence.
+
+## Progress
+
+| Phase | Plans Complete | Status | Completed |
+|---|---|---|---|
+| 1. Foundation and Oracle | 1/1 | Complete | 2026-08-01 |
+| 2. Parsing and Color State | 2/2 | Complete | 2026-08-01 |
+| 3. Conversion and Representation | 2/2 | Complete | 2026-08-01 |
+| 4. Operations and Palettes | 6/6 | Complete | 2026-08-01 |
+| 5. Delivery Evidence | 3/3 | Complete | 2026-08-01 |
diff --git a/.planning/STATE.md b/.planning/STATE.md
new file mode 100644
index 00000000..f05d5c2d
--- /dev/null
+++ b/.planning/STATE.md
@@ -0,0 +1,44 @@
+# Project State
+
+**Updated:** 2026-08-01
+
+## Current Position
+
+- Phase 5 of 5: Delivery Evidence — complete.
+- All three Phase 5 plans and all milestone implementation plans are complete.
+- Remaining human submission action: record, upload, and link the five-minute demo video.
+
+## Verified Evidence
+
+- Public repository: https://github.com/rajeet-04/TinyColor
+- Unauthenticated `ls-remote` returned branch `rajeet` at
+ `1c218b669d192e37b7019b08395cf348410dde79`.
+- GitHub Actions run 30686980719 passed `make verify`, Deno, build, and artifact
+ upload for that exact commit:
+ https://github.com/rajeet-04/TinyColor/actions/runs/30686980719
+- Immutable JavaScript oracle hashes: 3/3 verified.
+- Exact corpora: smoke 9/9, HEX/RGB 26/26, parser 23/23, conversion 35/35,
+ operations 71/71; 164/164 total and zero mismatches.
+- Go tests/vet and 15 Node tests pass.
+- Immutable Deno source suite: 45 passed, 0 failed, 1 ignored.
+- Byte-identical original suite against native Go: 45 passed, 0 failed, 1 ignored.
+- Differential fuzz evidence: 60.012 seconds, seed 20260801, 1,091,630 cases,
+ zero divergences.
+- Shared benchmark: 20 cold starts and 1,000 persistent requests per runtime,
+ with startup p99, latency p99, throughput, and peak RSS recorded.
+- Go `unsafe` source occurrences: 0.
+
+## Recent Decisions
+
+- Use the pinned V8-derived 8-bit luminance transfer table for exact `Math.pow` parity.
+- Apply TinyColor's sub-one RGB rounding once at the `FromCompat` boundary.
+- Keep fuzz comparisons exact and normalize benchmark workload newlines before hashing.
+- Never claim external evidence until public access and exact-commit CI are observed.
+- Use a test-only synchronous facade to run unchanged source assertions against
+ the native Go binary without duplicating TinyColor algorithms.
+
+## Session Continuity
+
+Last session: 2026-08-01
+Stopped at: Phase 5 complete; demo video remains a human submission action.
+Resume file: none
diff --git a/.planning/config.json b/.planning/config.json
new file mode 100644
index 00000000..a90bd9fb
--- /dev/null
+++ b/.planning/config.json
@@ -0,0 +1,15 @@
+{
+ "project": "tinycolor-go-port",
+ "source_oracle": "mod.js",
+ "source_tests": "test.js",
+ "target_language": "go",
+ "commit_docs": true,
+ "research_enabled": true,
+ "plan_checker_enabled": true,
+ "nyquist_validation_enabled": true,
+ "workflow": {
+ "nyquist_validation": true,
+ "ui_phase": false,
+ "ui_safety_gate": false
+ }
+}
diff --git a/.planning/debug/fuzz-readability-precision.md b/.planning/debug/fuzz-readability-precision.md
new file mode 100644
index 00000000..3383d91c
--- /dev/null
+++ b/.planning/debug/fuzz-readability-precision.md
@@ -0,0 +1,61 @@
+---
+status: resolved
+trigger: "Investigate and fix next deterministic Phase 5 fuzz parity mismatch: readability for {r:127,g:64,b:15,a:0} against '#80007f' returns 1.188086751976723 in JavaScript and 1.1880867519767233 in Go."
+created: 2026-08-01T00:00:00+05:30
+updated: 2026-08-01T06:00:00+05:30
+---
+
+## Current Focus
+
+hypothesis: resolved: exact V8-derived results are required for the finite 8-bit luminance domain
+test: exact readability regression, 71-case operations corpus, and broad seeded fuzz harness
+expecting: exact parity with no tolerance or output rounding
+next_action: record and validate the required 60-second seed-20260801 fuzz log
+
+## Symptoms
+
+expected: JavaScript and Go exact JSON response match for readability; JavaScript returns 1.188086751976723.
+actual: Go returns 1.1880867519767233; broad seed-1 one-second fuzz run has 388 divergences and fuzz-32 is first.
+errors: exact floating-point JSON mismatch for operation readability
+reproduction: exact request is in .superpowers/sdd/05-02-PLAN/task-1-report.md; run through both adapters and typed Readability with input {r:127,g:64,b:15,a:0}, args.other '#80007f'.
+started: uncovered after the fuzz saturation root-cause fix.
+
+## Eliminated
+
+## Evidence
+
+- timestamp: 2026-08-01T00:01:00+05:30
+ checked: repository status and protected fuzz files
+ found: fuzz/README.md is modified and fuzz/harness.mjs plus fuzz/harness.test.mjs are untracked pre-existing work
+ implication: preserve those files without editing, staging, reverting, or narrowing them
+- timestamp: 2026-08-01T00:01:00+05:30
+ checked: debug knowledge base
+ found: only prior saturation rounding case exists and its keywords/root cause do not match readability precision
+ implication: no known-pattern candidate applies
+- timestamp: 2026-08-01T00:01:00+05:30
+ checked: complete JavaScript getLuminance/readability and Go Color.Luminance/Readability implementations plus callers
+ found: both round normalized RGB before luminance and use the same WCAG constants; Go caches the two luminances once while JavaScript recomputes them for Max and Min; IsReadable and MostReadable both call shared Readability
+ implication: parsing and exact arithmetic intermediates must be measured before changing the shared method
+- timestamp: 2026-08-01T00:02:00+05:30
+ checked: exact requests through both adapters
+ found: normalized RGB inspections are identical; first luminance is identical at 0.08213307169664993, but #80007f luminance is 0.06121500300950977 in JavaScript and 0.061215003009509765 in Go
+ implication: parsing, alpha, and final ratio ordering are ruled out; the first divergence is inside shared Color.Luminance for integer RGB 128,0,127
+- timestamp: 2026-08-01T00:03:00+05:30
+ checked: new exact typed regression and exact operations JSONL row before production changes
+ found: typed test fails with 1.1880867519767233 and corpus has exactly one mismatch at readability-object-precision
+ implication: RED is confirmed and independently covers both public Go API and adapter parity
+- timestamp: 2026-08-01T00:04:00+05:30
+ checked: channel-level gamma expansion raw float bits for #80007f
+ found: channel 128 has identical normalized/base input but V8 Math.pow yields bits 3fcba1511e3e632d while Go math.Pow yields 3fcba1511e3e632c; channel 127 matches at 3fcb2a60a1263b0a
+ implication: the root cause is Go math.Pow not reproducing the JavaScript oracle's power rounding, not weighted luminance summation
+- timestamp: 2026-08-01T00:04:00+05:30
+ checked: all nonlinear integer channels 11 through 255
+ found: 93 of 245 channel powers differ, with signed deltas in both directions and two cases differing by two ULPs
+ implication: unconditional Nextafter or decimal/output rounding would be incorrect; the fix must reproduce the bounded transfer function or V8 algorithm
+
+## Resolution
+
+root_cause: V8 Math.pow and Go math.Pow produce different final float64 bits for 93 of TinyColor's 245 nonlinear 8-bit channel values.
+fix: Color.Luminance now uses a finite V8-derived 256-entry transfer table. The follow-on palette divergences were fixed at their shared constructor boundary by applying TinyColor's sub-one channel rounding and bound01 normalization.
+verification: The exact regression passed; all 71 operations cases matched; the full local verify equivalent passed; seed 1 ran for 1.008 seconds across 14,778 cases with zero divergences.
+files_changed: [src/tinycolor/luminance.go, src/tinycolor/color.go, src/tinycolor/operations_test.go, src/cmd/pow-debug/main.go]
diff --git a/.planning/debug/knowledge-base.md b/.planning/debug/knowledge-base.md
new file mode 100644
index 00000000..b591de51
--- /dev/null
+++ b/.planning/debug/knowledge-base.md
@@ -0,0 +1,21 @@
+# GSD Debug Knowledge Base
+
+Resolved debug sessions. Used by `gsd-debugger` to surface known-pattern hypotheses at the start of new investigations.
+
+---
+
+## fuzz-saturate-rounding — Saturate negative rounding differed at an RGB half boundary
+- **Date:** 2026-08-01
+- **Error patterns:** saturate, rounding, #400140, #202020, #212121, RGB 32, RGB 33, fuzz divergence
+- **Root cause:** setHSL called a direct hslToRGB conversion, bypassing TinyColor's object-input percentage conversion and Bound01 truncation before RGB output rounding.
+- **Fix:** Route setHSL through the existing hslColor/parser conversion and remove the duplicate direct converter.
+- **Files changed:** src/tinycolor/color.go, src/tinycolor/operations_test.go, compat/cases/operations.jsonl
+---
+
+## full-range-differential-inconsistencies — Broad adapter parity diverged across formatting, parsing, equality, and brighten
+- **Date:** 2026-08-01
+- **Error patterns:** 6019 mismatches, string format hsl, hsl 1%, white, percentage RGB, equals empty input, brighten
+- **Root cause:** Five shared-path gaps: string format was discarded; explicit percent strings were re-promoted as ratios; percentage RGB used pre-rounded channels; Equals omitted JavaScript falsy guards; Brighten skipped the oracle's rounded RGB snapshot.
+- **Fix:** Forward string format; preserve explicit percentages; calculate percentage RGB from internal floats; apply JS truthiness before equality parsing; start Brighten from ToRGB channels.
+- **Files changed:** src/cmd/tinycolor-compat/main.go, src/cmd/tinycolor-compat/main_test.go, src/internal/parser/parser.go, src/tinycolor/color.go, src/tinycolor/color_test.go, src/tinycolor/operations_test.go
+---
diff --git a/.planning/debug/resolved/ci-deno2-test-discovery.md b/.planning/debug/resolved/ci-deno2-test-discovery.md
new file mode 100644
index 00000000..0f6dcc7e
--- /dev/null
+++ b/.planning/debug/resolved/ci-deno2-test-discovery.md
@@ -0,0 +1,47 @@
+---
+status: resolved
+trigger: "Deno 2 removed the files test config, so deno task test discovers generated npm and Node port tests and fails on undeclared @deno/shim-deno-test imports."
+created: 2026-08-01T05:20:00+05:30
+updated: 2026-08-01T05:23:00+05:30
+---
+
+## Current Focus
+
+hypothesis: Confirmed: the workflow invoked broad task discovery, while the source contract is the root test.js suite only.
+test: Completed workflow text audit and git diff --check.
+expecting: All checks pass.
+next_action: Archive the resolved record and commit only the workflow and this debug document.
+
+## Symptoms
+
+expected: CI runs only the immutable original root suite test.js.
+actual: Deno 2 discovers npm/esm/test.js, npm/cjs/test.js, tests/original/verify.test.mjs, and tests/port/adapter.test.mjs.
+errors: Deno warns that files test config was removed; type-check fails because @deno/shim-deno-test is not a declared Deno dependency in generated npm tests.
+reproduction: GitHub Actions source-suite step runs deno task test under Deno 2.
+started: Appeared in CI after the oracle hash checkout fix allowed verification to reach the Deno source-suite step.
+
+## Eliminated
+
+## Evidence
+
+- timestamp: 2026-08-01T05:20:00+05:30
+ checked: GitHub Actions failure report.
+ found: Deno 2 ignores the removed files test config and broad task discovery reaches generated npm and Node-specific tests.
+ implication: Test discovery scope, not missing npm-test dependencies, is the failure boundary.
+
+- timestamp: 2026-08-01T05:21:00+05:30
+ checked: Complete .github/workflows/port.yml.
+ found: The source-suite step runs deno task test without a path, exactly matching the broad discovery trigger.
+ implication: Scoping this existing CI invocation to test.js fixes the cause without modifying configs, dependencies, or generated tests.
+
+- timestamp: 2026-08-01T05:23:00+05:30
+ checked: Exact workflow command audit and git diff --check.
+ found: deno test test.js is present; deno task test and --no-check are absent; git diff --check exited 0 with only line-ending conversion warnings.
+ implication: The workflow is narrowly scoped to the immutable source suite and the change has no whitespace errors.
+
+## Resolution
+
+root_cause: Deno 2 removed the files test configuration, but CI still invoked deno task test without a path. That broad invocation recursively discovered generated npm and Node port tests outside the intended immutable root suite, then type-checking failed on their environment-specific imports.
+fix: Change only the source-suite workflow command to deno test test.js.
+verification: Text audit confirmed deno test test.js is present and deno task test/--no-check are absent; git diff --check exited 0.
+files_changed: [.github/workflows/port.yml, .planning/debug/resolved/ci-deno2-test-discovery.md]
diff --git a/.planning/debug/resolved/ci-oracle-hash-line-endings.md b/.planning/debug/resolved/ci-oracle-hash-line-endings.md
new file mode 100644
index 00000000..7af8f263
--- /dev/null
+++ b/.planning/debug/resolved/ci-oracle-hash-line-endings.md
@@ -0,0 +1,57 @@
+---
+status: resolved
+trigger: "Fix GitHub Actions make verify where tests/original/verify.mjs reports mismatch for mod.js, test.js, tinycolor.js."
+created: 2026-08-01T05:09:56.9355940+05:30
+updated: 2026-08-01T05:16:00+05:30
+---
+
+## Current Focus
+
+hypothesis: Confirmed: the oracle manifest describes CRLF working-tree bytes, but Git had no checkout eol attribute for the three LF blobs.
+test: Completed focused regression, production verifier, and whitespace validation.
+expecting: All checks pass.
+next_action: Archive this resolved record and commit only the assigned files.
+
+## Symptoms
+
+expected: Ubuntu checkout bytes match kickoff manifest and verifier prints verified: 3.
+actual: All three oracle text files mismatch in CI; local Windows passes.
+errors: mismatch: mod.js / test.js / tinycolor.js; Make hashes target exits 1.
+reproduction: Local SHA-256 hashes equal manifest, while raw Git blob SHA-256 values are different LF bytes. Local core.autocrlf=true; no .gitattributes exists; oracle git diff is empty.
+started: Introduced when CI started running the existing Windows-derived kickoff manifest.
+
+## Eliminated
+
+## Evidence
+
+- timestamp: 2026-08-01T05:09:56.9355940+05:30
+ checked: Prefilled symptom evidence.
+ found: Windows working-tree hashes match the manifest, but raw Git blob hashes do not; the repository lacks .gitattributes and local core.autocrlf=true.
+ implication: Checkout line-ending conversion, rather than oracle content drift, is the leading mechanism.
+
+- timestamp: 2026-08-01T05:11:00+05:30
+ checked: Assigned verifier files and root attributes file.
+ found: tests/original/verify.test.mjs already exists; .gitattributes and the debug knowledge base do not. verify.mjs hashes raw file bytes and does no line-ending normalization.
+ implication: The repository checkout must supply the manifest's CRLF bytes; verifier behavior should remain unchanged.
+
+- timestamp: 2026-08-01T05:13:00+05:30
+ checked: New focused Node test before adding attributes.
+ found: The test failed exactly as predicted; git check-attr returned eol: unspecified for mod.js, test.js, and tinycolor.js while the other three verifier tests passed.
+ implication: The missing checkout policy is directly reproduced and the hypothesis is confirmed.
+
+- timestamp: 2026-08-01T05:15:00+05:30
+ checked: Focused Node suite after adding .gitattributes.
+ found: All four tests passed, including the check that all three oracle paths resolve to eol: crlf.
+ implication: The repository now declares the required checkout bytes independently of platform defaults.
+
+- timestamp: 2026-08-01T05:16:00+05:30
+ checked: Production verifier and git diff --check.
+ found: verify.mjs printed verified: 3; git diff --check exited 0 with only line-ending conversion warnings, including one for a concurrent fuzz file that was not touched.
+ implication: The original hash verification succeeds and the assigned diff contains no whitespace errors.
+
+## Resolution
+
+root_cause: The kickoff manifest hashes CRLF working-tree bytes, but the repository stores LF blobs and had no .gitattributes policy. Windows core.autocrlf=true masked this locally; Ubuntu checked out LF bytes and all three raw-byte hashes mismatched.
+fix: Pin mod.js, test.js, and tinycolor.js to text eol=crlf at the repository root; retain a focused git check-attr regression test.
+verification: RED confirmed eol unspecified; GREEN confirmed 4/4 focused tests pass; production verifier printed verified: 3; git diff --check exited 0.
+files_changed: [.gitattributes, tests/original/verify.test.mjs, .planning/debug/resolved/ci-oracle-hash-line-endings.md]
diff --git a/.planning/debug/resolved/full-range-differential-inconsistencies.md b/.planning/debug/resolved/full-range-differential-inconsistencies.md
new file mode 100644
index 00000000..51434799
--- /dev/null
+++ b/.planning/debug/resolved/full-range-differential-inconsistencies.md
@@ -0,0 +1,144 @@
+---
+status: resolved
+trigger: "A deterministic 100,000-case Node-vs-Go stress run found 6,019 exact mismatches; fix known string-format and HSL percentage parser repros test-first, then classify remaining mismatches one root cause at a time."
+created: 2026-08-01T11:57:15.5165643+05:30
+updated: 2026-08-01T12:17:02.4360622+05:30
+---
+
+## Current Focus
+
+hypothesis: Confirmed resolved.
+test: Completed focused RED/GREEN regressions, full repository checks, and repeated 100,000-case differential verification.
+expecting: Zero mismatches and no test regressions.
+next_action: Archive the resolved session and return the root-cause handoff without committing.
+
+## Symptoms
+
+expected: Exact JSON behavioral parity between compat/js-runner.mjs and the Go adapter for supported TinyColor public operations, including valid percentage strings and explicit toString formats.
+actual: 6,019 mismatches in 100,000 seeded broad cases; known exact repros are `string` with input `red` and `args.format=hsl` returning `red` in Go versus `hsl(0, 100%, 50%)` in JS, and `inspect` with `hsl(115, 1%, 1%)` returning white in Go versus `rgb(3, 3, 3)` in JS. Initial distribution: string 5,341; inspect 35; fromRatio 43; output 41; analysis 26; equals 38; clone 45; modify 108; mix 90; readability 73; isReadable 60; mostReadable 82; palette 37; randomInvariant 0.
+errors: Exact output mismatch, no crash. Known adapter branch at src/cmd/tinycolor-compat/main.go ignores args.format. Known parser ratio helper at src/internal/parser/parser.go may convert string `1%` into 100% because ParseFloat(`1%`) is 1.
+reproduction: Build bin/tinycolor.exe, compare JSONL through `node compat/js-runner.mjs` and the Go binary. Minimal requests: `{"id":"string-format","operation":"string","input":"red","args":{"format":"hsl"}}` and `{"id":"hsl-one-percent","operation":"inspect","input":"hsl(115, 1%, 1%)"}`. Broad runner exists at C:\tmp\tinycolor-stress.mjs and was run from repo root.
+started: Found today after narrower Phase 5 fuzz passed; likely pre-existing broad-domain gaps.
+
+## Eliminated
+
+- hypothesis: Correcting percentage RGB output would eliminate all remaining string/output/modify mismatches.
+ evidence: The next 100,000-case run eliminated string and output mismatches but retained 12 modify-only cases, all represented by brighten examples; modify has another independent root cause.
+ timestamp: 2026-08-01T12:07:40.3112231+05:30
+- hypothesis: TinyColor equality rejects all invalid parsed colors before comparison.
+ evidence: The immutable oracle only checks JavaScript top-level falsiness (`!color1 || !color2`) and then compares normalized RGB strings; truthy invalid colors may still compare equal as black.
+ timestamp: 2026-08-01T12:08:56.2349809+05:30
+
+## Evidence
+
+- timestamp: 2026-08-01T11:58:08.3727772+05:30
+ checked: `.planning/debug/knowledge-base.md` for overlap with mismatch, format, percentage, parser, and HSL symptoms.
+ found: The only entry concerns saturate rounding and has fewer than two overlapping symptom keywords.
+ implication: There is no qualifying known-pattern candidate; investigate the supplied repros directly.
+- timestamp: 2026-08-01T11:58:08.3727772+05:30
+ checked: Repository status before investigation.
+ found: Only the newly created debug session is untracked; no pre-existing user code edits are present.
+ implication: Subsequent source/test diffs can be attributed to this investigation while preserving the debug file separately.
+- timestamp: 2026-08-01T11:58:39.5190467+05:30
+ checked: Complete `src/cmd/tinycolor-compat/main.go` and its tests.
+ found: `handle` parses both inspect and string identically, but the string fast path calls `color.String()` and never reads `args["format"]`; the output/toString path correctly calls `color.ToString(format)`.
+ implication: The string-format mismatch is caused by a local adapter argument-loss defect, not TinyColor formatting itself.
+- timestamp: 2026-08-01T11:58:39.5190467+05:30
+ checked: Complete `src/internal/parser/parser.go`, `parser_test.go`, and `hsl.go`.
+ found: Shared `ratio` calls `ParseFloat` and promotes every numeric value `<= 1` to percentage text, including the literal string `"1%"`; `hslModel` and `hsvModel` both depend on it.
+ implication: The parser cannot distinguish normalized numeric ratios from already-explicit percentage strings and can turn 1% into 100% across HSL and HSV paths.
+- timestamp: 2026-08-01T11:59:25.1113451+05:30
+ checked: All `ratio` and `ToString` references plus complete `object.go`, `bounds.go`, `color.go`, `compat/js-runner.mjs`, and the broad stress generator.
+ found: `ratio` has exactly four production callers (HSL saturation/lightness and HSV saturation/value). `Bound01` already handles explicit `%` correctly. The Go adapter `string` branch and JS oracle both receive the same args, but only JS forwards `format`; the public Go `ToString(format)` behavior already exists and is tested.
+ implication: The smallest first fix is one adapter argument forward; the parser fix should preserve `ratio` for raw numbers while skipping its numeric-promotion step for percentage strings.
+- timestamp: 2026-08-01T12:00:20.6632996+05:30
+ checked: New `JSONL string forwards explicit format` regression before production changes.
+ found: RED as predicted: actual result was `red`; expected `hsl(0, 100%, 50%)`.
+ implication: The regression reproduces the supplied public mismatch and isolates the adapter string branch.
+- timestamp: 2026-08-01T12:01:04.3955140+05:30
+ checked: Focused adapter regression after forwarding `args.format` to `Color.ToString`.
+ found: GREEN: `go test ./cmd/tinycolor-compat -run TestRunJSONLAndUsageErrors -count=1` passed.
+ implication: The one-path fix corrects explicit string formats while preserving the existing JSONL adapter cases.
+- timestamp: 2026-08-01T12:02:47.4046947+05:30
+ checked: JS oracle and new exact JSONL regression for `hsl(115, 1%, 1%)` before parser changes.
+ found: Oracle returns RGB 3,3,3 and value `hsl(115, 1%, 1%)`; RED Go result is RGB 255,255,255 and `hsl(0, 0%, 100%)`.
+ implication: The regression exactly reproduces the supplied full-stack mismatch.
+- timestamp: 2026-08-01T12:02:47.4046947+05:30
+ checked: Immutable oracle `hslToRgb`, `hsvToRgb`, `bound01`, and `convertToPercentage` functions.
+ found: String HSL/HSV percentages go directly to `bound01`; object inputs use `convertToPercentage`, whose JavaScript `<=` coercion does not convert strings containing `%`. Go's shared `ratio` combines both paths but converts percentage strings incorrectly.
+ implication: Guarding the promotion with `!color.IsPercentage(value)` reproduces the oracle distinction without splitting the parser paths.
+- timestamp: 2026-08-01T12:03:31.1535483+05:30
+ checked: Exact HSL one-percent regression after guarding explicit percentages.
+ found: GREEN: `go test ./cmd/tinycolor-compat -run TestRunJSONLAndUsageErrors/JSONL_inspect_preserves_one-percent_HSL_channels -count=1` passed.
+ implication: The smallest parser change fixes the supplied public repro.
+- timestamp: 2026-08-01T12:04:19.5752472+05:30
+ checked: Unchanged deterministic 100,000-case broad stress after the two fixes.
+ found: Mismatches fell from 6,019 to 121. New distribution is string 1, output 6, equals 35, modify 79, and zero in every other operation. String/output/modify examples have identical rounded RGB but percentage strings differ by one point; equals examples compare invalid empty input against a different input that normalizes to black.
+ implication: The residuals collapse cleanly into two candidate root causes rather than requiring an architectural change.
+- timestamp: 2026-08-01T12:05:16.6121994+05:30
+ checked: All Go percentage-output callers, complete `color_test.go`, and immutable oracle `toPercentageRgb`/`toPercentageRgbString`.
+ found: Go `ToPercentageRGB` first calls `ToRGB`, rounding each internal channel to an integer; JavaScript applies `bound01` and percentage rounding directly to each internal float channel. All Go percentage strings route through `ToPercentageRGB`.
+ implication: A shared two-line correction in `ToPercentageRGB` should eliminate percentage string differences across direct output, string formatting, and modified-color inspections.
+- timestamp: 2026-08-01T12:05:58.0357035+05:30
+ checked: New direct percentage-output regression before production changes.
+ found: RED as predicted: Go returned `rgb(44%, 0%, 100%)`; expected `rgb(43%, 0%, 100%)`.
+ implication: Integer RGB pre-rounding is directly observable and sufficient to cause the residual output cluster.
+- timestamp: 2026-08-01T12:06:37.9971179+05:30
+ checked: Focused percentage-output regression after computing from internal channels.
+ found: GREEN: `go test ./tinycolor -run TestPercentageRGBUsesUnroundedChannels -count=1` passed.
+ implication: The shared output method now matches the oracle example; broad verification can test all three affected operation families.
+- timestamp: 2026-08-01T12:07:40.3112231+05:30
+ checked: Deterministic 100,000-case stress after the percentage-output fix.
+ found: Mismatches fell from 121 to 47: equals 35 and modify 12; every other operation is exact. All string/output mismatches and 67 of 79 modify mismatches disappeared.
+ implication: Percentage output is confirmed across its consumers; invalid equality and brighten behavior remain separate clusters.
+- timestamp: 2026-08-01T12:08:56.2349809+05:30
+ checked: Every `Equals` caller and complete immutable oracle equality function.
+ found: JS returns false for any falsy top-level argument, including empty string; Go checks only `nil` and otherwise compares `ToRGBString`, so empty string becomes invalid black and can equal a clamped black input.
+ implication: Equality needs JSON-compatible JS truthiness at its shared public entry point, not validity checks in callers.
+- timestamp: 2026-08-01T12:10:06.1947734+05:30
+ checked: New exact equality stress regression before production changes.
+ found: RED: Go reported the empty input equal to the clamped-black HSL object.
+ implication: The missing falsy guard directly reproduces the full equality cluster mechanism.
+- timestamp: 2026-08-01T12:11:07.7954806+05:30
+ checked: Focused equality regression after adding JSON-compatible falsy handling.
+ found: GREEN: `go test ./tinycolor -run TestEqualsRejectsFalsyInput -count=1` passed.
+ implication: Empty string is now rejected before invalid-as-black comparison at the shared equality entry point.
+- timestamp: 2026-08-01T12:11:45.4137675+05:30
+ checked: Deterministic 100,000-case stress after the equality fix.
+ found: Exactly 12 mismatches remain, all in modify and both captured examples use brighten; every other operation is exact, including equals.
+ implication: Equality is broadly confirmed. Brighten is the only remaining behavior path.
+- timestamp: 2026-08-01T12:12:38.0636370+05:30
+ checked: Complete oracle constructor, `_applyModification`, and `brighten`, plus every Go Brighten caller and complete modifier tests.
+ found: Oracle brighten calls `tinycolor(color).toRgb()` before applying its rounded delta, forcing every starting channel to an integer. Go applies the same delta rounding directly to `model` floats. `_applyModification` then copies the oracle result channels back while preserving original format metadata.
+ implication: Starting Brighten from Go's existing `ToRGB()` snapshot matches the oracle mechanism and explains both the percentage and achromatic-hue examples.
+- timestamp: 2026-08-01T12:13:14.6239668+05:30
+ checked: New exact brighten stress regression before production changes.
+ found: RED as predicted: Go returned `rgb(68%, 91%, 13%)`; expected `rgb(67%, 91%, 13%)`.
+ implication: Retaining the pre-brighten fractional channel reproduces the final residual mechanism.
+- timestamp: 2026-08-01T12:13:55.0190481+05:30
+ checked: Focused brighten regression after applying the delta to `ToRGB()` channels.
+ found: GREEN: `go test ./tinycolor -run TestBrightenUsesRoundedRGBSnapshot -count=1` passed.
+ implication: The final identified root cause is corrected; broad verification can now test completeness.
+- timestamp: 2026-08-01T12:15:21.2484424+05:30
+ checked: Final deterministic 100,000-case stress after all five fixes.
+ found: Zero mismatches in every operation category.
+ implication: The original 6,019-case parity failure is eliminated across the unchanged broad input domain.
+- timestamp: 2026-08-01T12:15:21.2484424+05:30
+ checked: `go test ./... -count=1`, `go vet ./...`, `node --test tests/port/adapter.test.mjs`, `gofmt`, and `git diff --check`.
+ found: All checks passed; diff check emitted only existing Windows LF-to-CRLF conversion warnings.
+ implication: No test, vet, adapter, formatting, or whitespace regression was detected.
+- timestamp: 2026-08-01T12:16:16.8806534+05:30
+ checked: Full verification after simplifying equality truthiness through existing `color.ParseFloat`.
+ found: `go test ./... -count=1`, `go vet ./...`, adapter tests, and `git diff --check` passed; the repeated 100,000-case stress again reported zero mismatches in every category.
+ implication: The final minimal diff is stable under focused, repository-wide, and broad differential checks.
+- timestamp: 2026-08-01T12:17:02.4360622+05:30
+ checked: Human/primary-agent verification checkpoint.
+ found: The broad stress and focused checks were accepted as green, with explicit instruction to finalize the session as resolved without commits.
+ implication: The session can be archived; source and planning changes remain uncommitted for primary-agent micro-commit review.
+
+## Resolution
+
+root_cause: Five independent shared-path gaps caused all 6,019 mismatches: string format was discarded; explicit percent strings were re-promoted as ratios; percentage RGB used pre-rounded channels; Equals omitted JavaScript falsy guards; Brighten skipped the oracle's rounded RGB snapshot.
+fix: Forward string format; preserve explicit HSL/HSV percentages; calculate percentage RGB from internal floats; apply JS truthiness before equality parsing; start Brighten from `ToRGB()` channels.
+verification: Each root cause received a regression that failed before its fix and passed afterward. The final deterministic 100,000-case run passed three times with zero mismatches. Full Go tests, Go vet, 164/164 compatibility cases, port adapter tests, immutable Deno tests (45 passed, 0 failed, 1 ignored), gofmt, build, and diff checks passed.
+files_changed: [src/cmd/tinycolor-compat/main.go, src/cmd/tinycolor-compat/main_test.go, src/internal/parser/parser.go, src/tinycolor/color.go, src/tinycolor/color_test.go, src/tinycolor/operations_test.go]
diff --git a/.planning/debug/resolved/fuzz-saturate-rounding.md b/.planning/debug/resolved/fuzz-saturate-rounding.md
new file mode 100644
index 00000000..3a8d8bba
--- /dev/null
+++ b/.planning/debug/resolved/fuzz-saturate-rounding.md
@@ -0,0 +1,72 @@
+---
+status: resolved
+trigger: "Find and fix the first deterministic Phase 5 fuzz parity mismatch: saturate(-100) on #400140 returns #212121 in Go but #202020 in JavaScript."
+created: 2026-08-01T05:19:38.0748150+05:30
+updated: 2026-08-01T05:44:00+05:30
+---
+
+## Current Focus
+
+hypothesis: confirmed — Go setHSL bypasses TinyColor's object-input percentage conversion and Bound01 truncation, retaining 32.5 instead of the oracle's 32.487 before identical half-up output rounding
+test: completed
+expecting: all requested checks pass
+next_action: archived; commit remains for a caller with writable Git metadata
+
+## Symptoms
+
+expected: JavaScript oracle and Go response match exactly for request {id:fuzz-2, operation:modify, input:#400140, args:{method:saturate, amount:-100}}; JS after is #202020 with RGB 32,32,32.
+actual: Go after is #212121 with RGB 33,33,33.
+errors: seed-1 one-second fuzz run found 338 divergences; first mismatch is deterministic and full request/outputs are in .superpowers/sdd/05-02-PLAN/task-1-report.md.
+reproduction: send the exact JSONL request to persistent or one-shot compat/js-runner.mjs and the Go CLI; also call typed Color.Saturate(-100) on #400140.
+started: uncovered by the new broad Phase 5 fuzz generator; fixed Phase 4 corpus did not include this input.
+
+## Eliminated
+
+## Evidence
+
+- timestamp: 2026-08-01T05:23:00+05:30
+ checked: prior Phase 5 task report and repository status
+ found: exact request deterministically differs (#202020 JS vs #212121 Go); only concurrent fuzz harness files are dirty
+ implication: preserve fuzz files and isolate the fix to shared Go conversion behavior plus regressions
+
+- timestamp: 2026-08-01T05:23:00+05:30
+ checked: repository knowledge base and user memory registry
+ found: no matching prior TinyColor/HSL rounding diagnosis
+ implication: investigate from source behavior rather than reuse a known pattern
+
+- timestamp: 2026-08-01T05:25:00+05:30
+ checked: first adapter reproduction attempt
+ found: setup was invalid because Node ran from src and Go's default cache was sandbox-denied
+ implication: no behavioral conclusion; rerun from root and set GOCACHE inside the workspace
+
+- timestamp: 2026-08-01T05:30:00+05:30
+ checked: exact request through root compat/js-runner.mjs and src/cmd/tinycolor-compat
+ found: reproduced #202020 in JavaScript and #212121 in Go with otherwise identical responses
+ implication: mismatch is in shared typed color behavior, not fuzz harness or adapter serialization
+
+- timestamp: 2026-08-01T05:30:00+05:30
+ checked: source rgbToHsl/saturate/tinycolor(hsl)/hslToRgb path and all Go setHSL callers
+ found: JavaScript converts l=0.12745098039215685 to "12.745098039215685%" then Bound01 truncates to 12.74%, yielding 32.487; Go setHSL directly multiplies unquantized l by 255, yielding exactly 32.5
+ implication: identical half-up rounding then produces 32 versus 33; source-compatible percentage quantization is missing in the shared Go modifier conversion path used by Lighten, Darken, Saturate, Desaturate, Greyscale, and Spin
+
+- timestamp: 2026-08-01T05:36:00+05:30
+ checked: typed regression and full operations JSONL corpus before production change
+ found: typed test failed with #212121; corpus passed 69/70 and only the new exact regression mismatched
+ implication: regression isolates the defect and provides both API-level and adapter-level RED evidence
+
+- timestamp: 2026-08-01T05:40:00+05:30
+ checked: typed regression, 70-case operations corpus, and exact request through both adapters after fix
+ found: typed test passed; corpus passed 70/70 with zero mismatches; both adapters returned #202020 and RGB 32,32,32
+ implication: minimal shared-path fix resolves the original issue and adapter parity
+
+- timestamp: 2026-08-01T05:44:00+05:30
+ checked: go test ./..., go vet ./..., git diff --check, scoped diff, and commit attempt
+ found: all tests, vet, and diff check passed; scoped diff contains only the shared fix and two regressions; commit failed because .git/index.lock could not be created
+ implication: code is verified and ready to commit, but repository metadata permissions prevent this agent from creating the requested commit
+
+## Resolution
+
+root_cause: setHSL calls hslToRGB directly with normalized HSL values, while the JavaScript modifier reconstructs through tinycolor(hsl), whose object parser converts s/l to percentage strings and Bound01 truncates them to four decimal ratio precision before RGB conversion. At the 32.5 boundary this changes the rounded channel.
+fix: setHSL now reuses hslColor and the existing parser conversion, removing the duplicate direct hslToRGB implementation that skipped TinyColor percentage quantization.
+verification: focused typed test passed; operations corpus 70/70 with zero mismatches; exact JS and Go outputs both #202020/RGB 32; go test ./... passed; go vet ./... passed; git diff --check passed.
+files_changed: [src/tinycolor/color.go, src/tinycolor/operations_test.go, compat/cases/operations.jsonl]
diff --git a/.planning/debug/resolved/phase4-parity-gaps.md b/.planning/debug/resolved/phase4-parity-gaps.md
new file mode 100644
index 00000000..3def3314
--- /dev/null
+++ b/.planning/debug/resolved/phase4-parity-gaps.md
@@ -0,0 +1,113 @@
+---
+status: resolved
+trigger: "Investigate and fix Phase 4 parity gaps: JS coercion/defaults, MostReadable empty fallback, Brighten half-boundary rounding, truthy non-string WCAG level/size errors, and trailing whitespace."
+created: 2026-08-01T03:25:49.1964363+05:30
+updated: 2026-08-01T03:43:31.6647882+05:30
+---
+
+## Current Focus
+
+hypothesis: Resolved; automated and independent primary-agent verification both confirm parity.
+test: Archive this session and commit only the resolved debug document.
+expecting: No active Phase 4 debug session remains and source stays untouched.
+next_action: Move this file to `.planning/debug/resolved/` and create the final docs micro-commit.
+
+## Symptoms
+
+expected: Go compatibility behavior exactly matches local mod.js/test.js.
+actual: "Reviewer reproducers: (1) modifier/mix amount JS coercion/default gaps for null/false/numeric strings; (2) MostReadable with empty candidates and fallback enabled should still choose white/black; (3) Brighten half-boundary must mirror -Math.round(-delta); (4) truthy non-string WCAG level/size should reproduce source throw/error behavior."
+errors: Existing 58-row corpus passes because these cases are absent.
+reproduction: Use direct Node oracle requests and new fixed JSONL rows for the reported edge cases.
+started: Introduced during Phase 4 commits from base 38a7483 through 5601b29.
+
+## Eliminated
+
+## Evidence
+
+- timestamp: 2026-08-01T03:27:00.1181439+05:30
+ checked: Complete `mod.js` operations, Go facade/adapter, current unit tests/corpus, and Phase 4 research/validation.
+ found: Source uses `amount === 0 ? 0 : amount || fallback`, `-Math.round(-delta)`, and `(value || default).toUpperCase/toLowerCase`; Go uses float64-only decoding, `mathRound(delta)`, and string-only WCAG fields. `MostReadable` currently returns no result immediately for every empty list.
+ implication: All four reviewer areas have specific falsifiable divergence mechanisms; empty fallback must be probed with both readable-null and unreadable-null bases.
+- timestamp: 2026-08-01T03:27:39.1157918+05:30
+ checked: Twelve direct Node/Go requests covering all reported edges.
+ found: Eleven mismatches reproduced. Null/false/numeric-string amounts diverged for modifiers and Mix; dark-base empty fallback diverged while white-base empty fallback matched null; both brighten half signs diverged; truthy numeric level and boolean size threw in Node but returned true in Go.
+ implication: All four claims are valid with the MostReadable qualification that fallback occurs only when the provisional null/black candidate is not already readable.
+- timestamp: 2026-08-01T03:28:28.5725482+05:30
+ checked: Operations differential after adding six permanent amount cases.
+ found: RED reproduced exactly six mismatches; all 58 pre-existing cases passed.
+ implication: The failure is isolated to adapter amount defaulting/coercion, not typed modifier or Mix arithmetic.
+- timestamp: 2026-08-01T03:29:26.9690590+05:30
+ checked: Go suite and 64-row operations corpus after adapter fix.
+ found: `go test ./...` passed with repository-local GOCACHE; operations passed 64/64 with zero mismatches; focused diff check passed.
+ implication: Shared adapter coercion now reproduces modifier/Mix null, false, and numeric-string semantics without public API changes.
+- timestamp: 2026-08-01T03:30:30.8620009+05:30
+ checked: Atomic commit for amount fix.
+ found: Commit `9e8155b` contains only the operations corpus and Go adapter changes.
+ implication: Amount parity is independently recoverable and the next bug can be tested in isolation.
+- timestamp: 2026-08-01T03:31:33.4612643+05:30
+ checked: Focused `TestMostReadable` and 65-row differential after adding dark-base empty fallback.
+ found: Both RED checks fail only because Go returns no result while Node returns white; the original white-base empty case still passes.
+ implication: The early empty-list return is the confirmed root cause; fallback must depend on provisional null/black readability.
+- timestamp: 2026-08-01T03:32:34.8788960+05:30
+ checked: Focused MostReadable test, full Go suite, and 65-row operations differential after the fix.
+ found: All checks passed; white-base empty fallback remains null and dark-base empty fallback returns white.
+ implication: Selection now matches the source's null-as-black intermediate behavior without changing the nullable API.
+- timestamp: 2026-08-01T03:33:05.1909523+05:30
+ checked: Atomic commit for empty fallback fix.
+ found: Commit `9d7e74e` contains only the paired regressions and shared MostReadable change.
+ implication: Empty fallback parity is independently complete.
+- timestamp: 2026-08-01T03:33:58.7789774+05:30
+ checked: Focused modifier test and 67-row differential with exact positive/negative half ties.
+ found: RED failed at positive half in the unit test and exactly both half cases in the corpus; 65 prior rows passed.
+ implication: `mathRound(delta)` is the isolated root cause and must mirror the source's negated-round expression.
+- timestamp: 2026-08-01T03:34:38.9954301+05:30
+ checked: Focused modifier test, full Go suite, and 67-row operations differential after one-line rounding fix.
+ found: All checks passed, including both exact half signs and the pre-existing `.2` non-tie case.
+ implication: Brighten now matches `-Math.round(-delta)` without changing other modifiers.
+- timestamp: 2026-08-01T03:35:20.2523946+05:30
+ checked: Atomic commit for brighten fix.
+ found: Commit `8d2cdf3` contains only half-boundary regressions and the one-line delta correction.
+ implication: Brighten parity is independently complete.
+- timestamp: 2026-08-01T03:36:05.3216422+05:30
+ checked: 69-row differential after adding two truthy non-string WCAG cases.
+ found: RED produced exactly two mismatches with Node throwing `.toUpperCase`/`.toLowerCase` errors and Go returning true; 67 prior cases passed, including false/zero defaults.
+ implication: Dynamic type validation belongs only in the compatibility adapter before constructing typed WCAG options.
+- timestamp: 2026-08-01T03:36:54.3807334+05:30
+ checked: Full Go test/vet, adapter test, and 69-row operations differential after WCAG validation.
+ found: All checks passed; exact error strings and blank ids match Node, while falsy non-string defaults remain green.
+ implication: The adapter now preserves source throw behavior without weakening the typed Go WCAG API.
+- timestamp: 2026-08-01T03:37:40.4754534+05:30
+ checked: Atomic commit for WCAG fix and direct trailing-whitespace scan of `04-RESEARCH.md`.
+ found: Commit `5e14c31` contains only corpus/adapter changes; research lines 3, 4, and 266 each end in two spaces.
+ implication: The reviewer whitespace claim is confirmed and isolated to three documentation lines.
+- timestamp: 2026-08-01T03:38:11.3678041+05:30
+ checked: Direct whitespace scan and focused diff check after docs cleanup.
+ found: No trailing whitespace remains; only three line endings and the previously missing final newline changed.
+ implication: Documentation cleanup is ready for its independent commit.
+- timestamp: 2026-08-01T03:38:43.7870812+05:30
+ checked: Atomic commit for research cleanup.
+ found: Commit `c022dfe` contains only `04-RESEARCH.md`.
+ implication: All requested changes are independently committed and ready for final regression verification.
+- timestamp: 2026-08-01T03:40:22.7203553+05:30
+ checked: Complete Phase 1-4 gate.
+ found: Go test/vet and adapter test passed; corpora passed 9/9, 26/26, 23/23, 35/35, and 69/69 with zero mismatches; `git diff --check` passed; immutable oracle diff was empty.
+ implication: The fixes are regression-safe across all available phases; Deno remains unavailable and is not claimed.
+- timestamp: 2026-08-01T03:40:58.9567769+05:30
+ checked: Phase 4 evidence references and docs diff.
+ found: All stale 58-row current evidence references were replaced with 69/69; diff check passed.
+ implication: Evidence docs accurately describe the final verified gate.
+- timestamp: 2026-08-01T03:41:28.3450918+05:30
+ checked: Evidence-only commit and final worktree state.
+ found: Commit `26c3e17` records 69/69 evidence; all requested code/test/docs changes are committed independently.
+ implication: Automated verification is complete; only the GSD human-verify checkpoint remains before archival.
+- timestamp: 2026-08-01T03:43:31.6647882+05:30
+ checked: Human confirmation and independent primary-agent verification.
+ found: Confirmed fixed; primary-agent checks independently passed Go test/vet, adapter, all corpora, diff check, clean oracle diff, and matching oracle hashes.
+ implication: Resolution is confirmed end-to-end and the session can be archived.
+
+## Resolution
+
+root_cause: Adapter coercion, empty-list fallback selection, brighten tie rounding, and WCAG dynamic type handling each flattened a distinct JavaScript behavior.
+fix: Amount coercion now follows strict-zero/truthiness and numeric conversion; empty MostReadable lists evaluate the source's null/black provisional candidate before fallback; Brighten uses `-mathRound(-delta)`; WCAG truthy non-strings return the oracle's exact errors; Phase 4 evidence and whitespace were refreshed.
+verification: Full gate passed locally and independently under the primary agent: Go test/vet, adapter tests, corpora 9/9 + 26/26 + 23/23 + 35/35 + 69/69, diff check, empty immutable-oracle diff, and matching oracle hashes. Human confirmed fixed. Deno unavailable and not claimed.
+files_changed: [compat/cases/operations.jsonl, src/cmd/tinycolor-compat/main.go, src/tinycolor/color.go, src/tinycolor/operations_test.go, COMPATIBILITY.md, .planning/phases/04-operations-and-palettes/04-02-SUMMARY.md, .planning/phases/04-operations-and-palettes/04-06-SUMMARY.md, .planning/phases/04-operations-and-palettes/04-RESEARCH.md]
diff --git a/.planning/phases/01-foundation-and-oracle/01-01-PLAN.md b/.planning/phases/01-foundation-and-oracle/01-01-PLAN.md
new file mode 100644
index 00000000..bd8bedf0
--- /dev/null
+++ b/.planning/phases/01-foundation-and-oracle/01-01-PLAN.md
@@ -0,0 +1,192 @@
+---
+phase: 1
+plan: 1
+type: execute
+subsystem: compatibility-foundation
+tags: [go, node, jsonl, differential-testing]
+wave: 0
+depends_on: []
+files_modified:
+ - go/go.mod
+ - go/tinycolor/color.go
+ - go/cmd/tinycolor-compat/main.go
+ - go/internal/compat/protocol.go
+ - go/internal/compat/protocol_test.go
+ - compat/js-runner.mjs
+ - compat/run.mjs
+ - compat/cases/smoke.jsonl
+ - COMPATIBILITY.md
+autonomous: true
+requirements: [EQV-01, QLT-01, QLT-03]
+must_haves:
+ truths:
+ - "One JSONL request can be evaluated by local mod.js and by the Go runner."
+ - "The comparison command reports pass/mismatch totals and complete mismatch details."
+ - "The immutable JavaScript oracle files are not modified."
+ artifacts:
+ - path: "compat/js-runner.mjs"
+ provides: "Local mod.js JSONL oracle"
+ - path: "compat/run.mjs"
+ provides: "Structured differential report"
+ - path: "go/cmd/tinycolor-compat/main.go"
+ provides: "Go JSONL compatibility runner"
+ key_links:
+ - from: "compat/run.mjs"
+ to: "compat/js-runner.mjs"
+ via: "child-process stdin/stdout JSONL invocation"
+ - from: "compat/run.mjs"
+ to: "go/cmd/tinycolor-compat/main.go"
+ via: "go run command using the same request lines"
+---
+
+
+Build the smallest trustworthy parity foundation: independent Go module, local
+Node oracle, shared JSONL protocol, smoke corpus, and a report that makes every
+comparison result inspectable.
+
+Purpose: all later work needs a feedback loop before behavior is ported.
+Output: runnable `node compat/run.mjs compat/cases/smoke.jsonl` plus Go tests.
+
+
+
+
+
+Create the Go module, protocol, and minimal public color package
+go/go.mod, go/tinycolor/color.go, go/internal/compat/protocol.go, go/internal/compat/protocol_test.go, go/cmd/tinycolor-compat/main.go
+
+- `AGENT.md`
+- `PLAN.md`
+- `.planning/phases/01-foundation-and-oracle/01-CONTEXT.md`
+- `docs/ARCHITECTURE.md`
+- `mod.js`
+
+
+Create `go/go.mod` with a project-local module path and only the Go version.
+Create `go/tinycolor/color.go` with the smallest exported `Color` value and
+inspection methods needed by the fixed Phase 1 smoke corpus. Implement only
+these inputs: `red`, `#000`, `not a color`, `transparent`,
+`rgba(255, 0, 0, .5)`, `hsl(0, 100%, 50%)`, `hsv(0, 100%, 100%)`, RGB object
+`{r:255,g:0,b:0}`, and equivalent `fromRatio` RGB. Unknown input returns a
+classified unsupported-input response; Phase 2 replaces this narrow dispatcher
+with full TinyColor parsing.
+Create `go/internal/compat/protocol.go` defining JSON-safe request and response
+types: Request has `id`, `operation`, `input`, `args`; Response has `id`,
+`result`, and `error`. Implement a line-oriented decoder/encoder that rejects
+malformed JSON with a structured error and writes no diagnostics to stdout.
+Encode exactly one of `result` or `error`: use `omitempty` (or equivalent) and
+make the response constructor reject both-set and neither-set states. Test a
+success response, unsupported operation, malformed JSON, and both/neither
+response-construction attempts.
+Create `go/cmd/tinycolor-compat/main.go` that reads stdin line by line, echoes
+each request ID, delegates every smoke `inspect`, `string`, and `fromRatio`
+operation to `go/tinycolor`, and returns `unsupported operation` for unknown
+operations. It must not duplicate color tables or fixed-input dispatch logic.
+Add `go/internal/compat/protocol_test.go` covering one success response, one
+malformed JSON response, and the one-request-to-one-response invariant.
+
+Set-Location go; gofmt -w tinycolor/color.go internal/compat/protocol.go internal/compat/protocol_test.go cmd/tinycolor-compat/main.go; go test ./...; go vet ./...; '{"id":"x","operation":"unknown"}' | go run ./cmd/tinycolor-compat
+
+- `go/go.mod` exists and contains a `go 1.26` directive.
+- `go/internal/compat/protocol.go` defines `Request` and `Response`.
+- Protocol tests prove successful, unsupported, and malformed requests serialize
+ exactly one of `result` or `error`.
+- `go test ./...` from `go/` exits 0.
+- `go run ./cmd/tinycolor-compat` returns a JSON line with the input `id` and
+ an `error` field for `{"id":"x","operation":"unknown"}`.
+
+The Go module builds, protocol tests pass, and the runner emits one structured response per request.
+
+
+
+Create the local Node oracle and fixed smoke corpus
+compat/js-runner.mjs, compat/cases/smoke.jsonl
+
+- `mod.js`
+- `test.js`
+- `docs/ARCHITECTURE.md`
+- `go/internal/compat/protocol.go`
+
+
+Create `compat/js-runner.mjs` as an ESM executable that imports `../mod.js`.
+Implement the shared JSONL loop. Support exactly these Phase 1 operations:
+`inspect` returns validity, detected format, alpha, RGB object, and default
+string; `string` returns `tinycolor(input).toString(args.format)`; `fromRatio`
+returns the same inspect result after `tinycolor.fromRatio(input, args)`; and
+`error` serializes thrown JavaScript errors by name and message. Keep input/output
+serialization deterministic. Every Node response must contain exactly one of
+`result` or `error`; malformed JSON and unknown operations must return a
+structured `error`, and one non-empty input line must produce one stdout line.
+Create `compat/cases/smoke.jsonl` with stable IDs
+covering `red`, `#000`, `not a color`, `transparent`, `rgba(255, 0, 0, .5)`,
+`hsl(0, 100%, 50%)`, `hsv(0, 100%, 100%)`, `{ "r": 255, "g": 0, "b": 0 }`,
+and one `fromRatio` request.
+
+'{"id":"ratio-red","operation":"fromRatio","input":{"r":1,"g":0,"b":0}}' | node compat/js-runner.mjs; '{bad json' | node compat/js-runner.mjs; '{"id":"unknown","operation":"unknown"}' | node compat/js-runner.mjs; (Get-Content compat/cases/smoke.jsonl | Measure-Object -Line).Lines
+
+- `compat/js-runner.mjs` contains `import tinycolor from "../mod.js"`.
+- `compat/cases/smoke.jsonl` contains at least 9 non-empty JSON lines with
+ unique `id` values.
+- A `fromRatio` request returns an inspect-shaped result for red.
+- Node success, malformed JSON, and unknown-operation responses contain exactly
+ one of `result` or `error`, with one response line for each input line.
+- Piping one `inspect` request into `node compat/js-runner.mjs` returns one
+ response line with the same ID and a `result` field.
+- `git diff -- mod.js test.js tinycolor.js` is empty after the task.
+
+The Node runner evaluates all fixed smoke operations against local mod.js and the corpus is source-only.
+
+
+
+Run equal smoke operations through both runners and publish the baseline
+compat/run.mjs, go/cmd/tinycolor-compat/main.go, COMPATIBILITY.md
+
+- `compat/js-runner.mjs`
+- `compat/cases/smoke.jsonl`
+- `go/cmd/tinycolor-compat/main.go`
+- `docs/TESTING.md`
+- `COMPATIBILITY.md`
+
+
+Create `compat/run.mjs` that reads a JSONL corpus, sends each request unchanged
+to both runner commands, parses their one-line responses, and prints a summary
+with `cases`, `passed`, and `mismatches`. For every mismatch print the stable
+case ID, operation, full request, JavaScript response, Go response, and exit
+non-zero. It must also print `owner` and `suspectedPackage`: use `compat` when
+the response protocol differs and `go/tinycolor` when the Go color result
+differs. Make the Go runner implement the same Phase 1 `inspect`, `string`,
+and `fromRatio` operations for exactly the fixed smoke corpus. For `fromRatio`,
+convert non-alpha fields from `[0,1]` before inspection and preserve alpha.
+Update `COMPATIBILITY.md` with the exact command, corpus name, case count, date,
+and result. This fixed corpus must be green; generalized parsing remains Phase 2
+and must not be added to smoke cases before it is implemented.
+
+Set-Location go; go test ./...; go vet ./...; Set-Location ..; node compat/run.mjs compat/cases/smoke.jsonl; git diff --check; git diff -- mod.js test.js tinycolor.js
+
+- `compat/run.mjs` contains the literal labels `cases`, `passed`, and
+ `mismatches`.
+- `node compat/run.mjs compat/cases/smoke.jsonl` exits 0 with `mismatches: 0`
+ for the fixed corpus and exits non-zero with case/JS/Go details for a forced
+ mismatch, `owner`, and `suspectedPackage`.
+- `COMPATIBILITY.md` names `compat/cases/smoke.jsonl` and the exact runner
+ command used for the result.
+- `git diff --check` exits 0.
+
+The fixed smoke corpus is green, its exact command/count are documented, and the oracle source diff is empty.
+
+
+
+
+
+From repository root run:
+
+```powershell
+Set-Location go; go test ./...; Set-Location ..
+Set-Location go; go vet ./...; Set-Location ..
+node compat/run.mjs compat/cases/smoke.jsonl
+git diff --check
+git diff -- mod.js test.js tinycolor.js
+```
+
+The Go tests and differential report must exit 0. The oracle-file diff command
+must produce no output. Record the actual report count in `COMPATIBILITY.md`.
+
diff --git a/.planning/phases/01-foundation-and-oracle/01-01-SUMMARY.md b/.planning/phases/01-foundation-and-oracle/01-01-SUMMARY.md
new file mode 100644
index 00000000..e1f19fe4
--- /dev/null
+++ b/.planning/phases/01-foundation-and-oracle/01-01-SUMMARY.md
@@ -0,0 +1,15 @@
+---
+phase: 1
+plan: 1
+subsystem: compatibility-foundation
+provides: [Go module, Node and Go JSONL runners, differential smoke corpus]
+commits: [78423e3]
+---
+
+# Phase 1 Plan 1: Foundation and Oracle Summary
+
+Created the independent Go module, shared JSONL protocol, local JavaScript
+oracle runner, Go compatibility runner, and fixed nine-case smoke corpus.
+
+Final evidence: Go tests and vet passed; `compat/cases/smoke.jsonl` passed 9/9
+with zero mismatches; `mod.js`, `test.js`, and `tinycolor.js` stayed unchanged.
diff --git a/.planning/phases/01-foundation-and-oracle/01-CONTEXT.md b/.planning/phases/01-foundation-and-oracle/01-CONTEXT.md
new file mode 100644
index 00000000..e017ccd6
--- /dev/null
+++ b/.planning/phases/01-foundation-and-oracle/01-CONTEXT.md
@@ -0,0 +1,85 @@
+# Phase 1: Foundation and Oracle - Context
+
+**Gathered:** 2026-08-01
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Create the independent Go module and a local, executable Node-to-Go comparison
+path. This phase proves the test architecture; it does not implement full color
+parsing, conversions, or the public CLI.
+
+
+
+## Implementation Decisions
+
+### Source integrity
+- The local `mod.js`, `test.js`, generated npm files, and demo are immutable
+ reference files.
+- The Node runner imports `./mod.js` directly; it must not compare against a
+ registry package.
+
+### Module and protocol
+- New implementation files live below `go/`, with a separate `go/go.mod`.
+- Both runners use one JSON object per stdin line and one JSON object per stdout
+ line. Diagnostics go to stderr.
+- The request has `id`, `operation`, `input`, and optional `args`; a response
+ echoes `id` and contains one of `result` or `error`.
+
+### Evidence
+- The initial corpus is a deliberately small smoke suite sourced from `test.js`
+ and README examples; it must include a valid value, invalid value, alpha, a
+ named color, and one HSL/HSV/object form.
+- The report must include total cases, pass count, mismatch count, and for each
+ mismatch the request plus JavaScript and Go results.
+
+### the agent's Discretion
+- Package names, JSON implementation details, and exact process-launch strategy,
+ provided they maintain the protocol and use standard library dependencies.
+
+
+
+## Canonical References
+
+### Source behavior
+- `mod.js` — local reference implementation the Node runner imports.
+- `test.js` — existing behavioral cases used to seed the corpus.
+- `README.md` — accepted input and documented operation surface.
+
+### Project decisions
+- `AGENT.md` — repository boundaries, ownership, and required checks.
+- `PLAN.md` — all-wave plan and Phase 1 gate.
+- `DECISIONS.md` — accepted architecture decisions.
+- `docs/ARCHITECTURE.md` — layer and protocol constraints.
+- `docs/TESTING.md` — comparison rules and test layers.
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+- `mod.js`: ESM default export, directly importable by Node.
+- `test.js`: categorized upstream behavior, including invalid colors and alpha.
+
+### Established Patterns
+- Source calls return objects and strings; source tests use Deno but local Node
+ can import `mod.js` for an adapter.
+
+### Integration Points
+- Future Go parser/API code consumes the protocol designed here; the smoke
+ runner must allow unsupported operations while the implementation is staged.
+
+
+
+## Deferred Ideas
+
+Full parser/conversion implementation (Phase 2/3), CLI and CI (Phase 5), and
+Deno suite setup once the runtime is available.
+
+
+---
+
+*Phase: 01-foundation-and-oracle*
+*Context gathered: 2026-08-01*
+
diff --git a/.planning/phases/01-foundation-and-oracle/01-RESEARCH.md b/.planning/phases/01-foundation-and-oracle/01-RESEARCH.md
new file mode 100644
index 00000000..470a8eff
--- /dev/null
+++ b/.planning/phases/01-foundation-and-oracle/01-RESEARCH.md
@@ -0,0 +1,38 @@
+# Phase 1 Research — Foundation and Oracle
+
+## Findings
+
+- This checkout's executable ESM source is `mod.js`; it exports TinyColor as a
+ default function and contains both methods and static helpers.
+- `test.js` is a Deno test file that imports `./mod.js`; it has groups for
+ initialization, input, conversion, output, readability, modifications, and
+ palettes. It should be read as behavior evidence, not altered.
+- Node 24.18.0 can execute an ESM adapter locally. Go 1.26.1 is installed.
+- Deno is not installed, so `deno task test` cannot currently validate the
+ upstream suite. Node import of `mod.js` is the available local oracle path.
+- JSON Lines is sufficient for requests because all phase-one inputs and
+ outputs can be JSON-safe. Do not attempt to transfer JavaScript functions or
+ original object identity through this adapter.
+
+## Recommended implementation shape
+
+Use one short-lived command invocation per runner during Phase 1 for simpler
+failure attribution. If benchmark evidence later shows process startup dominates
+the suite, upgrade the same protocol to persistent child processes without
+changing case files or response schema.
+
+## Pitfalls
+
+- JavaScript's `undefined`, `NaN`, and signed-zero behavior needs explicit
+ serialization rules before later corpus expansion.
+- A Go error is not automatically source-equivalent: source invalid color input
+ frequently creates an invalid value that renders as black.
+- JSON field ordering is not behavior; values, operation results, and error
+ classification are.
+
+## Validation Architecture
+
+Phase 1 can validate all of its new behavior with Go tests plus one Node-driven
+smoke report. The original Deno test suite remains a manual/CI follow-up until
+the runtime is installed.
+
diff --git a/.planning/phases/01-foundation-and-oracle/01-VALIDATION.md b/.planning/phases/01-foundation-and-oracle/01-VALIDATION.md
new file mode 100644
index 00000000..1bf524f0
--- /dev/null
+++ b/.planning/phases/01-foundation-and-oracle/01-VALIDATION.md
@@ -0,0 +1,54 @@
+---
+phase: 1
+slug: foundation-and-oracle
+status: draft
+nyquist_compliant: true
+wave_0_complete: false
+created: 2026-08-01
+---
+
+# Phase 1 — Validation Strategy
+
+## Test Infrastructure
+
+| Property | Value |
+|---|---|
+| Framework | Go standard `testing` plus Node 24.18.0 |
+| Config file | `go/go.mod` (created by Plan 01-01) |
+| Quick run command | `go test ./...` from `go/` |
+| Full suite command | `node compat/run.mjs compat/cases/smoke.jsonl` |
+| Estimated runtime | under 10 seconds |
+
+## Sampling Rate
+
+- After every task commit: `go test ./...` and `go vet ./...` from `go/`.
+- After Wave 0: `node compat/run.mjs compat/cases/smoke.jsonl`.
+- Before phase verification: run both commands, `go vet ./...`, and `git diff --check`.
+
+## Per-Task Verification Map
+
+| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
+|---|---|---|---|---|---|---|---|
+| 01-01-01 | 01 | 0 | QLT-01 | static | `git diff -- mod.js test.js tinycolor.js` | ❌ W0 | pending |
+| 01-01-02 | 01 | 0 | EQV-01 | unit | `go test ./...` | ❌ W0 | pending |
+| 01-01-03 | 01 | 0 | EQV-01, QLT-03 | differential | `node compat/run.mjs compat/cases/smoke.jsonl` | ❌ W0 | pending |
+
+## Wave 0 Requirements
+
+- [ ] `go/go.mod` and a minimal Go runner test.
+- [ ] `go/tinycolor/color.go` implements only fixed smoke-corpus inputs.
+- [ ] `compat/js-runner.mjs`, `compat/run.mjs`, and smoke corpus.
+- [ ] A documented protocol schema in `docs/ARCHITECTURE.md`.
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|---|---|---|---|
+| Upstream Deno source suite | QLT-03 | Deno unavailable locally | After installing Deno, run `deno task test` from repo root and record result. |
+
+## Validation Sign-Off
+
+- [x] Every planned task has an automated check or a declared Wave 0 dependency.
+- [x] No three consecutive tasks lack automated verification.
+- [x] The unavailable Deno check is explicit rather than claimed.
+- [ ] Phase checks are green.
diff --git a/.planning/phases/02-parsing-and-color-state/02-01-PLAN.md b/.planning/phases/02-parsing-and-color-state/02-01-PLAN.md
new file mode 100644
index 00000000..3910cd67
--- /dev/null
+++ b/.planning/phases/02-parsing-and-color-state/02-01-PLAN.md
@@ -0,0 +1,90 @@
+---
+phase: 2
+plan: 1
+type: execute
+subsystem: parser-model-rgb
+tags: [go, parser, tinycolor, differential-testing]
+wave: 1
+depends_on: []
+files_modified:
+ - src/internal/color/model.go
+ - src/internal/color/bounds.go
+ - src/internal/color/model_test.go
+ - src/internal/parser/parser.go
+ - src/internal/parser/hex.go
+ - src/internal/parser/rgb.go
+ - src/internal/parser/names.go
+ - src/internal/parser/parser_test.go
+ - src/tinycolor/color.go
+ - compat/cases/parser-hex-rgb.jsonl
+ - COMPATIBILITY.md
+autonomous: true
+requirements: [PAR-01, PAR-02, QLT-02]
+must_haves:
+ truths:
+ - "HEX, RGB(A), named colors, transparent, invalid input, and RGB objects match local TinyColor state."
+ - "Phase 1 JSONL operations remain supported without hard-coded color inputs."
+ artifacts:
+ - path: "go/internal/color/model.go"
+ provides: "Normalized RGBA, validity, and format state"
+ - path: "go/internal/parser/parser.go"
+ provides: "Single string/object parser entrypoint"
+ - path: "compat/cases/parser-hex-rgb.jsonl"
+ provides: "Exact source-derived parser corpus"
+---
+
+
+Replace the fixed Phase 1 color decoder with the shared normalized model and
+the first complete parser slice: bounds, HEX, RGB(A), names, transparent,
+invalid input, and RGB objects.
+
+
+
+
+Create the normalized color model and JavaScript-equivalent bounds
+src/internal/color/model.go, src/internal/color/bounds.go, src/internal/color/model_test.go
+
+- `mod.js` lines 359-442 and 1047-1128
+- `test.js` lines 274-443 and 698-746
+- `src/tinycolor/color.go`
+
+
+Define one private normalized RGBA model with float RGB channels, alpha,
+validity, a typed format that can represent source false distinctly from known
+format strings, and original input metadata. Preserve source original-input
+rules: null and empty become `""`; string case is retained; object input remains
+JSON-structurally equal; and FromRatio retains its transformed object. Port `bound01`, `boundAlpha`, percentage detection,
+`1.0` handling, clamping, modulo, and near-maximum rounding from `mod.js`.
+Write table tests for negative/overflow values, `1`, string `1.0`, percentages,
+invalid alpha, alpha zero, the source invalid-black state, lowercase/uppercase/
+mixed strings, objects, empty strings, and null.
+
+Set-Location src; go test ./internal/color; go vet ./...
+All normalized state and bounds tests pass with explicit source-derived boundary values.
+
+
+
+Parse HEX, RGB(A), names, transparent, and RGB objects through the shared model
+src/internal/parser/parser.go, src/internal/parser/hex.go, src/internal/parser/rgb.go, src/internal/parser/names.go, src/internal/parser/parser_test.go, src/tinycolor/color.go, compat/cases/parser-hex-rgb.jsonl, COMPATIBILITY.md
+
+- `mod.js` lines 359-442 and 1129-1260
+- `test.js` lines 316-443, 487-697, and 698-746
+- `compat/js-runner.mjs`
+- `compat/cases/smoke.jsonl`
+
+
+Implement one parser entrypoint used by `go/tinycolor`; remove fixed-input
+switches from the public facade. Accept optional-`#` 3/4/6/8 hex, case-insensitive
+trimmed names, transparent, and source-permissive RGB/RGBA fields. Generate or
+commit the full local named-color map as Go data derived from `mod.js`. Accept
+RGB object fields using the same bounds and alpha rules. Preserve `name`, `hex`,
+`hex8`, `rgb`, and `prgb` formats. Add source-derived JSONL cases for every
+syntax family, name aliases, invalid strings/objects, percentage RGB, and
+alpha boundaries, original strings/objects/null/empty values; retain all Phase 1 smoke cases unchanged.
+Extend both JSONL `inspect` results with an `original` field: Node uses
+`getOriginalInput()` and Go serializes the model's JSON-safe original value.
+
+Set-Location src; go test ./...; go vet ./...; Set-Location ..; node compat/run.mjs compat/cases/parser-hex-rgb.jsonl; node compat/run.mjs compat/cases/smoke.jsonl
+HEX/RGB/name/object differential corpus and the unchanged Phase 1 smoke corpus exit 0 with no mismatches.
+
+
diff --git a/.planning/phases/02-parsing-and-color-state/02-01-SUMMARY.md b/.planning/phases/02-parsing-and-color-state/02-01-SUMMARY.md
new file mode 100644
index 00000000..3c27b6a3
--- /dev/null
+++ b/.planning/phases/02-parsing-and-color-state/02-01-SUMMARY.md
@@ -0,0 +1,15 @@
+---
+phase: 2
+plan: 1
+subsystem: parser-model-rgb
+provides: [normalized color model, bounds, HEX RGB and name parsing]
+commits: [82b1208]
+---
+
+# Phase 2 Plan 1: Model and RGB Parser Summary
+
+Replaced the Phase 1 fixed decoder with normalized color state and shared
+parsing for HEX, RGB(A), percentage RGB, named colors, transparent, invalid
+input, and RGB objects while preserving format and original-input metadata.
+
+Final evidence: the focused parser corpus passed 26/26 with zero mismatches.
diff --git a/.planning/phases/02-parsing-and-color-state/02-02-PLAN.md b/.planning/phases/02-parsing-and-color-state/02-02-PLAN.md
new file mode 100644
index 00000000..4d956f94
--- /dev/null
+++ b/.planning/phases/02-parsing-and-color-state/02-02-PLAN.md
@@ -0,0 +1,81 @@
+---
+phase: 2
+plan: 2
+type: execute
+subsystem: parser-hsl-hsv
+tags: [go, parser, hsl, hsv, differential-testing]
+wave: 2
+depends_on: [02-01]
+files_modified:
+ - src/internal/parser/hsl.go
+ - src/internal/parser/hsv.go
+ - src/internal/parser/object.go
+ - src/internal/parser/parser_test.go
+ - src/tinycolor/color.go
+ - compat/cases/parser.jsonl
+ - COMPATIBILITY.md
+autonomous: true
+requirements: [PAR-01, PAR-02, QLT-02]
+must_haves:
+ truths:
+ - "HSL(A), HSV(A), HSL/HSV objects, and FromRatio produce source-equivalent normalized state."
+ - "The complete Phase 2 parser corpus and Phase 1 smoke corpus report zero mismatches."
+ artifacts:
+ - path: "src/internal/parser/hsl.go"
+ provides: "HSL(A) parser path to normalized RGB"
+ - path: "src/internal/parser/hsv.go"
+ provides: "HSV(A) parser path to normalized RGB"
+ - path: "compat/cases/parser.jsonl"
+ provides: "Full Phase 2 parser differential corpus"
+---
+
+
+Finish the input surface by porting HSL/HSV conversion used during parsing,
+object precedence, FromRatio transformation, and the full Phase 2 parser corpus.
+
+
+
+
+Parse HSL(A), HSV(A), typed objects, and FromRatio using source bounds
+src/internal/parser/hsl.go, src/internal/parser/hsv.go, src/internal/parser/object.go, src/internal/parser/parser_test.go, src/tinycolor/color.go
+
+- `mod.js` lines 359-572 and 1129-1260
+- `test.js` lines 274-315, 444-515, 945-1096
+- `src/internal/color/bounds.go`
+- `src/internal/parser/parser.go`
+
+
+Port only the RGB conversion math required to normalize parsed HSL/HSV values;
+keep output conversion APIs for Phase 3. Match source object precedence RGB,
+then HSV, then HSL. Accept CSS-unit HSL/HSV object fields, the permissive
+functional syntax, hue wrapping, saturation/lightness/value ratios, and alpha
+normalization. Implement FromRatio by converting every non-alpha object field
+through the source percentage transformation before parser dispatch. Add exact
+tests for source HSL/HSV examples, decimals, percentages, wrapped hues, alpha,
+and malformed components.
+
+Set-Location src; go test ./internal/parser ./tinycolor; go vet ./...
+Focused HSL/HSV, object, and FromRatio tests pass and no parser path bypasses the normalized model.
+
+
+
+Publish the complete parser differential corpus and compatibility evidence
+compat/cases/parser.jsonl, COMPATIBILITY.md
+
+- `test.js` lines 274-747 and 945-1096
+- `compat/cases/parser-hex-rgb.jsonl`
+- `compat/run.mjs`
+- `COMPATIBILITY.md`
+
+
+Create `compat/cases/parser.jsonl` from source cases covering ratios, RGB,
+percentage RGB, HSL(A), HSV(A), all hex lengths, names, transparent, invalid
+input, object precedence, alpha values, whitespace/case variants, and boundary
+values. Run it through the unchanged Node/Go runner; record exact case count,
+command, and result in `COMPATIBILITY.md`. Every mismatch must retain the
+runner's input/JS/Go/owner/suspected-package record until fixed.
+
+Set-Location src; go test ./...; go vet ./...; Set-Location ..; node compat/run.mjs compat/cases/parser.jsonl; node compat/run.mjs compat/cases/smoke.jsonl; git diff --check; git diff -- mod.js test.js tinycolor.js
+The full parser corpus and Phase 1 smoke corpus exit 0; compatibility evidence names the corpus count and no oracle files changed.
+
+
diff --git a/.planning/phases/02-parsing-and-color-state/02-02-SUMMARY.md b/.planning/phases/02-parsing-and-color-state/02-02-SUMMARY.md
new file mode 100644
index 00000000..9a2167f4
--- /dev/null
+++ b/.planning/phases/02-parsing-and-color-state/02-02-SUMMARY.md
@@ -0,0 +1,15 @@
+---
+phase: 2
+plan: 2
+subsystem: parser-hsl-hsv
+provides: [HSL and HSV parsing, typed objects, FromRatio normalization]
+commits: [82b1208]
+---
+
+# Phase 2 Plan 2: HSL and HSV Parser Summary
+
+Added HSL(A), HSV(A), typed-object precedence, hue wrapping, alpha handling,
+and FromRatio normalization through the shared model.
+
+Final evidence: the full parser corpus passed 23/23 and the Phase 1 smoke
+corpus remained 9/9, both with zero mismatches.
diff --git a/.planning/phases/02-parsing-and-color-state/02-CONTEXT.md b/.planning/phases/02-parsing-and-color-state/02-CONTEXT.md
new file mode 100644
index 00000000..f06371a3
--- /dev/null
+++ b/.planning/phases/02-parsing-and-color-state/02-CONTEXT.md
@@ -0,0 +1,72 @@
+# Phase 2: Parsing and Color State - Context
+
+**Gathered:** 2026-08-01
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Replace the fixed Phase 1 decoder with the complete input behavior in local
+`mod.js`: strings, object input, alpha, source format, validity, and normalized
+RGBA state. Output formatting beyond the minimal compatibility snapshot belongs
+to Phase 3.
+
+
+
+## Implementation Decisions
+
+### Ownership and boundaries
+- @rajeet-04 owns `go/internal/color` and `go/internal/parser` for this phase.
+- `go/tinycolor` remains a thin public facade over the internal model; do not
+ move formatting, readability, mutations, or palettes into Phase 2.
+
+### Source equivalence
+- `mod.js` is authoritative for `inputToRGB`, `bound01`, `boundAlpha`,
+ `isValidCSSUnit`, and `stringInputToObject`.
+- Preserve source format values: `rgb`, `prgb`, `hsl`, `hsv`, `hex`, `hex8`,
+ `name`, and invalid `false` (represented internally without an empty-string
+ ambiguity).
+- Invalid input is a valid construction result with `Valid=false`, black RGB,
+ alpha 1, and no format; it is not a Go parsing error.
+
+### Supported inputs
+- Strings: case-insensitive/trimming names, transparent, 3/4/6/8 hex with
+ optional `#`, and the permissive RGB(A)/HSL(A)/HSV(A) grammar from source.
+- Objects: RGB, HSL, HSV, optional alpha, CSS-unit values, and `FromRatio`.
+- Preserve alpha normalization: malformed, negative, or >1 alpha becomes 1;
+ numeric zero remains zero.
+
+### Compatibility evidence
+- Use generated JSONL corpus records derived from `test.js`; no edits to the
+ upstream test file.
+- Each mismatch includes input, operation, source result, Go result, owner, and
+ suspected package. No global float tolerance.
+
+### the agent's Discretion
+- Exact Go private type names and parser helper decomposition.
+- Whether name data is generated from `mod.js` during development or checked in
+ as generated Go data, provided the committed table comes from this checkout.
+
+
+
+## Canonical References
+
+### Upstream behavior
+- `mod.js` lines 359-654 — input-to-RGB and conversion paths invoked by parsing.
+- `mod.js` lines 1047-1260 — named colors, bounds, regex grammar, and string parser.
+- `test.js` lines 274-747 — ratio, RGB, HSL, HEX, HSV, invalid, name, and alpha tests.
+
+### Project contracts
+- `AGENT.md` — ownership and non-negotiable parity behavior.
+- `docs/ARCHITECTURE.md` — parser/model and adapter layers.
+- `docs/TESTING.md` — comparison rules.
+- `.planning/phases/01-foundation-and-oracle/01-01-PLAN.md` — stable protocol that Phase 2 must keep working.
+
+
+
+## Deferred Ideas
+
+Public output formats, analysis, manipulation, palettes, CLI, CI, benchmarks,
+and any syntax not accepted by the local source checkout.
+
+
diff --git a/.planning/phases/02-parsing-and-color-state/02-RESEARCH.md b/.planning/phases/02-parsing-and-color-state/02-RESEARCH.md
new file mode 100644
index 00000000..fbf9fb8f
--- /dev/null
+++ b/.planning/phases/02-parsing-and-color-state/02-RESEARCH.md
@@ -0,0 +1,34 @@
+# Phase 2 Research — Parsing and Color State
+
+## Source facts
+
+- TinyColor parses with `inputToRGB`: strings first become object-like fields,
+ then RGB has precedence over HSV, which has precedence over HSL.
+- `bound01` treats string `1.0` as `100%`, recognizes `%`, clamps before
+ scaling, returns exactly 1 near the upper bound, and otherwise uses modulo.
+- `boundAlpha` uses JavaScript `parseFloat`; non-numeric, negative, and >1
+ values become 1.
+- Its CSS unit and functional syntax are intentionally permissive: commas and
+ parentheses are optional, whitespace can separate components, signs and
+ decimal values are accepted, and matching is case-insensitive after lowercasing.
+- Hex permits `#` optionally at 3, 4, 6, and 8 digits. Four/eight digit alpha
+ converts from hex to `[0,1]` and receives format `hex8`.
+- Named colors are the source `names` map; recognized names are converted to
+ hex but retain source format `name`. `transparent` is special (`0,0,0,0`).
+- Invalid colors remain black with alpha 1 and false validity rather than
+ returning an exception.
+
+## Design consequence
+
+Make a private normalized model the only parser output. The public facade and
+compatibility runner should consume that model rather than duplicate bounds,
+format decisions, or name handling. The model should retain channel precision;
+observable rounding belongs to Phase 3.
+
+## Validation architecture
+
+Run focused Go unit tests for model/bounds/parser and differential JSONL cases
+through the existing Node runner. Use exact rendered/JSON values. Test parser
+categories separately so a mismatch names `go/internal/parser` or
+`go/internal/color`, not a vague package.
+
diff --git a/.planning/phases/02-parsing-and-color-state/02-VALIDATION.md b/.planning/phases/02-parsing-and-color-state/02-VALIDATION.md
new file mode 100644
index 00000000..fb059c18
--- /dev/null
+++ b/.planning/phases/02-parsing-and-color-state/02-VALIDATION.md
@@ -0,0 +1,38 @@
+---
+phase: 2
+slug: parsing-and-color-state
+status: draft
+nyquist_compliant: true
+wave_0_complete: false
+created: 2026-08-01
+---
+
+# Phase 2 — Validation Strategy
+
+| Property | Value |
+|---|---|
+| Framework | Go standard `testing` plus Node 24.18.0 |
+| Quick run | `go test ./internal/color ./internal/parser` from `go/` |
+| Full run | `go test ./...; node compat/run.mjs compat/cases/parser.jsonl` |
+| Static checks | `go vet ./...` and no output from `gofmt -d` |
+
+## Sampling rate
+
+- After each parser/model task: focused Go tests and `go vet ./...`.
+- After Plan 02-01: HEX/RGB/name differential corpus.
+- After Plan 02-02: full parser corpus and Phase 1 smoke corpus.
+
+## Verification map
+
+| Task | Plan | Requirement | Automated proof |
+|---|---|---|---|
+| 02-01-01 | 02-01 | PAR-02 | `go test ./internal/color` |
+| 02-01-02 | 02-01 | PAR-01 | `node compat/run.mjs compat/cases/parser-hex-rgb.jsonl` |
+| 02-02-01 | 02-02 | PAR-01, PAR-02 | `go test ./internal/parser` |
+| 02-02-02 | 02-02 | QLT-02 | `node compat/run.mjs compat/cases/parser.jsonl` |
+
+## Manual-only check
+
+When Deno is available, run `deno task test`; until then it remains explicitly
+unverified and does not substitute for Node differential evidence.
+
diff --git a/.planning/phases/03-conversion-and-representation/03-01-PLAN.md b/.planning/phases/03-conversion-and-representation/03-01-PLAN.md
new file mode 100644
index 00000000..1409c244
--- /dev/null
+++ b/.planning/phases/03-conversion-and-representation/03-01-PLAN.md
@@ -0,0 +1,190 @@
+---
+phase: 3
+plan: 1
+type: execute
+subsystem: conversion-formatting-analysis
+owner: B-xthxr
+tags: [go, tinycolor, conversion, formatting, analysis]
+wave: 1
+depends_on: [02-01, 02-02]
+files_modified:
+ - src/tinycolor/color.go
+ - src/tinycolor/color_test.go
+autonomous: true
+requirements: [FMT-01, QLT-02]
+must_haves:
+ truths:
+ - "Typed Go callers can obtain TinyColor-equivalent rounded RGB, percentage-RGB, fractional HSL/HSV, raw and string hex/hex8, name, filter, and generic string representations."
+ - "Typed Go callers can obtain source-equivalent brightness, luminance, dark/light classification, clone, equality, and random-color invariants."
+ - "Observable conversion and analysis boundary cases have direct, source-derived regression tests rather than a floating-point tolerance."
+ artifacts:
+ - path: "src/tinycolor/color.go"
+ provides: "The complete conversion, representation, compatibility-option state, clone/equality/random, and analysis facade over internal/color.Model"
+ contains: "func (c Color) ToRGBString() string"
+ - path: "src/tinycolor/color_test.go"
+ provides: "Source-derived output, analysis, equality, clone, and random-invariant regression tests"
+ contains: "Test"
+ key_links:
+ - from: "src/tinycolor/color.go"
+ to: "src/internal/color/model.go"
+ via: "Color.model RGBA, format, validity, and original state"
+ pattern: "c\\.model\\.(R|G|B|A|Format)"
+ - from: "src/tinycolor/color.go"
+ to: "src/internal/parser/names.go"
+ via: "reverse RGB-to-name lookup for ToName and format fallback"
+ pattern: "parser\\.NameForRGB"
+---
+
+
+Replace the Phase-1 snapshot formatter with one source-derived TinyColor facade
+for conversions, representations, and color analysis.
+
+Purpose: make all Phase-3 behavior available through `src/tinycolor` without
+duplicating the parser/model or adding a color dependency.
+Output: tested conversion, formatting, analysis, clone, equality, and random APIs
+ready for compatibility dispatch.
+
+
+
+@AGENT.md
+@DECISIONS.md
+@docs/ARCHITECTURE.md
+@.planning/phases/03-conversion-and-representation/03-RESEARCH.md
+@.planning/phases/03-conversion-and-representation/03-VALIDATION.md
+
+
+
+@src/internal/color/model.go
+@src/internal/parser/names.go
+@src/tinycolor/color.go
+@src/tinycolor/color_test.go
+
+
+From `src/internal/color/model.go`:
+```go
+type Model struct { R, G, B, A float64; Valid bool; Format Format; Original any }
+```
+
+From `src/tinycolor/color.go`:
+```go
+type Color struct{ model color.Model }
+func FromCompat(input any, fromRatio bool) (Color, error)
+type CompatOptions struct { Format string; GradientType bool }
+func FromCompatWithOptions(input any, fromRatio bool, options CompatOptions) (Color, error)
+func (c Color) RGB() map[string]any
+func (c Color) String() string
+```
+Keep `model` private. `CompatOptions` is compatibility-only state held by
+`Color`: a non-empty `Format` overrides the parser-detected format and
+`GradientType` affects only filter output. The helper first calls the existing
+parser-owned `FromCompat`, then attaches those options; it must not change
+parser/model ownership or mutate parser results.
+
+
+
+
+
+Implement source-equivalent conversions and every output representation
+src/tinycolor/color.go, src/tinycolor/color_test.go
+
+- `mod.js` lines 55-246 and 428-645
+- `test.js` lines 698-906 and 945-1125
+- `src/tinycolor/color.go`
+- `src/internal/color/model.go`
+- `src/internal/parser/names.go`
+
+
+- `ToRGB` rounds RGB channels with source `Math.round` behavior and retains alpha; `ToHSL` and `ToHSV` retain their fractional hue/saturation/lightness-or-value channels and alpha.
+- RGB, percentage RGB, HSL, HSV, raw `toHex`/`toHex8`, string hex/hex3/hex6/hex4/hex8, name, filter, and generic string output match source casing, separators, alpha rounding, RGBA/ARGB ordering, and fallback semantics.
+- `String()` takes the omitted-format path. Source `formatSet = !!format` means a missing format and `""` are both unset; only a non-empty requested format is explicit, including unknown/name fallback.
+
+
+In `src/tinycolor/color.go`, replace the Phase-1-only `String` branch tree with
+one set of small private source primitives (RGB-to-HSL/HSV, source-style channel
+rounding, decimal-alpha hex, RGB/RGBA/ARGB hex, and formatting dispatch) and
+public explicit methods for rounded RGB, fractional HSL/HSV, percentage RGB,
+raw and string hex/hex8, name, filter, and generic string output. Reuse
+`Color.model` and `parser.NameForRGB`; do not alter `src/internal/color` or
+`src/internal/parser`, add dependencies, or reparse rendered strings. Define
+`CompatOptions { Format string; GradientType bool }` and
+`FromCompatWithOptions(input, fromRatio, options)`: it must call `FromCompat`
+first, then store a non-empty format override and the gradient flag on `Color`.
+This is the sole compatibility-only construction path C will call; parser/model
+remain owned by A. Implement the source contract exactly: lower-case hex,
+`toHex8` RGBA versus `toFilter` ARGB, optional 3/4-character compression,
+`ToName` as `(string, bool)` with alpha-zero `transparent`, and `ToString`
+fallback using JavaScript truthiness (`formatSet = !!format`, so missing and
+`""` are both unset). Make `ToFilter` consume a compatibility second-color
+argument only when source truthiness would consume it; null, false, zero, and
+empty string use the start color as the end color. Do not add a dynamic public
+map API. Add failing direct Go tests before implementation for rounded `ToRGB`,
+fractional `ToHSL`/`ToHSV`, opaque/alpha strings, percentage rounding, raw and
+string hex variants, named/unnamed/transparent behavior, missing versus empty
+versus non-empty format fallback, ARGB filter ordering, and source-truthy/falsy
+second colors. This implements FMT-01 and keeps each discovered source
+discrepancy as a named regression (QLT-02).
+
+
+`color.go` has one canonical formatter used by `String()` and explicit output
+methods; its `CompatOptions` construction helper stores source format/gradient
+state without changing parser/model code. Direct tests prove rounded RGB,
+fractional HSL/HSV, raw/string hex, missing/empty/non-empty format, and
+source-truthy filter boundary cases without a broad float tolerance.
+
+Set-Location src; go test ./tinycolor -run 'Test.*(Output|String|Hex|Filter|Conversion|Name)' -count=1; go vet ./...
+All supported Phase-3 representation methods compile and their direct source-derived output tests pass exactly.
+
+
+
+Implement and test analysis, clone, equality, and random invariants
+src/tinycolor/color.go, src/tinycolor/color_test.go
+
+- `mod.js` lines 55-83 and 636-654
+- `test.js` lines 87-114, 895-942, and 1105-1125
+- `src/tinycolor/color.go`
+- `src/tinycolor/color_test.go`
+
+
+- Brightness and luminance use source-rounded RGB values; `IsDark` uses the source threshold and `IsLight` is its inverse.
+- Clone returns independent value state, while equality follows TinyColor's formatted-RGB-string comparison and preserves falsy-input false behavior at the compatibility boundary.
+- Random produces only valid opaque colors with RGB channels in range; it is never compared cross-runtime for exact values.
+
+
+Add `Color` analysis methods for brightness, luminance, dark/light, cloning,
+source-like equality, and random-color creation using only the Go standard
+library (D-005). Make analysis consume the rounded RGB representation exactly
+as `mod.js` does. Equality must be defined through the same RGB string
+formatter rather than raw model floats; expose a typed public comparison and
+leave JavaScript falsy coercion for C's adapter boundary. Clone must not share
+mutable backing state. Add direct tests for black/white, the `#777`/`#888`
+dark-light boundary, known luminance endpoints, equality alpha-rounding cases
+(`#ff000066`/`.4`, `#f009`/`.6`), unequal colors, clone independence, and
+random range/validity/alpha invariants. Do not invent a seedable API or claim
+exact JavaScript/Go random parity; name any deterministic mismatch with a
+reproducer for C's corpus/report workflow (QLT-02).
+
+
+Direct tests show correct source-derived analysis/equality/clone behavior and
+random tests assert only validity, alpha, and RGB bounds.
+
+Set-Location src; go test ./tinycolor -run 'Test.*(Brightness|Luminance|Dark|Light|Equal|Clone|Random)' -count=1; go test ./...; go vet ./...
+Analysis, clone, equality, and random APIs pass focused and full Go tests with no cross-runtime random equality assertion.
+
+
+
+
+Run `Set-Location src; go test ./...; go vet ./...`. Inspect the diff to ensure
+only `src/tinycolor` is changed: Phase 3 must consume the parser/model contract,
+not move conversion logic into it. The following plan supplies exact Node/Go
+differential evidence and QLT-02 records for this facade.
+
+
+
+- All FMT-01 representation and analysis methods are callable through `tinycolor.Color`.
+- Source formatting quirks are asserted as exact strings, not hidden by tolerance.
+- B's typed facade is ready for C to dispatch without changing model/parser files.
+
+
+
diff --git a/.planning/phases/03-conversion-and-representation/03-01-SUMMARY.md b/.planning/phases/03-conversion-and-representation/03-01-SUMMARY.md
new file mode 100644
index 00000000..e760aa57
--- /dev/null
+++ b/.planning/phases/03-conversion-and-representation/03-01-SUMMARY.md
@@ -0,0 +1,7 @@
+# Plan 03-01 Summary
+
+Implemented the `tinycolor.Color` conversion and representation facade:
+RGB/percentage RGB, HSL/HSV, hex variants, names, filters, format fallback,
+brightness, luminance, clone, equality, and random invariants.
+
+Validation passed: `go test ./tinycolor`, `go test ./...`, and `go vet ./...`.
diff --git a/.planning/phases/03-conversion-and-representation/03-02-PLAN.md b/.planning/phases/03-conversion-and-representation/03-02-PLAN.md
new file mode 100644
index 00000000..48dac784
--- /dev/null
+++ b/.planning/phases/03-conversion-and-representation/03-02-PLAN.md
@@ -0,0 +1,208 @@
+---
+phase: 3
+plan: 2
+type: execute
+subsystem: conversion-compatibility-evidence
+owner: C-mrashis
+tags: [compatibility, jsonl, differential-testing, tinycolor]
+wave: 2
+depends_on: [03-01]
+files_modified:
+ - compat/js-runner.mjs
+ - compat/run.mjs
+ - compat/cases/conversion.jsonl
+ - src/cmd/tinycolor-compat/main.go
+ - src/internal/compat/protocol.go
+ - src/internal/compat/protocol_test.go
+ - tests/port/adapter.test.mjs
+ - COMPATIBILITY.md
+autonomous: true
+requirements: [FMT-01, QLT-02]
+must_haves:
+ truths:
+ - "Every deterministic Phase-3 output and analysis operation has an independently runnable Node/Go JSONL reproducer that compares exactly."
+ - "Compatibility requests preserve source `!!format` semantics (missing and `\"\"` unset) and can pass source constructor/filter options needed for format, gradient, and truthy second-color behavior."
+ - "A successful JSONL result of `false`, including `toName` for a non-name or partial alpha, is encoded as `result:false` rather than dropped by Go JSON omission."
+ - "Any mismatch report retains the request, JavaScript result, Go result, operation, suspected package, and owner B or C; random is documented as invariant-only."
+ artifacts:
+ - path: "compat/cases/conversion.jsonl"
+ provides: "Exact FMT-01 conversion, format, analysis, clone, and equality differential corpus"
+ min_lines: 30
+ - path: "compat/js-runner.mjs"
+ provides: "Thin local-mod.js dispatch for Phase-3 compatibility operations"
+ - path: "src/cmd/tinycolor-compat/main.go"
+ provides: "Thin Go dispatch to the Phase-3 tinycolor facade"
+ - path: "src/internal/compat/protocol.go"
+ provides: "Presence-aware exclusive result/error JSONL encoding"
+ - path: "COMPATIBILITY.md"
+ provides: "Phase-3 evidence and documented random limitation or complete mismatch records"
+ key_links:
+ - from: "compat/cases/conversion.jsonl"
+ to: "compat/run.mjs"
+ via: "one JSONL request per source-derived case"
+ pattern: "conversion\\.jsonl"
+ - from: "compat/js-runner.mjs"
+ to: "mod.js"
+ via: "one tinycolor instance and named source method per request"
+ pattern: "tinycolor"
+ - from: "src/cmd/tinycolor-compat/main.go"
+ to: "src/tinycolor/color.go"
+ via: "thin operation dispatch to public Color methods"
+ pattern: "tinycolor\\."
+---
+
+
+Expose B's tested Phase-3 facade through the existing JSONL protocol and publish
+exact, reproducible Node-to-Go evidence for conversion and representation.
+
+Purpose: prove FMT-01 at the compatibility boundary while making every mismatch
+actionable under QLT-02.
+Output: adapter operations, protocol tests, fixed corpus, report ownership, and
+compatibility-matrix evidence.
+
+
+
+@AGENT.md
+@DECISIONS.md
+@docs/TEAM-OWNERSHIP.md
+@.planning/phases/03-conversion-and-representation/03-RESEARCH.md
+@.planning/phases/03-conversion-and-representation/03-VALIDATION.md
+@.planning/phases/03-conversion-and-representation/03-01-SUMMARY.md
+
+
+
+@compat/js-runner.mjs
+@compat/run.mjs
+@src/cmd/tinycolor-compat/main.go
+@src/internal/compat/protocol.go
+@tests/port/adapter.test.mjs
+@COMPATIBILITY.md
+
+
+Existing request contract from `src/internal/compat/protocol.go`:
+```go
+type Request struct { ID string; Operation string; Input any; Args any }
+```
+
+Preserve `inspect`, `string`, and `fromRatio` request meanings. Add only named
+Phase-3 operations (`output`, `analysis`, `equals`, `clone`, and
+`randomInvariant`) with backwards-compatible `args`; `args.options` carries
+source constructor options and `args.method` names a whitelisted source method.
+Decode options into B's concrete `tinycolor.CompatOptions` and construct through
+`tinycolor.FromCompatWithOptions`; never duplicate parser or conversion math.
+`Response` needs presence-aware custom encoding (for example, a private
+`hasResult` flag plus custom `MarshalJSON`) so `Success(id, false)` emits
+`{"id":"...","result":false}` while failures emit only `error`.
+
+
+
+
+
+Extend both adapters with narrow Phase-3 operation dispatch
+compat/js-runner.mjs, src/cmd/tinycolor-compat/main.go, src/internal/compat/protocol.go, src/internal/compat/protocol_test.go, tests/port/adapter.test.mjs
+
+- `compat/js-runner.mjs`
+- `src/cmd/tinycolor-compat/main.go`
+- `src/internal/compat/protocol.go`
+- `src/internal/compat/protocol_test.go`
+- `tests/port/adapter.test.mjs`
+- `src/tinycolor/color.go`
+- `mod.js` lines 55-246 and 636-654
+
+
+- `output` dispatches one whitelisted output method, including raw `toHex` and `toHex8`; missing and `""` format remain unset because source uses `!!format`, while non-empty format is explicit.
+- `toFilter` consumes `secondColor` only when JavaScript source truthiness would do so; missing/null/false/0/`""` use the start color for both ends.
+- `analysis`, `equals`, and `clone` return source-shaped JSON results; unsupported methods/options fail through the existing exclusive result-or-error protocol.
+- A Go successful result `false` serializes as a present `result:false`; error responses contain no result key, and adapter coverage includes a `toName` false case.
+- `randomInvariant` returns only JSON-safe invariant facts, never a random color for exact Node/Go comparison.
+
+
+Add matching thin dispatch in `compat/js-runner.mjs` and
+`src/cmd/tinycolor-compat/main.go` for `output`, `analysis`, `equals`, `clone`,
+and `randomInvariant`. Keep all existing operations byte-for-byte compatible.
+For new construction-based requests, accept a backwards-compatible nested
+`args.options` object and map it to `tinycolor.CompatOptions{Format,
+GradientType}` through `tinycolor.FromCompatWithOptions`, which owns that state
+after parser construction. The Node adapter passes the same options to the
+source constructor. Whitelist operation/method names including raw `toHex` and
+`toHex8`, reject unsupported input with the established JSONL error shape, and
+do not add conversion logic to either runner. Preserve source false/string
+results in JSON for `toName`. Apply JavaScript source semantics, not field
+presence semantics: `formatSet = !!format`, so both missing and `""` are unset;
+`secondColor` is used only when truthy. In `src/internal/compat/protocol.go`,
+replace `omitempty`-dependent response output with presence-aware response
+encoding: `Success(id, false)` must output `result:false`, `Success` and
+`Failure` must remain mutually exclusive, and malformed/unsupported requests
+retain the existing shape. First add `protocol_test.go` checks for false, true,
+string, and failure responses plus adapter tests for `toName` false, missing and
+empty formats, raw hex methods, second-color truthiness, and invalid
+operation/method coverage; then implement. This is C-owned compatibility work
+and routes FMT-01 behavior to B's facade while preserving complete QLT-02
+reproducers.
+
+
+Both runners support the same named Phase-3 operations and return identical
+JSON shapes for adapter tests; `result:false` remains present and exclusive of
+errors, and no runner contains color conversion formulas.
+
+node tests/port/adapter.test.mjs; Set-Location src; go test ./cmd/tinycolor-compat ./internal/compat ./tinycolor; Set-Location ..; node compat/run.mjs compat/cases/smoke.jsonl; node compat/run.mjs compat/cases/parser.jsonl
+New adapter protocol tests pass and all pre-Phase-3 JSONL corpora remain zero-mismatch compatible.
+
+
+
+Publish conversion corpus, exact report ownership, and Phase-3 evidence
+compat/cases/conversion.jsonl, compat/run.mjs, COMPATIBILITY.md
+
+- `test.js` lines 87-114, 698-942, 945-1125, and 1371-1398
+- `compat/cases/parser.jsonl`
+- `compat/run.mjs`
+- `COMPATIBILITY.md`
+- `docs/TEAM-OWNERSHIP.md`
+
+
+Create `compat/cases/conversion.jsonl` with independently runnable,
+source-derived deterministic rows for every `to*` representation: rounded RGB,
+percentage RGB, fractional HSL/HSV objects, HSL/HSV strings, raw `toHex`/
+`toHex8`, string hex/hex3/hex6/hex8/hex4, name/transparent, generic `toString`
+fallback (opaque, alpha zero, alpha between zero and one, missing format, empty
+format, and non-empty explicit known and unknown formats), filters (missing,
+truthy, and falsy `secondColor`; alpha half; and gradient type),
+brightness/luminance/dark/light, clone snapshots, and equality including
+formatted-alpha cases and falsy inputs. Keep random out of
+the exact corpus; add a `randomInvariant` case only if its Node and Go result
+is the identical invariant record, never generated channel values. Update
+`compat/run.mjs` so non-protocol Phase-3 mismatches are assigned owner `B`
+(`src/tinycolor`) and protocol/schema/report failures owner `C` (`compat`),
+while retaining the complete request, both results, operation, owner, and
+suspected package in every emitted record. Run the corpus and update
+`COMPATIBILITY.md` with the exact command, case count, zero-mismatch result or
+each complete unresolved mismatch record, plus the invariant-only random
+limitation. Do not edit `mod.js`, `test.js`, or any parser/model file.
+
+
+The conversion corpus exercises every deterministic FMT-01 output/analysis
+surface, `compat/run.mjs` prints complete owner-correct mismatch records, and
+the matrix records reproducible Phase-3 evidence without claiming exact random
+parity.
+
+Set-Location src; go test ./...; go vet ./...; Set-Location ..; node tests/port/adapter.test.mjs; node compat/run.mjs compat/cases/smoke.jsonl; node compat/run.mjs compat/cases/parser-hex-rgb.jsonl; node compat/run.mjs compat/cases/parser.jsonl; node compat/run.mjs compat/cases/conversion.jsonl; git diff --check; git diff -- mod.js test.js tinycolor.js
+All fixed Phase-1/2/3 corpora exit 0, Phase-3 evidence states its exact count, and every possible mismatch has a complete B-or-C-owned reproducer.
+
+
+
+
+Run the full Phase-3 command from `03-VALIDATION.md`. Confirm `conversion.jsonl`
+is fixed and deterministic, all JavaScript oracle files remain unchanged, and
+random is validated only through invariant records. Do not claim an upstream
+Deno test pass while Deno is unavailable.
+
+
+
+- FMT-01 has exact JSONL differential coverage for every deterministic Phase-3 operation.
+- QLT-02 mismatch output is complete, reproducible, and assigned to B or C.
+- Existing Phase-1/2 protocol and corpora still pass unchanged.
+
+
+
diff --git a/.planning/phases/03-conversion-and-representation/03-02-SUMMARY.md b/.planning/phases/03-conversion-and-representation/03-02-SUMMARY.md
new file mode 100644
index 00000000..2a7a5be5
--- /dev/null
+++ b/.planning/phases/03-conversion-and-representation/03-02-SUMMARY.md
@@ -0,0 +1,7 @@
+# Plan 03-02 Summary
+
+Added JSONL operations and conversion evidence, including a presence-aware
+protocol that preserves successful `result: false` values. The fixed conversion
+corpus passed 35/35 with zero mismatches.
+
+The full Phase 1–3 gate passed with all oracle files unchanged.
diff --git a/.planning/phases/03-conversion-and-representation/03-RESEARCH.md b/.planning/phases/03-conversion-and-representation/03-RESEARCH.md
new file mode 100644
index 00000000..62d3f7aa
--- /dev/null
+++ b/.planning/phases/03-conversion-and-representation/03-RESEARCH.md
@@ -0,0 +1,369 @@
+# Phase 3: Conversion and Representation - Research
+
+**Researched:** 2026-08-01
+**Domain:** TinyColor JavaScript-to-Go conversion, representation, and analysis compatibility
+**Confidence:** HIGH
+
+## User Constraints
+
+No `03-CONTEXT.md` exists. The phase remains constrained by the roadmap, requirements, `AGENT.md`, and the immutable local TinyColor oracle.
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|---|---|---|
+| FMT-01 | Reproduce color conversion and every documented output representation. | Map source output/analysis methods from `mod.js` and add operation-level differential cases for every observable result. |
+| QLT-02 | Record every mismatch with complete reproducer and owner. | Keep every output case as one JSONL request, let `compat/run.mjs` emit the request plus both results, and assign output/conversion mismatches to B (`src/tinycolor`) or protocol mismatches to C (`compat`). |
+
+
+## Project Constraints (from AGENT.md)
+
+- `mod.js`, `test.js`, `tinycolor.js`, `npm/`, `dist/`, and `demo/` are immutable JavaScript oracle material; do not change them to obtain parity.
+- Implement port behavior only under `src/`; put adapters, generated fixtures, and mismatch reports under `compat/`.
+- Preserve TinyColor quirks: precision until observable rounding, alpha normalization, detected-format fallback, invalid-as-black output, and permissive source behavior. Do not introduce a broad floating-point tolerance.
+- Keep the public Go API explicit and idiomatic, but preserve dynamic source quirks at the JSON compatibility boundary.
+- B owns `src/tinycolor` conversion/formatting/analysis; C owns `compat`, corpus/schema, and mismatch reporting. Do not change A's parser/model contract unilaterally.
+- Required phase checks are `go test ./...`, `go vet ./...`, `node compat/js-runner.mjs = 0
+ if !formatSet && hasAlpha && isNonAlphaFormat(format) {
+ if format == "name" && c.model.A == 0 {
+ return c.ToNameString() // "transparent"
+ }
+ return c.ToRGBString()
+ }
+ // Dispatch rgb/prgb/hex/hex3/hex4/hex8/name/hsl/hsv.
+ // Unknown or unavailable name falls back to six-digit #hex.
+}
+```
+
+The public API should distinguish an omitted format from an explicitly supplied empty/unknown format. The compatibility adapter has that information from JSON request shape; a Go convenience `String()` can always call the omitted-format path.
+
+### Exact ARGB filter construction
+
+```go
+// Source: mod.js toFilter and rgbaToArgbHex (lines 163-189, 624-635).
+func (c Color) ToFilter(second *Color, gradientType bool) string {
+ start := "#" + c.argbHex()
+ end := start
+ if second != nil {
+ end = "#" + second.argbHex()
+ }
+ prefix := ""
+ if gradientType {
+ prefix = "GradientType = 1, "
+ }
+ return "progid:DXImageTransform.Microsoft.gradient(" + prefix +
+ "startColorstr=" + start + ",endColorstr=" + end + ")"
+}
+```
+
+### Differential fixture shape
+
+```json
+{"id":"fmt-hex8-half-alpha","operation":"output","input":"rgba(255, 0, 0, .5)","args":{"method":"toHex8String"}}
+{"id":"fmt-filter-second","operation":"output","input":"transparent","args":{"method":"toFilter","secondColor":"red"}}
+{"id":"analysis-brightness-threshold","operation":"analysis","input":"#777","args":{"method":"isDark"}}
+{"id":"equal-alpha-string","operation":"equals","input":"#ff000066","args":{"other":"rgba(255, 0, 0, .4)"}}
+```
+
+Each JSONL row is independently runnable through `node compat/run.mjs compat/cases/conversion.jsonl`, satisfying QLT-02's complete reproducer requirement.
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|---|---|---|---|
+| Phase-1 `Color.String` manually covered a small output subset | Phase 3 should make source-method equivalents the canonical conversion/formatting surface | This phase | Avoids divergence between adapter snapshots and public output methods. |
+| Fixed smoke/parser corpus only used `inspect`, `string`, and `fromRatio` | Operation-level output/analysis corpus | This phase | Makes FMT-01 and QLT-02 measurable for every deterministic behavior. |
+
+**Deprecated/outdated:** Do not retain the comment in `src/tinycolor/color.go` that calls `String` the complete output API; it explicitly says full output APIs are Phase-3 work.
+
+## Open Questions
+
+1. **What exact exported Go signatures should represent boolean-or-string `toName` and optional formatting flags?**
+ - What we know: the compatibility adapter may return JSON `false` or a string; the public Go API should be explicit per `AGENT.md`.
+ - What's unclear: whether the project prefers `(string, bool)` for name lookup and separate `ToString(format string)` / `String()` methods, or a compatibility-shaped `any` method.
+ - Recommendation: use idiomatic `(string, bool)` for typed `ToName`; preserve `false` only in the adapter. Use a small private formatting dispatcher so `String()` represents omitted format and `ToString(format)` represents explicit format.
+
+2. **How should normal constructor options be represented in the JSONL protocol?**
+ - What we know: source options affect `_format` and `_gradientType`; existing normal constructor operations cannot pass them, while Node `fromRatio` forwards `args`.
+ - What's unclear: the exact new `args` schema.
+ - Recommendation: C defines one backwards-compatible nested `args.options` object for new operations, tests it with `{format:"name"}` and `{gradientType:true}`, and adds a Go compatibility construction helper rather than changing parser state.
+
+3. **How far should random parity go?**
+ - What we know: source uses unseeded `Math.random`; exact cross-runtime outputs are not comparable.
+ - What's unclear: whether a user-facing seeded API is desired later.
+ - Recommendation: do not add a seeded public API in this phase. Document invariant-only parity in `COMPATIBILITY.md`; add seed injection only if a later requirement explicitly needs reproducible random output.
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|---|---|---:|---|---|
+| Node.js | local JavaScript oracle and differential runner | ✓ | v24.18.0 | — |
+| Go | port tests and Go JSONL runner | ✓ | go1.26.1 windows/amd64 | — |
+| Deno | untouched source test suite | ✗ | — | Use Node JSONL differential evidence locally; run `deno task test` once Deno is installed/CI provides it. |
+
+**Missing dependencies with no fallback:** None for Phase-3 implementation and differential validation. Deno remains required for the later source-suite gate, but is not needed to execute the existing local oracle adapter.
+
+**Missing dependencies with fallback:** Deno — Node plus `compat/run.mjs` provides local differential evidence but does not constitute a Deno source-suite pass.
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|---|---|
+| Framework | Go standard `testing` (Go 1.26.1); Node JSONL differential harness; upstream Deno tests unavailable locally |
+| Config file | `src/go.mod`; `deno.json` for immutable upstream suite |
+| Quick run command | `Set-Location src; go test ./tinycolor ./internal/...; Set-Location ..; node compat/run.mjs compat/cases/conversion.jsonl` |
+| Full suite command | `Set-Location src; go test ./...; go vet ./...; Set-Location ..; node tests/port/adapter.test.mjs; node compat/run.mjs compat/cases/smoke.jsonl; node compat/run.mjs compat/cases/parser-hex-rgb.jsonl; node compat/run.mjs compat/cases/parser.jsonl; node compat/run.mjs compat/cases/conversion.jsonl` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|---|---|---|---|---|
+| FMT-01 | RGB/percentage RGB/HSL/HSV object and string output; six/three/eight/four hex; name; generic fallback | Go unit + Node/Go differential | `node compat/run.mjs compat/cases/conversion.jsonl` | ❌ Wave 0 |
+| FMT-01 | legacy ARGB `toFilter`, optional second color, gradient type | Go unit + differential | `node compat/run.mjs compat/cases/conversion.jsonl` | ❌ Wave 0 |
+| FMT-01 | brightness, luminance, dark/light, clone, equality, random invariants | Go unit + deterministic differential except random | `Set-Location src; go test ./tinycolor; Set-Location ..; node compat/run.mjs compat/cases/conversion.jsonl` | ❌ Wave 0 |
+| QLT-02 | mismatch has request, JS result, Go result, operation, and owner | integration/report | `node compat/run.mjs compat/cases/conversion.jsonl` | ✅ driver; ❌ conversion corpus |
+
+### Sampling Rate
+
+- **Per task commit:** `Set-Location src; go test ./tinycolor ./internal/...; Set-Location ..; node compat/run.mjs compat/cases/conversion.jsonl`
+- **Per wave merge:** full suite command above.
+- **Phase gate:** full suite green, conversion corpus zero mismatches, and `COMPATIBILITY.md` records both the evidence and any non-deterministic-random limitation before `/gsd:verify-work`.
+
+### Wave 0 Gaps
+
+- [ ] `compat/cases/conversion.jsonl` — deterministic FMT-01 cases grouped by all output methods, alpha/fallback rules, filter options, analysis, clone, and equality.
+- [ ] `src/tinycolor/color_test.go` additions — direct source-derived output and analysis tests plus random invariant test.
+- [ ] `tests/port/adapter.test.mjs` additions — every newly dispatched adapter operation has success/error protocol coverage.
+- [ ] `src/cmd/tinycolor-compat/main.go` / `compat/js-runner.mjs` operation dispatch — adapter surface needed for the corpus.
+- [ ] `COMPATIBILITY.md` Phase-3 evidence row and any complete unresolved mismatch records, owned B or C.
+
+## Sources
+
+### Primary (HIGH confidence)
+
+- Local [mod.js](../../../mod.js) — constructor state and public output/analysis methods (lines 5-246); conversion/hex/equality/random helpers (lines 359-645); rounding/bounds helpers (lines 1058-1129).
+- Local [test.js](../../../test.js) — source expectations for clone/random (lines 87-114), output/alpha/name/analysis (lines 698-942), conversion round trips (lines 945-1103), equality (lines 1105-1125), and filters (lines 1371-1398).
+- Local [AGENT.md](../../../AGENT.md) — immutable oracle, compatibility, ownership, and verification constraints.
+- Local [docs/ARCHITECTURE.md](../../../docs/ARCHITECTURE.md) and [DECISIONS.md](../../../DECISIONS.md) — model/facade/adapter boundaries and standard-library-first decision.
+- Local [compat/js-runner.mjs](../../../compat/js-runner.mjs), [compat/run.mjs](../../../compat/run.mjs), and [src/cmd/tinycolor-compat/main.go](../../../src/cmd/tinycolor-compat/main.go) — current protocol and missing operation coverage.
+
+### Secondary (MEDIUM confidence)
+
+None. The checked-out source and its source tests are the selected behavior authority.
+
+### Tertiary (LOW confidence)
+
+None.
+
+## Metadata
+
+**Confidence breakdown:**
+
+- Standard stack: HIGH — repository decisions mandate standard library, local oracle, and JSONL harness.
+- Architecture: HIGH — direct inspection of the model/parser/facade/adapters and source implementation.
+- Pitfalls: HIGH — each is tied to a source branch or test assertion.
+
+**What might have been missed:** The immutable `test.js` is not runnable locally because Deno is unavailable, so its listed cases were inspected rather than executed. Add Phase-3 cases to the JSONL corpus now and run `deno task test` when Deno becomes available; do not claim that pass beforehand.
+
+**Research date:** 2026-08-01
+**Valid until:** Stable for this pinned local source; revisit if the selected oracle checkout changes.
diff --git a/.planning/phases/03-conversion-and-representation/03-VALIDATION.md b/.planning/phases/03-conversion-and-representation/03-VALIDATION.md
new file mode 100644
index 00000000..e306ed3a
--- /dev/null
+++ b/.planning/phases/03-conversion-and-representation/03-VALIDATION.md
@@ -0,0 +1,60 @@
+---
+phase: 3
+slug: conversion-and-representation
+status: ready
+nyquist_compliant: true
+wave_0_complete: false
+created: 2026-08-01
+---
+
+# Phase 3 — Validation Strategy
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Go standard `testing`; Node JSONL differential harness |
+| **Config file** | `src/go.mod` |
+| **Quick run command** | `Set-Location src; go test ./tinycolor ./internal/...; Set-Location ..; node compat/run.mjs compat/cases/conversion.jsonl` |
+| **Full suite command** | `Set-Location src; go test ./...; go vet ./...; Set-Location ..; node tests/port/adapter.test.mjs; node compat/run.mjs compat/cases/smoke.jsonl; node compat/run.mjs compat/cases/parser-hex-rgb.jsonl; node compat/run.mjs compat/cases/parser.jsonl; node compat/run.mjs compat/cases/conversion.jsonl` |
+| **Estimated runtime** | ~20 seconds |
+
+## Sampling Rate
+
+- **After every task commit:** Run the quick command.
+- **After every plan wave:** Run the full suite command.
+- **Before `$gsd-verify-work`:** Full suite must be green.
+- **Max feedback latency:** 30 seconds.
+
+## Per-Task Verification Map
+
+| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|-----------|-------------------|-------------|--------|
+| 03-01-01 | 01 | 1 | FMT-01, QLT-02 | direct Go unit | `Set-Location src; go test ./tinycolor -run 'Test.*(Output|String|Hex|Filter|Conversion|Name)' -count=1` | ✅ task creates tests | ⬜ pending |
+| 03-01-02 | 01 | 1 | FMT-01, QLT-02 | direct Go unit | `Set-Location src; go test ./tinycolor -run 'Test.*(Brightness|Luminance|Dark|Light|Equal|Clone|Random)' -count=1` | ✅ task creates tests | ⬜ pending |
+| 03-02-01 | 02 | 2 | FMT-01, QLT-02 | protocol + regression | `node tests/port/adapter.test.mjs; Set-Location src; go test ./internal/compat` | ✅ task extends tests | ⬜ pending |
+| 03-02-02 | 02 | 2 | FMT-01, QLT-02 | exact differential | `node compat/run.mjs compat/cases/conversion.jsonl` | ✅ task creates corpus | ⬜ pending |
+
+## Test-fixture sequencing
+
+No separate Wave 0 is required: Plan 03-01 creates and runs its direct Go
+regressions in Wave 1; Plan 03-02 then creates the adapter protocol coverage
+and differential corpus after that facade exists in Wave 2. The conversion
+corpus is not a prerequisite fixture for Plan 03-01 and must not be referenced
+as one.
+
+## Manual-Only Verifications
+
+None. Random output is verified by per-runtime invariants rather than false
+cross-runtime exact comparison.
+
+## Validation Sign-Off
+
+- [x] All tasks have automated verification or a Wave 0 dependency.
+- [x] Sampling continuity has no three-task validation gap.
+- [x] No Wave 0 is required; the conversion fixture is created by Wave 2.
+- [x] No watch-mode flags.
+- [x] Feedback latency is under 30 seconds.
+- [x] `nyquist_compliant: true` is set in frontmatter.
+
+**Approval:** ready 2026-08-01
diff --git a/.planning/phases/04-operations-and-palettes/04-01-PLAN.md b/.planning/phases/04-operations-and-palettes/04-01-PLAN.md
new file mode 100644
index 00000000..d1afd3c0
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-01-PLAN.md
@@ -0,0 +1,126 @@
+---
+phase: 4
+plan: 1
+type: tdd
+subsystem: operations-modifiers-mix
+owner: B-xthxr
+tags: [go, tinycolor, modifiers, mix, tdd]
+wave: 1
+depends_on: [03-01, 03-02]
+files_modified:
+ - src/tinycolor/color.go
+ - src/tinycolor/operations_test.go
+autonomous: true
+requirements: [OPS-01, QLT-02]
+must_haves:
+ truths:
+ - "Pointer modifiers mutate their receiver, preserve alpha and metadata, and return that same receiver."
+ - "Typed Mix returns a fresh interpolated color without mutating either input."
+ - "Typed operations use explicit numeric arguments; JavaScript omission and truthiness are not public Go behavior."
+ artifacts:
+ - path: "src/tinycolor/color.go"
+ provides: "Pointer modifier methods, source-style HSL/HSV-to-RGB helpers, and pure Mix"
+ contains: "func (c *Color) Lighten(amount float64) *Color"
+ - path: "src/tinycolor/operations_test.go"
+ provides: "Direct source-derived modifiers and Mix regressions"
+ contains: "TestModifiers"
+ key_links:
+ - from: "src/tinycolor/color.go"
+ to: "src/internal/color/model.go"
+ via: "Color.model channel and alpha updates"
+ pattern: "c\\.model\\.(R|G|B|A)"
+---
+
+
+Build B's typed operations foundation: mutable TinyColor modifiers and a pure
+Mix utility, with source arithmetic but no JavaScript argument coercion.
+
+Purpose: establish the public library contract that the next adapter plan can
+dispatch without putting color math or dynamic defaults in compatibility code.
+Output: source-derived Go tests and operations in `src/tinycolor`.
+
+
+
+@AGENT.md
+@.planning/phases/04-operations-and-palettes/04-RESEARCH.md
+@docs/superpowers/specs/2026-08-01-phase-4-operations-design.md
+@.planning/phases/04-operations-and-palettes/04-VALIDATION.md
+
+
+
+@mod.js lines 255-321, 655-710, and 771-790
+@test.js lines 2013-2078
+@src/tinycolor/color.go
+@src/internal/color/model.go
+
+
+Existing public substrate:
+```go
+type Color struct { model color.Model; gradientType bool }
+func (c Color) ToRGB() RGB
+func (c Color) ToHSL() HSL
+func (c Color) ToHSV() HSV
+```
+
+Add exactly these typed public contracts:
+```go
+func (c *Color) Lighten(amount float64) *Color
+func (c *Color) Darken(amount float64) *Color
+func (c *Color) Saturate(amount float64) *Color
+func (c *Color) Desaturate(amount float64) *Color
+func (c *Color) Greyscale() *Color
+func (c *Color) Brighten(amount float64) *Color
+func (c *Color) Spin(amount float64) *Color
+func Mix(first, second Color, amount float64) Color
+```
+These methods receive an explicit amount. Do not add optional, variadic, `any`,
+or static modifier APIs. The Phase 4 adapter alone supplies source defaults.
+
+
+
+
+
+Red-green-refactor pointer modifiers and pure Mix
+src/tinycolor/color.go, src/tinycolor/operations_test.go
+
+- Lighten/darken and saturate/desaturate adjust HSL lightness/saturation by `amount / 100`, clamp only that component to `[0,1]`, preserve alpha, and mutate the original `*Color`.
+- Greyscale is desaturate 100; brighten adds `Math.round(255 * amount / 100)` to each rounded RGB channel before channel clamping; spin wraps hue into `[0,360)` without a typed default.
+- Mix interpolates rounded source RGB and alpha as `(second-first)*amount/100+first`, returns a new value, and lets model normalization enforce source out-of-range behavior.
+- `Spin(math.NaN())` follows the source omitted-argument path: it changes the receiver's channels to black while retaining the receiver's validity, format, original input, alpha, and identity.
+
+
+RED: create `operations_test.go` with named tests for same-pointer return and
+visible receiver mutation (`red.Lighten(10)` becomes `#ff3333`), explicit zero
+no-ops, HSL clamp at both limits, brighten's pre-clamp rounding boundary,
+negative and over-360 spin wrapping, `math.NaN()` spin black-channel metadata retention, alpha preservation, greyscale, Mix default
+math supplied explicitly as 50, Mix zero, amount 90, transparent alpha, and
+out-of-range Mix. Run the focused command and confirm it fails only because the
+operations API is absent. GREEN: add local reciprocal HSL/HSV conversion and
+clamp helpers only in `color.go`, then implement the listed pointer methods and
+pure `Mix`; retain `model.Format`, `Original`, `Valid`, and `gradientType` while
+changing only operation-owned channels/alpha. Use the standard library only;
+do not modify parser/model or add dependencies. REFACTOR only after green to
+share conversion/update helpers without changing externally tested results.
+
+Make micro-commits in this exact order: `test(04-01): add failing modifier and
+mix regressions`, `feat(04-01): implement modifiers and mix`, and, only if a
+cleanup is needed, `refactor(04-01): share operation conversion helpers`.
+
+Set-Location src; go test ./tinycolor -run 'Test(Modifiers|Mix)' -count=1; go test ./...; go vet ./...
+All modifier and Mix direct tests pass; no file outside `src/tinycolor` changes, and the typed facade contains no JSON/default/coercion handling.
+
+
+
+
+Run the focused test while RED and again after every GREEN/REFACTOR commit. Then
+run the full Go command in `04-VALIDATION.md`; do not run or modify the oracle.
+
+
+
+- OPS-01 modifier mutation, bounds, hue wrapping, alpha, and Mix invariants are directly tested.
+- QLT-02 has named source-derived regressions ready for fixed adapter rows.
+
+
+
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-01-SUMMARY.md b/.planning/phases/04-operations-and-palettes/04-01-SUMMARY.md
new file mode 100644
index 00000000..f21a9d98
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-01-SUMMARY.md
@@ -0,0 +1,47 @@
+---
+phase: 4
+plan: 1
+subsystem: operations-modifiers-mix
+tags: [go, tinycolor, modifiers, mix, tdd]
+provides: [typed pointer modifiers, pure Mix, direct regressions]
+affects: [src/tinycolor]
+tech-stack: [go-standard-library]
+key-files:
+ created:
+ - src/tinycolor/operations_test.go
+ modified:
+ - src/tinycolor/color.go
+decisions:
+ - Typed modifiers require explicit numeric amounts; JavaScript defaults remain adapter work.
+ - Mix rounds source channels before interpolation and returns an independent Color value.
+---
+
+# Phase 4 Plan 1: Modifiers and Mix Summary
+
+Implemented mutable typed modifiers and a pure source-style `Mix` utility with
+direct regression coverage for mutation, bounds, hue wrapping, alpha,
+metadata retention, rounding, and out-of-range interpolation.
+
+## TDD Evidence
+
+- RED: `Set-Location src; go test ./tinycolor -run 'Test(Modifiers|Mix)' -count=1`
+ exited `1` because `Lighten`, `Darken`, `Saturate`, `Desaturate`,
+ `Brighten`, `Spin`, and `Mix` were undefined.
+- GREEN focused: `Set-Location 'R:\Code\TinyColor'; Set-Location src; go test ./tinycolor -run 'Test(Modifiers|Mix)' -count=1`
+ exited `0`.
+- Full validation: `Set-Location 'R:\Code\TinyColor'; Set-Location src; go test ./tinycolor -run 'Test(Modifiers|Mix)' -count=1; go test ./...; go vet ./...`
+ exited `0`.
+
+## Commits
+
+- `0899892` `test(04-01): add failing modifier and mix regressions`
+- `a514597` `feat(04-01): implement modifiers and mix`
+
+## Deviations from Plan
+
+None - plan executed as specified. The compatibility adapter and corpus remain
+unchanged for Plan 04-02.
+
+## Known Stubs
+
+None.
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-02-PLAN.md b/.planning/phases/04-operations-and-palettes/04-02-PLAN.md
new file mode 100644
index 00000000..22ecbe3f
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-02-PLAN.md
@@ -0,0 +1,123 @@
+---
+phase: 4
+plan: 2
+type: execute
+subsystem: operations-modifiers-mix-compatibility
+owner: C-mrashis
+tags: [compatibility, jsonl, modifiers, mix]
+wave: 2
+depends_on: [04-01]
+files_modified:
+ - compat/js-runner.mjs
+ - src/cmd/tinycolor-compat/main.go
+ - tests/port/adapter.test.mjs
+ - compat/run.mjs
+ - compat/cases/operations.jsonl
+autonomous: true
+requirements: [OPS-01, QLT-02]
+must_haves:
+ truths:
+ - "Modifier and Mix requests compare source and Go results exactly, including mutation identity snapshots."
+ - "Absent, zero, and null arguments follow JavaScript only in the adapters."
+ - "Every modifier and Mix corpus mismatch reports the fixed row's owner and suspected package."
+ artifacts:
+ - path: "compat/cases/operations.jsonl"
+ provides: "Deterministic modifier and Mix JSONL reproducers with owner and suspected-package metadata"
+ - path: "compat/run.mjs"
+ provides: "Mismatch records that preserve corpus ownership metadata"
+ - path: "src/cmd/tinycolor-compat/main.go"
+ provides: "Presence-aware dispatch to typed modifier and Mix APIs"
+ key_links:
+ - from: "compat/cases/operations.jsonl"
+ to: "compat/js-runner.mjs"
+ via: "modify and mix requests"
+ pattern: "operation.*(modify|mix)"
+---
+
+
+Prove modifier and Mix parity through thin, presence-aware compatibility
+dispatch and fixed deterministic corpus rows.
+
+Purpose: record every source quirk as a reproducer without leaking dynamic
+JavaScript behavior into B's public API.
+Output: matching Node/Go operations plus initial Phase 4 corpus.
+
+
+
+@AGENT.md
+@.planning/phases/04-operations-and-palettes/04-RESEARCH.md
+@.planning/phases/04-operations-and-palettes/04-01-SUMMARY.md
+@.planning/phases/04-operations-and-palettes/04-VALIDATION.md
+
+
+
+@mod.js lines 255-321, 655-710, and 771-790
+@compat/js-runner.mjs
+@src/cmd/tinycolor-compat/main.go
+@tests/port/adapter.test.mjs
+@compat/run.mjs
+
+
+Request shape stays `{"id","operation","input","args"}`. Add only:
+```json
+{"operation":"modify","args":{"method":"lighten","amount":0}}
+{"operation":"mix","args":{"other":"#000","amount":25}}
+```
+`modify` returns `{before,after,sameReceiver}`; `mix` returns one `Inspect`
+snapshot. In Go, decode `args` as a map and check key presence before decoding
+the value. Do not add formulas to either runner.
+
+
+
+
+
+Add presence-aware modifier and Mix adapter dispatch
+compat/js-runner.mjs, src/cmd/tinycolor-compat/main.go, tests/port/adapter.test.mjs
+
+- `modify` creates one source color, snapshots it before and after one whitelisted call, and proves the returned source instance is identical.
+- For lighten/brighten/darken/saturate/desaturate, source uses `amount === 0 ? 0 : amount || 10`; Mix uses the same rule with 50; Spin has no default, where omitted mutates the valid receiver to black channels while retaining metadata and JSON null is zero.
+
+
+RED: extend the Node adapter tests with successful `modify` and `mix` response
+shapes, omitted versus zero lighten, omitted versus null Spin, and unsupported
+method errors. Confirm they fail before dispatch exists. GREEN: add only
+whitelisted `modify` and `mix` branches in both runners. Node must use
+`Object.hasOwn(args, "amount")`; Go must use map presence, not a zero-value
+decode. The adapter selects defaults, forwards explicit numbers to B's typed
+methods, and serializes `before`, `after`, and `sameReceiver: true` for Go only
+after a pointer method call. Route omitted Spin through `Color.Spin(math.NaN())` so the pointer operation
+produces the source snapshot: black channels with the original valid/name/original
+metadata and same receiver; pass JSON null to typed zero. Mix parses `other`, calls `tinycolor.Mix`, and returns a snapshot.
+Keep existing operations unchanged and put no HSL/HSV or interpolation logic in
+the runners. Commit RED as `test(04-02): add modifier adapter failures`, then
+GREEN as `feat(04-02): dispatch modifiers and mix`.
+
+node tests/port/adapter.test.mjs; Set-Location src; go test ./cmd/tinycolor-compat ./tinycolor -count=1; Set-Location ..; node compat/run.mjs compat/cases/smoke.jsonl
+Adapters have matching modify/mix JSON shapes and preserve all documented defaults exclusively at the boundary.
+
+
+
+Publish exact modifier and Mix corpus rows
+compat/cases/operations.jsonl, compat/run.mjs
+
+Create the initial deterministic Phase 4 corpus using the operation shapes
+above. Include every modifier with normal, explicit-zero, clamp, and alpha
+coverage; Spin negative, over-360, omitted, and null; Greyscale; and Mix
+omitted 50, zero, 90, transparent alpha, and out-of-range amounts. Each row
+needs a stable unique id, one observable source behavior, `owner` (`B-xthxr` or
+`C-mrashis`), and `suspectedPackage`. Update `compat/run.mjs` so a mismatch
+reports those fixed row fields along with the request and both responses; use
+the existing protocol fallback only for legacy rows without metadata. Run the
+corpus after each addition batch; retain every mismatch row unchanged as its
+complete QLT-02 reproducer. Commit only this evidence as `test(04-02): add modifier and
+mix differential corpus`.
+
+node compat/run.mjs compat/cases/operations.jsonl; git diff --check -- compat/cases/operations.jsonl compat/run.mjs
+The corpus contains reproducible modifier and Mix cases and reports zero mismatches or complete, row-owned harness mismatch records.
+
+
+
+Run the focused checks in `04-VALIDATION.md`; confirm source default/coercion text occurs only in adapter code and corpus cases.
+
+
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-02-SUMMARY.md b/.planning/phases/04-operations-and-palettes/04-02-SUMMARY.md
new file mode 100644
index 00000000..c33233da
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-02-SUMMARY.md
@@ -0,0 +1,58 @@
+---
+phase: 4
+plan: 2
+subsystem: operations-modifiers-mix-compatibility
+tags: [compatibility, jsonl, modifiers, mix]
+provides: [presence-aware adapter dispatch, deterministic modifier-mix corpus]
+affects: [compat, src/cmd/tinycolor-compat]
+key-files:
+ created:
+ - compat/cases/operations.jsonl
+ modified:
+ - compat/js-runner.mjs
+ - compat/run.mjs
+ - src/cmd/tinycolor-compat/main.go
+ - tests/port/adapter.test.mjs
+decisions:
+ - JavaScript omitted, zero, and null amount behavior is confined to the compatibility adapters.
+ - Operations corpus mismatches preserve fixed owner and suspected-package metadata.
+---
+
+# Phase 4 Plan 2: Modifier and Mix Compatibility Summary
+
+Added thin Node and Go dispatch for pointer-mutating modifiers and pure Mix,
+with deterministic owner-tagged operations reproducers.
+
+## TDD Evidence
+
+- RED: `node tests/port/adapter.test.mjs` exited `1` because `modify` returned
+ `unsupported operation` instead of the required mutation snapshot.
+- GREEN: `node tests/port/adapter.test.mjs` exited `0` after dispatch was added.
+
+## Validation
+
+- `node tests/port/adapter.test.mjs`: exited `0`.
+- `Set-Location src; go test ./cmd/tinycolor-compat ./tinycolor -count=1`: exited `0`.
+- `node compat/run.mjs compat/cases/smoke.jsonl`: `9` cases passed, `0` mismatches.
+- `node compat/run.mjs compat/cases/operations.jsonl`: `32` cases, `26` passed,
+ `6` Mix metadata mismatches before the follow-up fix.
+- `git diff --check`: exited `0`.
+
+## Differential Evidence
+
+The six Mix mismatch records revealed that the Go value dropped TinyColor's
+observable raw interpolated RGBA input. Follow-up commit `d2f22c6`
+(`fix(04-02): preserve mix original metadata`) restored that metadata. The
+final Phase 4 parity gate passed all `69/69` operations rows with zero mismatches.
+
+## Commits
+
+- `d71ea12` `test(04-02): add modifier adapter failures`
+- `b1d0798` `feat(04-02): dispatch modifiers and mix`
+- `b322d2c` `test(04-02): add modifier and mix differential corpus`
+
+## Deviations from Plan
+
+The corpus correctly exposed a shared-library defect after the planned adapter
+work. It was fixed in a separate focused micro-commit rather than masked in
+the harness.
diff --git a/.planning/phases/04-operations-and-palettes/04-03-PLAN.md b/.planning/phases/04-operations-and-palettes/04-03-PLAN.md
new file mode 100644
index 00000000..0257457f
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-03-PLAN.md
@@ -0,0 +1,79 @@
+---
+phase: 4
+plan: 3
+type: tdd
+subsystem: readability-library
+owner: B-xthxr
+tags: [go, tinycolor, wcag, readability, tdd]
+wave: 3
+depends_on: [04-02]
+files_modified: [src/tinycolor/color.go, src/tinycolor/operations_test.go]
+autonomous: true
+requirements: [OPS-01, QLT-02]
+must_haves:
+ truths:
+ - "Readability and WCAG checks use exact source thresholds and inclusive comparisons."
+ - "MostReadable preserves first ties, fallback recursion, and an empty candidate result."
+ artifacts:
+ - path: "src/tinycolor/color.go"
+ provides: "Typed WCAG2Options, Readability, IsReadable, and nullable MostReadable"
+ contains: "func MostReadable"
+ key_links:
+ - from: "src/tinycolor/color.go"
+ to: "src/tinycolor/color.go"
+ via: "Readability uses existing rounded-channel Luminance"
+ pattern: "Luminance"
+---
+
+Implement B's source-equivalent WCAG operations with a typed options contract and direct regressions.
+@AGENT.md
+@.planning/phases/04-operations-and-palettes/04-RESEARCH.md
+@docs/superpowers/specs/2026-08-01-phase-4-operations-design.md
+@.planning/phases/04-operations-and-palettes/04-VALIDATION.md
+
+@mod.js lines 797-871 and 1261-1277
+@test.js lines 1100-1370
+@src/tinycolor/color.go
+
+
+Add a typed value contract, with normalization private to `tinycolor`:
+```go
+type WCAG2Options struct { Level string; Size string; IncludeFallbackColors bool }
+func Readability(first, second Color) float64
+func IsReadable(first, second Color, options WCAG2Options) bool
+func MostReadable(base Color, candidates []Color, options WCAG2Options) (Color, bool)
+```
+The bool is false for the source null candidate result. Adapter conversion,
+including dynamic truthiness and non-string option inputs, is not public API.
+
+
+
+
+Red-green-refactor exact readability and selection operations
+src/tinycolor/color.go, src/tinycolor/operations_test.go
+
+- Readability is raw `(max(luminance)+.05)/(min(luminance)+.05)`; no rounding or epsilon.
+- Normalized options support AA/AAA and small/large thresholds 4.5, 3, 7, and 4.5 inclusively.
+- MostReadable uses strict-greater selection, returns the first tie, conditionally recurses only to white/black, and can return no color for empty candidates.
+
+
+RED: add direct tests for ratios 1 and 21, all four threshold boundaries,
+mixed-case typed options, invalid typed option fallback, first-candidate tie,
+unreadable candidate fallback to `#fff/#000`, fallback disabled, and empty
+candidates with both fallback flags. Confirm failure before adding APIs. GREEN:
+implement a private options normalizer, `Readability`, `IsReadable`, and
+`MostReadable` against `Color.Luminance()`. Preserve exact raw float results
+and source scan order; keep fallback local and never mutate caller slices or
+options. Empty candidates must yield `(Color{}, false)` rather than panic or an
+invented color. Do not add a WCAG package, parser changes, JSON coercion, or a
+float tolerance. Commit `test(04-03): add failing readability regressions`,
+then `feat(04-03): implement readability operations`; refactor only after green.
+
+Set-Location src; go test ./tinycolor -run 'Test(Readability|IsReadable|MostReadable)' -count=1; go test ./...; go vet ./...
+All typed WCAG and selection regressions pass exactly, including nullable empty results.
+
+
+Run the focused test before and after implementation, then the full Go gate in `04-VALIDATION.md`.
+- OPS-01 readability is source-equivalent in the library.
+- QLT-02 has named direct tests for every threshold and selection edge.
+
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-03-SUMMARY.md b/.planning/phases/04-operations-and-palettes/04-03-SUMMARY.md
new file mode 100644
index 00000000..e6a76eae
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-03-SUMMARY.md
@@ -0,0 +1,16 @@
+---
+phase: 4
+plan: 3
+subsystem: readability-library
+provides: [typed WCAG readability and most-readable selection]
+commits: [8609f21, 821ed05]
+---
+
+# Phase 4 Plan 3: Readability Library Summary
+
+Implemented typed `WCAG2Options`, `Readability`, `IsReadable`, and nullable
+`MostReadable`. Direct tests cover raw ratios, inclusive WCAG thresholds,
+case/invalid normalization, first ties, fallback colors, and empty candidates.
+
+The red test commit was `8609f21`; implementation was `821ed05`. Focused Go
+tests, `go test ./...`, and `go vet ./...` passed.
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-04-PLAN.md b/.planning/phases/04-operations-and-palettes/04-04-PLAN.md
new file mode 100644
index 00000000..e519a1d2
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-04-PLAN.md
@@ -0,0 +1,77 @@
+---
+phase: 4
+plan: 4
+type: execute
+subsystem: readability-compatibility
+owner: C-mrashis
+tags: [compatibility, jsonl, wcag, readability]
+wave: 4
+depends_on: [04-03]
+files_modified: [compat/js-runner.mjs, src/cmd/tinycolor-compat/main.go, tests/port/adapter.test.mjs, compat/cases/operations.jsonl]
+autonomous: true
+requirements: [OPS-01, QLT-02]
+must_haves:
+ truths:
+ - "Readability adapter requests preserve source WCAG defaults/coercion and null results."
+ - "Every WCAG selection and fallback edge is an exact JSONL reproducer."
+ artifacts:
+ - path: "compat/cases/operations.jsonl"
+ provides: "Readability, isReadable, and mostReadable corpus rows"
+ key_links:
+ - from: "src/cmd/tinycolor-compat/main.go"
+ to: "src/tinycolor/color.go"
+ via: "thin dispatch to Readability, IsReadable, and MostReadable"
+ pattern: "tinycolor\\.(Readability|IsReadable|MostReadable)"
+---
+
+Expose B's readability APIs through source-shaped compatibility requests and exact corpus proof.
+@AGENT.md
+@.planning/phases/04-operations-and-palettes/04-RESEARCH.md
+@.planning/phases/04-operations-and-palettes/04-03-SUMMARY.md
+@.planning/phases/04-operations-and-palettes/04-VALIDATION.md
+@mod.js lines 797-871 and 1261-1277
+@compat/js-runner.mjs
+@src/cmd/tinycolor-compat/main.go
+@compat/cases/operations.jsonl
+
+
+Add request operations:
+```json
+{"operation":"readability","args":{"other":"#fff"}}
+{"operation":"isReadable","args":{"other":"#fff","options":{"level":"AAA","size":"large"}}}
+{"operation":"mostReadable","args":{"candidates":["#111","#222"],"options":{"includeFallbackColors":true}}}
+```
+`mostReadable` serializes `null` when B returns `ok == false`; adapters map
+dynamic options to `WCAG2Options` and never implement contrast formulas.
+
+
+
+Add source-coercing WCAG adapter dispatch
+compat/js-runner.mjs, src/cmd/tinycolor-compat/main.go, tests/port/adapter.test.mjs
+
+RED: add adapter tests for raw numeric readability, default empty options,
+mixed-case strings, invalid/null/false/zero/empty WCAG fields, strict tie
+order, fallback true/false, and empty candidates serializing null. GREEN: add
+only three dispatch branches to both runners. Node calls the source methods;
+Go maps presence-aware dynamic options so JavaScript `(value || default)` and
+case normalization happen here, then calls B's typed functions. For Go's
+`MostReadable`, serialize `null` for `ok == false`; do not construct a zero
+color snapshot. Preserve source truthiness for `includeFallbackColors` and use
+no WCAG math in either runner. Commit RED as `test(04-04): add readability
+adapter failures` and GREEN as `feat(04-04): dispatch readability operations`.
+
+node tests/port/adapter.test.mjs; Set-Location src; go test ./cmd/tinycolor-compat ./tinycolor -count=1; Set-Location ..; node compat/run.mjs compat/cases/operations.jsonl
+Both runners return matching readability types and source-shaped null/fallback behavior.
+
+
+Add WCAG differential reproducers
+compat/cases/operations.jsonl
+Add deterministic rows for ratios 1, 1.1121078324840545, and 21; all four threshold families; defaults and invalid/mixed-case options; first-tie selection; fallback enabled/disabled; and empty candidates with true and false fallback. Keep one behavior per id, append to the existing Phase 4 corpus, and commit `test(04-04): add readability differential corpus`.
+node compat/run.mjs compat/cases/operations.jsonl; git diff --check -- compat/cases/operations.jsonl
+All readability rows pass exactly or retain complete harness mismatch records for QLT-02.
+
+
+Execute both focused commands in `04-VALIDATION.md`; inspect adapter diffs to confirm they only decode/coerce/dispatch/serialize.
+- OPS-01 WCAG behavior is verified at the Node-to-Go boundary.
+- QLT-02 captures every dynamic-options and nullable-result edge.
+
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-04-SUMMARY.md b/.planning/phases/04-operations-and-palettes/04-04-SUMMARY.md
new file mode 100644
index 00000000..b5a31298
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-04-SUMMARY.md
@@ -0,0 +1,16 @@
+---
+phase: 4
+plan: 4
+subsystem: readability-compatibility
+provides: [readability JSONL dispatch and WCAG differential evidence]
+commits: [0c72f8c, 817ad71, 469b9f4]
+---
+
+# Phase 4 Plan 4: Readability Compatibility Summary
+
+Added Node and Go dispatch for `readability`, `isReadable`, and
+`mostReadable`, preserving adapter-owned option defaults, truthiness, and null
+results. The fixed corpus covers source ratios, WCAG families, coercion,
+first ties, fallback selection, and empty candidates.
+
+Adapter tests, Go tests, and the operations corpus passed in the final gate.
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-05-PLAN.md b/.planning/phases/04-operations-and-palettes/04-05-PLAN.md
new file mode 100644
index 00000000..9d6a7096
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-05-PLAN.md
@@ -0,0 +1,79 @@
+---
+phase: 4
+plan: 5
+type: tdd
+subsystem: palettes-library
+owner: B-xthxr
+tags: [go, tinycolor, palettes, tdd]
+wave: 5
+depends_on: [04-04]
+files_modified: [src/tinycolor/color.go, src/tinycolor/operations_test.go]
+autonomous: true
+requirements: [OPS-01, QLT-02]
+must_haves:
+ truths:
+ - "Palette values are independent of their input and preserve source order, defaults, alpha, and hue wrapping."
+ - "Polyad remains private; callers only receive Complement, SplitComplement, Triad, Tetrad, Analogous, and Monochromatic."
+ artifacts:
+ - path: "src/tinycolor/color.go"
+ provides: "Typed palette methods and private polyad helper"
+ contains: "func (c Color) Analogous"
+ key_links:
+ - from: "src/tinycolor/color.go"
+ to: "src/tinycolor/color.go"
+ via: "palette methods use source-compatible HSL/HSV helpers"
+ pattern: "(rgbToHSL|rgbToHSV)"
+---
+
+Implement B's pure palette methods, preserving TinyColor order and typed explicit-argument semantics.
+@AGENT.md
+@.planning/phases/04-operations-and-palettes/04-RESEARCH.md
+@docs/superpowers/specs/2026-08-01-phase-4-operations-design.md
+@.planning/phases/04-operations-and-palettes/04-VALIDATION.md
+@mod.js lines 713-769
+@test.js lines 2080-2150
+@src/tinycolor/color.go
+
+
+Add only:
+```go
+func (c Color) Complement() Color
+func (c Color) SplitComplement() []Color
+func (c Color) Triad() []Color
+func (c Color) Tetrad() []Color
+func (c Color) Analogous(results, slices int) []Color
+func (c Color) Monochromatic(results int) []Color
+```
+Use a private `polyad` helper. Explicit zero is a typed value; the next adapter
+plan applies JavaScript `results || 6` and `slices || 30` behavior.
+
+
+
+Red-green-refactor ordered pure palette operations
+src/tinycolor/color.go, src/tinycolor/operations_test.go
+
+- Complement, split complement, triad, and tetrad return source-ordered values beginning with the input where source does.
+- Analogous follows the signed/bit-shift source hue initialization and emits default-red order `ff0000,ff0066,ff0033,ff0000,ff3300,ff6600` when called with explicit 6/30.
+- Monochromatic follows HSV value stepping and emits `ff0000,2a0000,550000,800000,aa0000,d40000` for explicit six red results.
+
+
+RED: add direct tests for every public palette result length/order, the two
+documented red sequences, custom analogous results/slices, non-red hue wrap,
+alpha preservation, and independence by mutating a returned value and checking
+the source and other values. Confirm absent methods fail first. GREEN: implement
+value-returning palette methods using private HSL/HSV helpers and a private
+polyad helper. Preserve source arithmetic and output order precisely; never
+mutate `c`, export polyad, special-case Go zero as a JavaScript default, or
+modify parser/model. Decide and test the typed zero behavior explicitly as an
+empty result or documented guard before adapter coercion, rather than silently
+mapping it to 6/30. Commit `test(04-05): add failing palette regressions`, then
+`feat(04-05): implement ordered palettes`; refactor only while green.
+
+Set-Location src; go test ./tinycolor -run 'Test(Palette|Complement|Analogous|Monochromatic|Triad|Tetrad)' -count=1; go test ./...; go vet ./...
+All palette APIs are pure, ordered, direct-test covered, and no public JavaScript-default API exists.
+
+
+Use the focused RED/GREEN command and the full Go gate from `04-VALIDATION.md`.
+- OPS-01 palette ordering and independence are library-tested.
+- QLT-02 has direct cases for default-equivalent values, customization, wrapping, and alpha.
+
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-05-SUMMARY.md b/.planning/phases/04-operations-and-palettes/04-05-SUMMARY.md
new file mode 100644
index 00000000..0fa0c7df
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-05-SUMMARY.md
@@ -0,0 +1,16 @@
+---
+phase: 4
+plan: 5
+subsystem: palettes-library
+provides: [ordered typed palette operations]
+commits: [651a576, 5708bfb]
+---
+
+# Phase 4 Plan 5: Palette Library Summary
+
+Implemented pure typed complement, split-complement, triad, tetrad,
+analogous, and monochromatic operations with a private polyad helper. Tests
+cover source order, custom and wrapping hues, alpha behavior, typed zero
+guards, and independent returned values.
+
+Focused palette tests, `go test ./...`, and `go vet ./...` passed.
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-06-PLAN.md b/.planning/phases/04-operations-and-palettes/04-06-PLAN.md
new file mode 100644
index 00000000..232ccdb7
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-06-PLAN.md
@@ -0,0 +1,87 @@
+---
+phase: 4
+plan: 6
+type: execute
+subsystem: palettes-compatibility-phase-gate
+owner: C-mrashis
+tags: [compatibility, jsonl, palettes, phase-gate]
+wave: 6
+depends_on: [04-05]
+files_modified: [compat/js-runner.mjs, src/cmd/tinycolor-compat/main.go, tests/port/adapter.test.mjs, compat/cases/operations.jsonl]
+autonomous: true
+requirements: [OPS-01, QLT-02]
+must_haves:
+ truths:
+ - "Every public palette operation has an exact source-order JSONL reproducer."
+ - "Adapter-only zero/default coercion selects 6/30 for analogous and six for monochromatic."
+ - "The final Phase 4 gate runs all prior corpora and reports each mismatch with its existing owner metadata."
+ artifacts:
+ - path: "compat/cases/operations.jsonl"
+ provides: "Complete deterministic Phase 4 operations corpus"
+ min_lines: 50
+ key_links:
+ - from: "compat/js-runner.mjs"
+ to: "mod.js"
+ via: "whitelisted palette source method"
+ pattern: "(complement|analogous|monochromatic|splitcomplement|triad|tetrad)"
+---
+
+Finish Phase 4 by adding palette adapter/corpus proof and running the full reproducible gate.
+@AGENT.md
+@.planning/phases/04-operations-and-palettes/04-RESEARCH.md
+@.planning/phases/04-operations-and-palettes/04-05-SUMMARY.md
+@.planning/phases/04-operations-and-palettes/04-VALIDATION.md
+@mod.js lines 713-769
+@compat/js-runner.mjs
+@src/cmd/tinycolor-compat/main.go
+@compat/run.mjs
+@compat/cases/operations.jsonl
+
+
+Add `palette` requests with `args.method` limited to `complement`,
+`splitcomplement`, `triad`, `tetrad`, `analogous`, and `monochromatic`.
+Results are arrays of `Inspect` snapshots in source order. For dynamic requests,
+the adapter applies `results || 6` and `slices || 30`; typed palette methods do
+not receive missing/zero-as-default semantics.
+
+
+
+Add palette adapter dispatch with adapter-owned defaults
+compat/js-runner.mjs, src/cmd/tinycolor-compat/main.go, tests/port/adapter.test.mjs
+
+RED: add adapter tests asserting ordered snapshots for complement, split,
+triad, tetrad, analogous, and monochromatic; include omitted and explicit-zero
+analogous/monochromatic requests. GREEN: add a narrow `palette` dispatch branch
+to each runner. Node calls one source method. Go maps dynamic fields using
+source `||` truthiness, calls B's methods with concrete values, and serializes
+each returned `Color.Inspect()` in order. Reject unknown methods through the
+existing error protocol. Do not copy palette calculations into runners, mutate
+input values, or add a public polyad operation. Commit `test(04-06): add palette
+adapter failures`, then `feat(04-06): dispatch palette operations`.
+
+node tests/port/adapter.test.mjs; Set-Location src; go test ./cmd/tinycolor-compat ./tinycolor -count=1; Set-Location ..; node compat/run.mjs compat/cases/operations.jsonl
+Palette dispatch returns exactly ordered value snapshots and confines zero/default coercion to adapters.
+
+
+Complete palette corpus and execute the Phase 4 full gate
+compat/cases/operations.jsonl
+
+Append deterministic rows for all six palette methods: default red sequences,
+custom analogous count/slices, analogous and monochromatic zero defaults, a
+non-red hue-wrap case, and alpha retention. Retain all modifier, Mix, and WCAG
+rows from earlier plans; do not rewrite their ids or expected request shape.
+Run every command in `04-VALIDATION.md` and record no manually edited expected
+outputs: `compat/run.mjs` remains the exact comparator and existing mismatch
+records retain request, source/Go result, operation, suspected package, and B/C
+owner. Commit corpus-only work as `test(04-06): complete palette differential
+corpus`; make no production, test, oracle, or top-level planning changes outside
+the executor's planned ownership.
+
+Set-Location src; go test ./...; go vet ./...; Set-Location ..; node tests/port/adapter.test.mjs; node compat/run.mjs compat/cases/smoke.jsonl; node compat/run.mjs compat/cases/parser-hex-rgb.jsonl; node compat/run.mjs compat/cases/parser.jsonl; node compat/run.mjs compat/cases/conversion.jsonl; node compat/run.mjs compat/cases/operations.jsonl; git diff --check; git diff -- mod.js test.js tinycolor.js
+All available Phase 1-4 gates pass with zero mismatches; any failure remains a full, owner-assigned reproducer and no Deno suite pass is claimed.
+
+
+Execute the final command exactly as written. A nonzero differential command blocks completion; Deno remains explicitly unavailable.
+- OPS-01 covers every palette in exact source order.
+- QLT-02 has a complete fixed Phase 4 corpus and preserved owner-assigned mismatch reports.
+
\ No newline at end of file
diff --git a/.planning/phases/04-operations-and-palettes/04-06-SUMMARY.md b/.planning/phases/04-operations-and-palettes/04-06-SUMMARY.md
new file mode 100644
index 00000000..38f6604b
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-06-SUMMARY.md
@@ -0,0 +1,17 @@
+---
+phase: 4
+plan: 6
+subsystem: palettes-compatibility-phase-gate
+provides: [palette JSONL dispatch and complete Phase 4 evidence]
+commits: [8776335, c328fa4, 7cd0e84, 796044f, 9e8155b, 9d7e74e, 8d2cdf3, 5e14c31]
+---
+
+# Phase 4 Plan 6: Palette Compatibility and Gate Summary
+
+Added palette dispatch for all six public operations, retaining source order
+and adapter-owned zero defaults. The final corpus contains 69 operations rows.
+Focused follow-ups repaired `Analogous` metadata, dynamic amount/WCAG behavior,
+empty-candidate fallback selection, and brighten half-tie rounding.
+
+Final validation passed: Go tests and vet, adapter tests, and fixed corpora at
+9/9 smoke, 26/26 HEX/RGB, 23/23 parser, 35/35 conversion, and 69/69 operations.
diff --git a/.planning/phases/04-operations-and-palettes/04-RESEARCH.md b/.planning/phases/04-operations-and-palettes/04-RESEARCH.md
new file mode 100644
index 00000000..14cf6878
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-RESEARCH.md
@@ -0,0 +1,267 @@
+# Phase 4: Operations and Palettes - Research
+
+**Researched:** 2026-08-01
+**Domain:** Source-compatible TinyColor operations, WCAG readability, and color palettes in Go
+**Confidence:** HIGH
+
+## User Constraints
+
+No `04-CONTEXT.md` exists. The phase is constrained by the Phase 4 roadmap goal, `OPS-01` and `QLT-02`, [AGENT.md](../../../AGENT.md), the immutable local [mod.js](../../../mod.js) oracle, and the approved [operations design](../../../docs/superpowers/specs/2026-08-01-phase-4-operations-design.md).
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|---|---|---|
+| OPS-01 | Reproduce mutation, utilities, readability, and palette operations. | Exact source algorithms, defaults, return/mutation semantics, palette order, and adapter operation shapes below. |
+| QLT-02 | Record every mismatch with complete reproducer and owner. | Fixed JSONL cases run through the existing exact comparator; B owns library results and C owns dispatch/corpus protocol mismatches. |
+
+
+
+## Project Constraints (from AGENT.md)
+
+- `mod.js`, `test.js`, `tinycolor.js`, `npm/`, `dist/`, and `demo/` are immutable JavaScript oracle material. Do not change them to obtain parity.
+- New Go behavior belongs under `src/`; compatibility adapters, fixtures, and reports belong under `compat/`.
+- Preserve TinyColor quirks, including defaults, validation, clamping, hue wrapping, alpha, mutation, and invalid-as-black behavior. Do not use a broad floating-point tolerance.
+- The public Go API is explicit and typed; the JSON compatibility adapter owns JavaScript truthiness/default coercion that cannot be expressed in the typed API.
+- B owns `src/tinycolor` operations, readability, and palettes. C owns `compat`, fixture schema, and reproducible differential evidence.
+- Required checks include `go test ./...`, `go vet ./...`, the Node oracle runner, and the differential command. Deno is unavailable locally, so a Deno suite pass must not be claimed.
+
+## Summary
+
+Phase 4 should extend only `src/tinycolor`: its existing normalized `color.Model` and `Color` conversion methods already provide the required RGBA/HSL/HSV substrate. Implement mutating instance methods as pointer receivers that change the existing `Color`, and implement `Mix`, WCAG operations, and palette methods as pure facade operations returning values. No parser/model or third-party color package is needed. The compatibility runners should only construct input colors, apply one public operation, and serialize observable results.
+
+The local source is unusually specific about JavaScript defaults. `lighten`, `brighten`, `darken`, `saturate`, `desaturate`, and `mix` preserve a numeric `0` but turn other falsy amounts into their default. `spin` has no default at all: omitted `undefined` becomes invalid black after conversion, while JSON `null` behaves as `0`. `analogous` and `monochromatic` use `value || default`, so JSON `0` selects their defaults. These rules must be confined to compatibility dispatch; typed Go methods should accept explicit numeric amounts/counts and not pretend that omitted and zero are the same API state.
+
+**Primary recommendation:** make three micro-commit-friendly plans: B implements modifiers and `Mix`, then B implements readability, then B implements palettes; C follows each with thin dual-runner dispatch plus a dedicated exact JSONL corpus. Keep all JS coercion in the adapter and compare deterministic values exactly.
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---|---:|---|---|
+| Go standard library (`math`) | Go 1.26.1 installed | HSL/HSV arithmetic, clamping, and source-compatible rounding | D-005 requires standard-library-first and the oracle algorithms are short. |
+| Local `mod.js` and `test.js` | checked-out oracle | Exact behavior and source assertions | D-001 makes this checkout, not a generic color specification, authoritative. |
+| Existing JSONL harness | repository-local | Exact Node-to-Go differential validation | It already reports the full request, both results, operation, and owner. |
+
+### Supporting
+
+| Dependency | Purpose | When to Use |
+|---|---|---|
+| `src/internal/color.Model` | normalized RGBA, alpha, validity, format, original input | Read/write it only through `tinycolor.Color`; do not add another color state type. |
+| Existing `rgbToHSL` / `rgbToHSV` helpers in `src/tinycolor/color.go` | source-like conversion inputs | Reuse them and add the reciprocal helpers locally in the same facade. |
+
+**Installation:** None. Do not add a dependency.
+
+## Exact Source Semantics
+
+### Mutating modifiers
+
+Source: [mod.js](../../../mod.js), prototype wrappers around lines 255-321 and algorithms around lines 655-710.
+
+| Method | Amount rule | Calculation | Bounds and alpha | Receiver behavior |
+|---|---|---|---|---|
+| `Lighten(amount)` | `amount === 0 ? 0 : amount \|\| 10` | HSL `l += amount / 100` | `l = clamp01(l)`; alpha is preserved | Mutates and returns the same instance. |
+| `Darken(amount)` | same | HSL `l -= amount / 100` | `l = clamp01(l)`; alpha preserved | Mutates and returns the same instance. |
+| `Saturate(amount)` | same | HSL `s += amount / 100` | `s = clamp01(s)`; alpha preserved | Mutates and returns the same instance. |
+| `Desaturate(amount)` | same | HSL `s -= amount / 100` | `s = clamp01(s)`; alpha preserved | Mutates and returns the same instance. |
+| `Greyscale()` | no argument | equivalent to `desaturate(100)` | saturation becomes 0; alpha preserved | Mutates and returns the same instance. |
+| `Brighten(amount)` | same | rounded RGB delta: `round(255 * amount / 100)` added per channel | every RGB channel clamps to `[0,255]`; alpha preserved | Mutates and returns the same instance. |
+| `Spin(amount)` | **no default** | `hue = (h + amount) % 360`; if negative, add 360 | hue wraps; alpha preserved for numeric amount | Mutates and returns the same instance. |
+
+The wrapper evaluates the free function against `this`, copies the resulting `_r/_g/_b`, then calls `setAlpha` with the result alpha. It leaves source format, original input, validity, and gradient type intact. Go must therefore use pointer receivers such as `func (c *Color) Lighten(amount float64) *Color`; a value receiver would silently fail the required receiver mutation. Keep typed methods numeric and explicit. The adapter must distinguish omitted amount from JSON `0` and must implement the source defaults before calling the typed method.
+
+Oracle probe results, run against the local source on 2026-08-01:
+
+- `tinycolor("red").lighten(10)` returns the exact same object and changes it from `#ff0000` to `#ff3333`.
+- `tinycolor("red").spin()` produces `#000000` because `undefined` yields `NaN`, which reaches object parsing as invalid input.
+- `tinycolor("red").spin(null)` and `.spin(0)` both remain `#ff0000`; JSON cannot convey `undefined`, so an omitted JSON amount must be routed separately from `null`.
+- The source exposes no `tinycolor.lighten` static operation (`typeof tinycolor.lighten === "undefined"`). Do not invent static modifier APIs in the parity adapter.
+
+### `Mix`
+
+Source: [mod.js](../../../mod.js), lines 771-790; source tests around lines 2013-2078.
+
+`tinycolor.mix(color1, color2, amount)` uses `amount === 0 ? 0 : amount || 50`, converts both inputs with `toRgb()` first (thus channels are already rounded), sets `p = amount / 100`, and linearly interpolates every `r`, `g`, `b`, **and** `a` as `(second - first) * p + first`. It constructs a fresh TinyColor from that RGBA object. It does not clamp `amount`; out-of-range amounts extrapolate before input normalization clamps RGB and normalizes alpha. It does not mutate either input.
+
+Use a pure typed function, for example `func Mix(first, second Color, amount float64) Color`, and do the omitted/falsy default only in compatibility dispatch. Cover absent amount (`50`), explicit `0`, `.5` alpha, `90` (the source test's rounding-sensitive case), and out-of-range values.
+
+### Readability, `IsReadable`, and `MostReadable`
+
+Source: [mod.js](../../../mod.js), lines 797-871 and `validateWCAG2Parms` around lines 1261-1277; source tests around lines 1100-1370.
+
+- `readability(a, b)` constructs both colors and returns `(max(luminanceA, luminanceB) + .05) / (min(...) + .05)`. It inherits the existing `Color.Luminance()` rule, which uses rounded RGB channels.
+- `isReadable(a, b, wcag2)` defaults invalid/missing options to `AA/small`. The normalized pair is: `level = (parms.level || "AA").toUpperCase()` and `size = (parms.size || "small").toLowerCase()`, then invalid values become `AA` and `small` respectively. Thresholds are `AA small = 4.5`, `AA large = 3`, `AAA small = 7`, and `AAA large = 4.5`; comparisons are inclusive.
+- `mostReadable(base, candidates, args)` scans candidates in input order and changes its best only for a **strictly** larger score, so ties retain the first candidate. It returns that candidate when it meets the normalized readability option or when `includeFallbackColors` is falsy. Otherwise it mutates only the local `args` object by setting `includeFallbackColors = false`, then recursively chooses from `[#fff, #000]` using the same `level` and `size`.
+- Empty candidates are source-defined but inconvenient: `bestColor` remains `null`; with base `#fff`, both `includeFallbackColors: false` and `true` return `null` because `isReadable(#fff, null)` treats null as the black TinyColor input and succeeds. Preserve this JSON `null` result in adapter tests; do not dereference it or invent a fallback.
+
+Go ownership should use a typed `WCAG2Options` value (e.g. `Level`, `Size`, `IncludeFallbackColors`) and a normalization helper in `src/tinycolor`. The adapter must preserve source coercion: absent, `null`, `false`, `0`, and empty strings default as JavaScript does; string case is normalized; non-string values must not be prematurely rejected. `MostReadable` needs a nullable typed result, such as `(Color, bool)`, because the source can return null. Serialize `false` only for boolean-or-string source APIs, not this nullable color result.
+
+### Palettes: defaults, order, and input relationship
+
+Source: [mod.js](../../../mod.js), lines 713-769; source tests around lines 2080-2150.
+
+| Operation | Source output order and formula | Defaults / special behavior |
+|---|---|---|
+| `Complement()` | fresh HSL color with `(h + 180) % 360` | one color; source test confirms input remains unchanged. |
+| `SplitComplement()` | `[input, h + 72, h + 216]` | exactly 3 colors, in this order. |
+| `Triad()` | `polyad(3)`: `[input, h + 120, h + 240]` | exactly 3 colors. |
+| `Tetrad()` | `polyad(4)`: `[input, h + 90, h + 180, h + 270]` | exactly 4 colors. |
+| `Analogous(results, slices)` | starts `[input]`; initializes hue to `(h - ((360/slices * results) >> 1) + 720) % 360`, then increments one part for each remaining result | `results \|\| 6`, `slices \|\| 30`; zero therefore yields 6 and 30. Default red order is `ff0000,ff0066,ff0033,ff0000,ff3300,ff6600`. |
+| `Monochromatic(results)` | emits HSV `{h,s,v}`, then sets `v = (v + 1/results) % 1` | `results \|\| 6`; zero yields 6. Default red order is `ff0000,2a0000,550000,800000,aa0000,d40000`. |
+
+`polyad` is intentionally not public in the source because its prototype method is commented out; only use a private Go helper for triad/tetrad. Though `tinycolor(color)` returns the same JavaScript object when the input is already a TinyColor instance, the operation's observable colors are as above. Go's value-returning palette slice should contain independent values, as required by the project design; adapters serialize values only and must not claim JavaScript identity parity.
+
+## Architecture Patterns and Ownership
+
+### Recommended project shape
+
+```text
+src/
+├── tinycolor/color.go # B: Color methods, private HSL/HSV/RGBA helpers
+├── tinycolor/operations_test.go # B: modifiers, Mix, WCAG, palettes
+└── cmd/tinycolor-compat/main.go # C: Go adapter dispatch/coercion only
+compat/
+├── js-runner.mjs # C: matching local-oracle operation dispatch
+└── cases/operations.jsonl # C: fixed exact Phase 4 corpus
+```
+
+### Exact ownership boundaries
+
+| Owner | Owns | Must not do |
+|---|---|---|
+| B | `Color` pointer modifiers, pure `Mix`, readability/options normalization, palette helpers and methods, direct Go tests | modify parser/model contracts or place JS coercion in public typed APIs. |
+| C | operation schema, Node/Go runner whitelists, dynamic JSON coercion/defaults, `operations.jsonl`, adapter protocol tests, mismatch reports | duplicate HSL/HSV/WCAG/palette math in either runner. |
+| A | no planned Phase 4 change | absorb operations into parser/model. |
+
+### Minimal micro-commit plan split
+
+1. **B: modifiers and Mix** - add reciprocal local conversion helpers only as needed; pointer-receiver modifiers and pure `Mix`; direct tests for same-receiver mutation, defaults applied by a small compatibility-facing helper, zero, clamp, hue wrap, and alpha.
+2. **C: modifier/Mix evidence** - add `operation: "modify"` and `operation: "mix"` dispatch to both runners plus initial `operations.jsonl`; extend adapter protocol tests. Commit only fixture/adapter work.
+3. **B: readability** - add `Readability`, `IsReadable`, `MostReadable`, a typed normalized options helper, and nullable result handling; direct tests for each WCAG threshold, first-tie selection, recursive black/white fallback, and empty list.
+4. **C: readability evidence** - add `readability`, `isReadable`, and `mostReadable` runner operations and their source-derived corpus rows.
+5. **B: palettes** - add pure palette methods and a private polyad helper; direct tests for default red sequences, custom analogous counts/slices, zero/default behavior, order, and alpha preservation.
+6. **C: palette evidence and phase gate** - add `palette` dispatch, complete deterministic corpus, run all existing and new corpora, then update compatibility evidence only with actual results.
+
+## Required Adapter Coercion and JSONL Shapes
+
+The existing runners accept `{id, operation, input, args}` and must retain all prior operations. Add only operation-specific request forms; use `Object.hasOwn(args, "amount")` in Node and an equivalent Go map-presence check so omitted differs from `0` and `null`.
+
+```json
+{"id":"mod-lighten-default","operation":"modify","input":"#80000080","args":{"method":"lighten"}}
+{"id":"mod-lighten-zero","operation":"modify","input":"red","args":{"method":"lighten","amount":0}}
+{"id":"mod-spin-null","operation":"modify","input":"red","args":{"method":"spin","amount":null}}
+{"id":"mod-spin-omitted","operation":"modify","input":"red","args":{"method":"spin"}}
+{"id":"mix-alpha-quarter","operation":"mix","input":"transparent","args":{"other":"#000","amount":25}}
+{"id":"readability-black-white","operation":"readability","input":"#000","args":{"other":"#fff"}}
+{"id":"is-readable-default","operation":"isReadable","input":"#ff0088","args":{"other":"#5c1a72","options":{}}}
+{"id":"most-readable-fallback","operation":"mostReadable","input":"#123","args":{"candidates":["#124","#125"],"options":{"includeFallbackColors":true}}}
+{"id":"most-readable-empty","operation":"mostReadable","input":"#fff","args":{"candidates":[],"options":{"includeFallbackColors":true}}}
+{"id":"palette-analogous-default","operation":"palette","input":"red","args":{"method":"analogous"}}
+{"id":"palette-analogous-zero","operation":"palette","input":"red","args":{"method":"analogous","results":0,"slices":0}}
+{"id":"palette-triad","operation":"palette","input":"red","args":{"method":"triad"}}
+```
+
+Recommended result encoding: `modify` and `mix` return an `inspect` snapshot so mutation and alpha are observable; `readability` returns its raw number; `isReadable` returns a boolean; `mostReadable` returns `null` or a snapshot; `palette` returns a JSON array of snapshots in source order. Node must construct one instance for `modify`, call it once, and include both `before` and `after` snapshots plus `sameReceiver: returned === color`. Go must return `sameReceiver: true` only when its pointer method is used; this makes the mutation contract testable without exposing internals.
+
+Do not coerce typed public Go inputs through JSON rules. Adapter-only rules required for parity are: absent versus present amount, `0` preservation where source uses the strict zero ternary, null passed to `spin`, `||` defaults for analogous/monochromatic, WCAG string case/default handling, `includeFallbackColors` truthiness, and JSON `null` for no most-readable candidate.
+
+## Don't Hand-Roll
+
+| Problem | Do not build | Use instead | Why |
+|---|---|---|---|
+| General color library | external CSS/WCAG/color package | direct algorithms over existing `Color` conversion helpers | A library will not preserve TinyColor's defaults, rounding, null behavior, order, and mutation. |
+| Dynamic operation framework | reflection or a second protocol | existing JSONL request/response plus switch dispatch | The current protocol is already stable and exact-comparing. |
+| Public JavaScript-like argument API | `any`/variadic methods on `Color` | typed methods plus adapter coercion | D-006 explicitly separates Go usability from source quirks. |
+| Public `polyad` | new public method | private helper for `Triad` and `Tetrad` | The source deliberately disables its public polyad method. |
+
+## Common Pitfalls
+
+### Defaulting `spin` like the other modifiers
+
+`spin` has no `|| 10` source expression. An omitted JavaScript value is observably invalid black, while JSON null acts like zero. Test both cases separately.
+
+### Using Go's rounding for `Brighten` without checking the source boundary
+
+The source computes `Math.round(255 * amount / 100)` before clamping. Keep this rounding point; do not brighten through HSL or round only at string output.
+
+### Flattening all defaults into Go zero values
+
+`0` means “no change” for amount modifiers and `Mix`, but it means default 6/30 for `Analogous` and default 6 for `Monochromatic`. Presence-aware adapter decoding is required.
+
+### Mutating palettes or returning a reordered sequence
+
+Palette source order is a public result. `Complement` is non-mutating; the first element of analogous/split/triad/tetrad corresponds to the input color. Pin whole ordered arrays, not sets.
+
+### Incorrect fallback logic in `MostReadable`
+
+Fallback occurs only after selecting a best candidate, only when that candidate is not readable, and only when `includeFallbackColors` is truthy. Preserve the strict-greater tie rule and empty-list null behavior.
+
+### Rounding readability or tolerating float drift
+
+`readability` returns a raw float and `isReadable` compares that raw ratio inclusively. The harness compares JSON values exactly; do not round or apply an epsilon.
+
+## Validation Architecture
+
+### Test framework
+
+| Property | Value |
+|---|---|
+| Framework | Go standard `testing`; Node JSONL differential harness; upstream Deno suite unavailable locally |
+| Quick library command | `Set-Location src; go test ./tinycolor; Set-Location ..` |
+| Quick corpus command | `node compat/run.mjs compat/cases/operations.jsonl` |
+| Full phase command | `Set-Location src; go test ./...; go vet ./...; Set-Location ..; node tests/port/adapter.test.mjs; node compat/run.mjs compat/cases/smoke.jsonl; node compat/run.mjs compat/cases/parser-hex-rgb.jsonl; node compat/run.mjs compat/cases/parser.jsonl; node compat/run.mjs compat/cases/conversion.jsonl; node compat/run.mjs compat/cases/operations.jsonl; git diff --check` |
+
+### Requirements to test map
+
+| Requirement | Behavior | Test type | Command | Initial state |
+|---|---|---|---|---|
+| OPS-01 | mutable modifiers: default/zero, clamp, wrap, alpha, same receiver | Go unit + exact differential | `node compat/run.mjs compat/cases/operations.jsonl` | Wave 0 needed |
+| OPS-01 | Mix rounding and alpha interpolation | Go unit + exact differential | same | Wave 0 needed |
+| OPS-01 | WCAG normalization, thresholds, selection/fallback/null | Go unit + exact differential | same | Wave 0 needed |
+| OPS-01 | palettes: defaults, custom options, ordered results | Go unit + exact differential | same | Wave 0 needed |
+| QLT-02 | stable complete reproducer | differential integration | same | driver exists; Phase 4 rows/dispatch missing |
+
+### Required deterministic corpus groups
+
+- Modifiers: each method with omitted/default, zero, normal, clamp high/low, alpha input, `spin` negative/over-360/omitted/null.
+- Mix: omitted 50, zero, 90, transparent alpha progression, and out-of-range amount.
+- Readability: ratios `1`, `1.1121078324840545`, `21`; each threshold family; invalid/mixed-case options; first-tie behavior; include-fallback true/false; empty candidate null.
+- Palettes: all default red sequences copied from `test.js`; custom analogous `results`/`slices`; zero defaults; a non-red hue-wrapping case; alpha retention.
+
+### Environment availability
+
+| Dependency | Required by | Available | Version | Fallback |
+|---|---|---:|---|---|
+| Node.js | local JS oracle and differential harness | yes | v24.18.0 | none needed |
+| Go | library tests and Go adapter | yes | go1.26.1 windows/amd64 | none needed |
+| Deno | unchanged source suite | no | — | Node differential evidence only; does not count as a source-suite pass |
+
+## Sources
+
+### Primary (HIGH confidence)
+
+- [mod.js](../../../mod.js) - constructor/prototype mutation wrapper, modifiers, mix, WCAG operations, palette algorithms, and WCAG parameter validator.
+- [test.js](../../../test.js) - exhaustive modifier and mix loops; spin cases; WCAG/fallback assertions; default palette order expectations.
+- [docs/superpowers/specs/2026-08-01-phase-4-operations-design.md](../../../docs/superpowers/specs/2026-08-01-phase-4-operations-design.md) - approved scope and architectural separation.
+- [src/tinycolor/color.go](../../../src/tinycolor/color.go), [src/cmd/tinycolor-compat/main.go](../../../src/cmd/tinycolor-compat/main.go), [compat/js-runner.mjs](../../../compat/js-runner.mjs), and [compat/run.mjs](../../../compat/run.mjs) - current extension surfaces.
+- Oracle probe executed 2026-08-01 with Node v24.18.0 - omitted/null spin, zero palette defaults, empty `mostReadable`, static modifier absence, and identity mutation.
+
+### Secondary (MEDIUM confidence)
+
+None. The selected local source and tests are the authoritative specification.
+
+### Tertiary (LOW confidence)
+
+None.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH - project decisions and installed versions are local evidence.
+- Architecture: HIGH - approved Phase 4 design and existing owner boundaries agree.
+- Semantics and pitfalls: HIGH - direct local source, source tests, and oracle edge probe.
+
+**Research date:** 2026-08-01
+**Valid until:** this checkout's `mod.js` or `test.js` changes.
diff --git a/.planning/phases/04-operations-and-palettes/04-VALIDATION.md b/.planning/phases/04-operations-and-palettes/04-VALIDATION.md
new file mode 100644
index 00000000..b59dc5ba
--- /dev/null
+++ b/.planning/phases/04-operations-and-palettes/04-VALIDATION.md
@@ -0,0 +1,56 @@
+# Phase 4 Validation
+
+## Constraints
+
+- Run commands from `R:\Code\TinyColor` unless the command changes directory.
+- The JavaScript oracle, parser, and color model are not Phase 4 edit targets.
+- Treat every differential mismatch as a QLT-02 record; do not add float tolerance.
+- Deno is unavailable. Do not claim `deno task test` passed.
+
+## Focused Tasks
+
+| Plan/task | RED/GREEN command | Differential or adapter command |
+|---|---|---|
+| 04-01 modifiers/Mix library | `Set-Location src; go test ./tinycolor -run 'Test(Modifiers|Mix)' -count=1` | Not applicable until 04-02 |
+| 04-02 modifiers/Mix adapter/corpus | `node tests/port/adapter.test.mjs` | `node compat/run.mjs compat/cases/operations.jsonl` |
+| 04-03 readability library | `Set-Location src; go test ./tinycolor -run 'Test(Readability|IsReadable|MostReadable)' -count=1` | Not applicable until 04-04 |
+| 04-04 readability adapter/corpus | `node tests/port/adapter.test.mjs` | `node compat/run.mjs compat/cases/operations.jsonl` |
+| 04-05 palettes library | `Set-Location src; go test ./tinycolor -run 'Test(Palette|Complement|Analogous|Monochromatic|Triad|Tetrad)' -count=1` | Not applicable until 04-06 |
+| 04-06 palettes adapter/corpus | `node tests/port/adapter.test.mjs` | `node compat/run.mjs compat/cases/operations.jsonl` |
+
+For every TDD library task, first run its focused Go command after adding the
+test and confirm the feature is absent or behaviorally wrong. After the minimal
+implementation, rerun it, then run `Set-Location src; go test ./...; go vet
+./...` before the task's micro-commit.
+
+## Final Phase Gate
+
+```powershell
+Set-Location src
+go test ./...
+go vet ./...
+Set-Location ..
+node tests/port/adapter.test.mjs
+node compat/run.mjs compat/cases/smoke.jsonl
+node compat/run.mjs compat/cases/parser-hex-rgb.jsonl
+node compat/run.mjs compat/cases/parser.jsonl
+node compat/run.mjs compat/cases/conversion.jsonl
+node compat/run.mjs compat/cases/operations.jsonl
+git diff --check
+git diff -- mod.js test.js tinycolor.js
+```
+
+Pass criteria: all Go, adapter, and corpus commands exit zero; the operations
+corpus has zero mismatches; `git diff --check` is clean; and the final oracle
+diff is empty. If any differential check fails, preserve the exact JSONL row
+and harness record (request, JavaScript result, Go result, operation, owner,
+and suspected package) rather than changing the oracle or hiding the result.
+
+## Planned Execution Summaries
+
+- `04-01-SUMMARY.md`: modifier/Mix typed contracts, direct tests, focused and full Go evidence.
+- `04-02-SUMMARY.md`: adapter coercion rules, modifier/Mix corpus count, differential evidence.
+- `04-03-SUMMARY.md`: WCAG public contracts, threshold/selection tests, Go evidence.
+- `04-04-SUMMARY.md`: options/null adapter behavior, readability corpus count, differential evidence.
+- `04-05-SUMMARY.md`: palette contracts/order tests and Go evidence.
+- `04-06-SUMMARY.md`: palette adapter/corpus completion and final Phase 4 gate evidence.
\ No newline at end of file
diff --git a/.planning/phases/05-delivery-evidence/05-01-PLAN.md b/.planning/phases/05-delivery-evidence/05-01-PLAN.md
new file mode 100644
index 00000000..8b32668e
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-01-PLAN.md
@@ -0,0 +1,109 @@
+---
+phase: 5
+plan: 1
+type: execute
+subsystem: cli-build-ci
+owner: D-deepali
+tags: [cli, build, verification, ci, docker]
+wave: 1
+depends_on: []
+files_modified: [src/cmd/tinycolor-compat/main.go, src/cmd/tinycolor-compat/main_test.go, tests/original/verify.mjs, tests/original/verify.test.mjs, Makefile, Dockerfile, .github/workflows/port.yml, .github/workflows/deno.yml]
+autonomous: true
+requirements: [DEL-01, QLT-03]
+must_haves:
+ truths:
+ - "One executable supports parse, convert, lighten, palette, contrast, and the existing JSONL adapter mode."
+ - "One documented build command produces bin/tinycolor and one verify command checks every available local gate."
+ - "CI calls the repository verification command and runs the untouched Deno source suite separately."
+ artifacts:
+ - path: "src/cmd/tinycolor-compat/main_test.go"
+ provides: "Command-level regression coverage for all required human CLI operations and JSONL mode"
+ - path: "tests/original/verify.mjs"
+ provides: "Cross-platform immutable-oracle hash verification"
+ - path: ".github/workflows/port.yml"
+ provides: "Clean-checkout build, parity, source-suite, and artifact CI"
+ key_links:
+ - from: "Makefile"
+ to: "tests/original/manifest.sha256"
+ via: "node tests/original/verify.mjs"
+ pattern: "tests/original/verify.mjs"
+ - from: ".github/workflows/port.yml"
+ to: "Makefile"
+ via: "make verify"
+ pattern: "make verify"
+---
+
+Expose the completed port through a tested human CLI and make its build and parity gate reproducible locally, in Docker, and in CI.
+@AGENT.md
+@.planning/phases/05-delivery-evidence/05-CONTEXT.md
+@.planning/phases/05-delivery-evidence/05-RESEARCH.md
+@.planning/phases/05-delivery-evidence/05-VALIDATION.md
+@src/cmd/tinycolor-compat/main.go
+@src/tinycolor/color.go
+@src/internal/compat/protocol.go
+@compat/run.mjs
+@tests/port/adapter.test.mjs
+@tests/original/manifest.sha256
+@Makefile
+@Dockerfile
+@deno.json
+
+
+Add the dual-mode human CLI without changing JSONL behavior
+src/cmd/tinycolor-compat/main.go, src/cmd/tinycolor-compat/main_test.go
+Read `src/cmd/tinycolor-compat/main.go` completely, then read exported methods and structs in `src/tinycolor/color.go`, response encoding in `src/internal/compat/protocol.go`, and stdin contract assertions in `tests/port/adapter.test.mjs`.
+
+RED: add table tests around `run(args []string, stdin io.Reader, stdout, stderr io.Writer) int`. Assert no args still accepts one JSONL `inspect` request. Assert `parse --json red` returns a JSON object with `hex:"ff0000"` and `valid:true`; `convert --to hsl red` prints `hsl(0, 100%, 50%)`; `lighten --amount 10 #000` prints `#1a1a1a`; `palette --type triad red` prints three ordered hex colors; and `contrast #000 #fff --json` returns ratio `21` plus AA/AAA small/large booleans. Assert unknown command and missing required flags return status 2 with usage on stderr. Run the focused test and confirm RED.
+
+GREEN: make `main` call `os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))`. With zero args, retain the current line-scanner JSONL loop and return zero after EOF; malformed individual requests still emit protocol failures without terminating the stream. With args, use a command-specific `flag.FlagSet` with `ContinueOnError`; flags precede positional color values. Implement exactly: `parse [--json] `, `convert --to hex|hex8|rgb|percentage-rgb|hsl|hsv|name [--json] `, `lighten [--amount 10] [--json] `, `palette --type complement|splitcomplement|triad|tetrad|analogous|monochromatic [--results 6] [--slices 30] [--json] `, and `contrast [--json] `. Human colors are strings passed to `tinycolor.FromCompat(value, false)`; invalid input remains a successful TinyColor value and JSON exposes `valid:false`. Human parse JSON is `Inspect()`, palette JSON is an array of `Inspect()` objects, and contrast JSON is `{ratio, aaSmall, aaLarge, aaaSmall, aaaLarge}` using `Readability` and `IsReadable`. Use `encoding/json`; no new package or binary. Run focused and full Go tests, then commit `feat(05-01): add human CLI commands`.
+
+
+- `src/cmd/tinycolor-compat/main.go` contains `func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int`.
+- Tests invoke all five command names and the zero-argument JSONL path.
+- `go -C src test ./cmd/tinycolor-compat -count=1` exits 0.
+- `node tests/port/adapter.test.mjs` exits 0, proving compatibility mode did not change.
+
+go -C src test ./cmd/tinycolor-compat -count=1; go -C src test ./...; node tests/port/adapter.test.mjs
+The single binary is useful to humans and remains byte-compatible with the existing differential adapter.
+
+
+
+Add cross-platform oracle verification and one-command local gates
+tests/original/verify.mjs, tests/original/verify.test.mjs, Makefile, Dockerfile
+Read `tests/original/manifest.sha256`, `Makefile`, `Dockerfile`, `.gitignore`, `compat/run.mjs`, and every filename in `compat/cases/` before editing.
+
+RED: add a Node built-in test that copies a two-line manifest and files into a temporary directory, proves `verifyManifest` accepts correct SHA-256 hashes, then changes one file and proves it reports that path. Run `node --test tests/original/verify.test.mjs` and confirm RED because the verifier does not exist.
+
+GREEN: implement `tests/original/verify.mjs` with `node:crypto`, `node:fs`, and `node:path`. Export `verifyManifest(manifestPath, root)` and make direct execution print `verified: 3` on success, print every missing/mismatched relative path on stderr, and exit 1 on failure. Expand Make targets to `.PHONY: build test verify fmt-check hashes fuzz bench clean`; `build` produces `bin/tinycolor`; `fmt-check` fails when `gofmt -l` prints any `.go` file under `src`; `hashes` runs the verifier; `test` runs Node adapter tests, `go -C src test ./...`, and all five corpora (smoke, parser-hex-rgb, parser, conversion, operations); `verify` depends on fmt-check, hashes, test and also runs `go -C src vet ./...`. Keep `clean` limited to the explicit ignored `bin/tinycolor` artifact. Update Docker to output `/tinycolor`, set it as ENTRYPOINT, and keep the scratch runtime. Run the full gate, then commit `build(05-01): add reproducible verification gate`.
+
+
+- `node tests/original/verify.mjs` prints exactly `verified: 3` and exits 0.
+- `make build` creates ignored `bin/tinycolor` and the binary runs `parse red` successfully.
+- `make verify` runs every corpus including conversion and operations and exits 0.
+- Dockerfile contains `ENTRYPOINT ["/tinycolor"]` and does not copy the JavaScript runtime into the final image.
+
+node --test tests/original/verify.test.mjs; node tests/original/verify.mjs; make build; ./bin/tinycolor parse red; make verify
+A clean checkout has one build command, one complete local verification command, and a portable immutable-oracle check.
+
+
+
+Replace the upstream-only workflow with port delivery CI
+.github/workflows/port.yml, .github/workflows/deno.yml
+Read `.github/workflows/deno.yml`, `deno.json`, `src/go.mod`, `Makefile`, and `tests/original/manifest.sha256` completely.
+
+Delete `.github/workflows/deno.yml` after transferring its source-suite intent into `.github/workflows/port.yml`. Configure push and pull_request, `contents: read`, Ubuntu, checkout v4, setup-go v5 using the exact version from `src/go.mod`, setup-node v4 with Node 24, and setup-deno v2 with Deno v2.x. Run `make verify`, then `deno task test` against untouched source files, then `make build`, then upload `bin/tinycolor` with upload-artifact v4. The CI file must not run `deno task build`, npm publication, or mutate generated upstream distributions. Run a YAML text audit and local `make verify`, then commit `ci(05-01): verify port and source suite`.
+
+
+- `.github/workflows/port.yml` contains `make verify`, `deno task test`, `make build`, and `actions/upload-artifact@v4`.
+- `.github/workflows/deno.yml` no longer exists, leaving one CI source of truth.
+- The workflow has `permissions: contents: read` and no publish permission or deployment step.
+- `make verify` still exits 0 locally; local documentation does not claim the CI or Deno run passed before GitHub executes it.
+
+rg -n "make verify|deno task test|make build|upload-artifact@v4|contents: read" .github/workflows/port.yml; if (Test-Path .github/workflows/deno.yml) { exit 1 }; make verify
+CI performs the same local gate, adds the environment-only untouched Deno suite, and publishes the working Go binary.
+
+
+Run `make verify`, confirm the CLI examples return exit 0, confirm oracle hashes match, and inspect the workflow for build/test-only permissions. A local Deno pass is not required or claimed when Deno is unavailable.
+- DEL-01 has a tested human CLI, one-command build, Docker artifact, and CI.
+- QLT-03 has a cross-platform manifest verifier and one complete clean-checkout command.
+
diff --git a/.planning/phases/05-delivery-evidence/05-01-SUMMARY.md b/.planning/phases/05-delivery-evidence/05-01-SUMMARY.md
new file mode 100644
index 00000000..353fdb89
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-01-SUMMARY.md
@@ -0,0 +1,75 @@
+---
+phase: 5
+plan: 1
+subsystem: cli-build-ci
+tags: [go, cli, make, docker, github-actions, sha256]
+provides: [human CLI, immutable-oracle verifier, reproducible local gate, delivery CI]
+affects: [src/cmd/tinycolor-compat, tests/original, Makefile, Dockerfile, .github/workflows]
+tech-stack: [go-standard-library, node-standard-library, github-actions]
+key-files:
+ created:
+ - src/cmd/tinycolor-compat/main_test.go
+ - tests/original/verify.mjs
+ - tests/original/verify.test.mjs
+ - .github/workflows/port.yml
+ modified:
+ - src/cmd/tinycolor-compat/main.go
+ - src/tinycolor/color_test.go
+ - Makefile
+ - Dockerfile
+ deleted:
+ - .github/workflows/deno.yml
+decisions:
+ - One executable selects JSONL mode with zero arguments and human CLI mode with a command.
+ - GNU Make exports a repository-local GOCACHE and derives GOEXE for Windows/Linux artifacts.
+ - CI calls make verify and runs the untouched Deno suite as a separate environment check.
+---
+
+# Phase 5 Plan 1: CLI, Build, and CI Summary
+
+Added tested `parse`, `convert`, `lighten`, `palette`, and `contrast` commands
+without changing the zero-argument JSONL protocol. Added cross-platform oracle
+hash verification, a complete local parity gate, a one-command build, the
+scratch Docker artifact, and one least-privilege port CI workflow.
+
+## TDD and Verification Evidence
+
+- CLI RED: the focused package test failed because `run` was undefined.
+- CLI GREEN: focused command tests, all Go tests, and Node adapter tests passed.
+- Manifest RED: verifier import was missing; malformed-line regression later
+ failed at `path.resolve` before the defensive parser fix.
+- Manifest GREEN: two Node tests passed and direct verification printed
+ `verified: 3`.
+- Formatting: `gofmt -l src` prints no files after the mechanical
+ `color_test.go` formatting fix.
+- Full local equivalents: Go tests/vet, adapter tests, and all 162 fixed cases
+ passed: 9 smoke, 26 parser-hex-rgb, 23 parser, 35 conversion, 69 operations.
+- Windows artifact: `bin/tinycolor.exe parse red` executed successfully.
+
+GNU Make is not installed in the current Windows environment, so `make build`
+and `make verify` were not directly invoked locally. Their individual commands
+passed; the configured Ubuntu CI remains the direct Make/Deno execution gate
+until a remote run is observed.
+
+## Commits
+
+- `df1f9b8` `feat(05-01): add human CLI commands`
+- `02f9ced` `build(05-01): add reproducible verification gate`
+- `a4ad85e` `fix(05-01): make verification gate portable`
+- `bc91502` `ci(05-01): verify port and source suite`
+- `57e1114` `fix(05-01): close delivery review gaps`
+
+## Deviations from Plan
+
+- The Makefile emits `bin/tinycolor.exe` on Windows and `bin/tinycolor` on
+ Linux. This is required for a runnable native Windows artifact.
+- The pre-existing unformatted `src/tinycolor/color_test.go` was formatted in
+ the portability fix because the new non-mutating format gate correctly
+ blocked completion.
+- Final review added manifest absolute/traversal containment, wired verifier
+ regressions into `make verify`, and aligned usage text with `tinycolor`.
+
+## Known External Checks
+
+- GitHub Actions execution has not yet been observed.
+- Deno remains unavailable locally; no original-suite pass is claimed here.
diff --git a/.planning/phases/05-delivery-evidence/05-02-PLAN.md b/.planning/phases/05-delivery-evidence/05-02-PLAN.md
new file mode 100644
index 00000000..951ce851
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-02-PLAN.md
@@ -0,0 +1,110 @@
+---
+phase: 5
+plan: 2
+type: execute
+subsystem: fuzz-benchmark-evidence
+owner: C-mrashis
+tags: [fuzz, differential, benchmark, p99, rss]
+wave: 2
+depends_on: [05-01]
+files_modified: [fuzz/harness.mjs, fuzz/harness.test.mjs, fuzz/validate-log.mjs, fuzz/validate-log.test.mjs, fuzz/README.md, fuzz/log.txt, bench/workload.jsonl, bench/run.mjs, bench/run.test.mjs, bench/methodology.md, bench/results.json, Makefile]
+autonomous: true
+requirements: [DEL-01, QLT-03]
+must_haves:
+ truths:
+ - "A checked-in harness runs identical public requests against JavaScript and Go for at least 60 continuous seconds."
+ - "The fuzz log records the actual seed, elapsed duration, case count, and every divergence; zero is claimed only when measured."
+ - "Original and port benchmarks use one workload and report startup p99, latency p99, throughput, and peak RSS or an explicit unsupported value."
+ artifacts:
+ - path: "fuzz/harness.mjs"
+ provides: "Seeded persistent-process differential fuzz runner"
+ - path: "fuzz/log.txt"
+ provides: "Published output of an actual 60-second session"
+ - path: "bench/results.json"
+ provides: "Machine-readable same-host benchmark evidence"
+ key_links:
+ - from: "fuzz/harness.mjs"
+ to: "compat/js-runner.mjs"
+ via: "persistent JSONL child process"
+ pattern: "compat/js-runner.mjs"
+ - from: "bench/run.mjs"
+ to: "bench/workload.jsonl"
+ via: "identical request stream"
+ pattern: "workload.jsonl"
+---
+
+Publish reproducible differential fuzz and honest original-versus-port performance evidence on shared public workloads.
+@AGENT.md
+@.planning/phases/05-delivery-evidence/05-CONTEXT.md
+@.planning/phases/05-delivery-evidence/05-RESEARCH.md
+@.planning/phases/05-delivery-evidence/05-VALIDATION.md
+@.planning/phases/05-delivery-evidence/05-01-SUMMARY.md
+@compat/js-runner.mjs
+@compat/run.mjs
+@src/cmd/tinycolor-compat/main.go
+@compat/cases/operations.jsonl
+@fuzz/README.md
+@bench/methodology.md
+@bench/results.json
+
+
+Build a deterministic persistent-process differential fuzz harness
+fuzz/harness.mjs, fuzz/harness.test.mjs, fuzz/README.md, Makefile
+Read `compat/js-runner.mjs`, `compat/run.mjs`, `src/cmd/tinycolor-compat/main.go`, all five `compat/cases/*.jsonl` files, `fuzz/README.md`, and the Phase 5 research fuzz section.
+
+RED: create Node built-in tests for a seeded PRNG (seed 1 repeats the same first sequence), request generation (stable IDs and only supported operations), CLI option validation (`--duration` positive seconds and integer `--seed`), and a one-second end-to-end run that returns a summary with elapsedSeconds >= 1, cases > 0, and divergences 0. Run `node --test fuzz/harness.test.mjs` and confirm RED.
+
+GREEN: implement `fuzz/harness.mjs` with Node standard libraries only. Parse `--duration ` default 60 and `--seed ` default 20260801. Build `bin/tinycolor` once through `go -C src build -o ../bin/tinycolor ./cmd/tinycolor-compat`, start one `node compat/js-runner.mjs` and one `bin/tinycolor` child, and exchange newline-delimited requests sequentially. Generate seeded string/object inputs across parse/output, modify, mix, readability, isReadable, and palette operations using adapter-supported method names and bounded numeric edge values. Compare parsed response objects with `isDeepStrictEqual`; print each mismatch as one JSON line containing request, JavaScript, and Go. Always print final `duration_seconds`, `seed`, `cases`, and `divergences`; terminate children; exit 1 for divergence or protocol/process failure. Add `make fuzz` for the exact 60-second command and replace the placeholder README with reproduction and claim rules. Run tests and a one-second smoke, then commit `feat(05-02): add differential fuzz harness`.
+
+
+- `node --test fuzz/harness.test.mjs` exits 0 and includes a one-second real differential run.
+- Two runs with `--duration 1 --seed 1` generate the same request prefix and both report zero divergences.
+- Harness source contains no `Math.random`, dependency import, shell-out to the JavaScript oracle from the Go port, or test-suite mutation.
+- `make fuzz` invokes `node fuzz/harness.mjs --duration 60 --seed 20260801`.
+
+node --test fuzz/harness.test.mjs; node fuzz/harness.mjs --duration 1 --seed 1; rg -n "duration 60|seed 20260801" Makefile fuzz/README.md
+A public seeded harness can sustain a real differential session without recompiling or spawning per case.
+
+
+
+Record the actual 60-second fuzz survivor evidence
+fuzz/validate-log.mjs, fuzz/validate-log.test.mjs, fuzz/log.txt
+Read `fuzz/harness.mjs`, `fuzz/README.md`, and any existing `fuzz/log.txt`. Confirm `make verify` is green before recording evidence.
+
+RED: add `fuzz/validate-log.test.mjs` using Node's built-in test runner and temporary files. Assert rejection of duration 59.999, wrong seed, zero cases, nonzero divergences, malformed numeric values, and every missing field; assert one log with duration 60, seed 20260801, positive cases, and zero divergences passes. Run the focused test and confirm RED because the validator does not exist.
+
+GREEN: add `fuzz/validate-log.mjs` as a small standard-library parser that exports its validation function, reads a log path when run directly, requires a numeric `duration_seconds >= 60`, exact integer `seed === 20260801`, integer `cases > 0`, and integer `divergences === 0`, prints `fuzz log verified`, and exits 1 with the failed field otherwise. Run `node fuzz/harness.mjs --duration 60 --seed 20260801` and capture its unedited stdout in `fuzz/log.txt`; do not hand-write, truncate, or normalize duration/case count. If divergences are nonzero, retain the failed log outside the final artifact, stop this plan, and open a separate regression/root-cause task owned by the implicated module. That recovery task must commit the deterministic corpus row and root-cause fix separately before this evidence task is rerun. Do not edit corpora, tests, or source within this log-only task. After focused negative/positive tests and the real log validator pass, commit validator, tests, and final log as `test(05-02): record 60-second fuzz session`.
+
+
+- `node fuzz/validate-log.mjs fuzz/log.txt` exits 0 only when duration is at least 60 seconds, seed is 20260801, cases is positive, and divergences is zero.
+- `node --test fuzz/validate-log.test.mjs` proves every invalid/missing field fails and a valid log passes.
+- The log contains no claim that cannot be regenerated by the documented command.
+- `make verify` exits 0 after the recorded run.
+
+node --test fuzz/validate-log.test.mjs; node fuzz/harness.mjs --duration 1 --seed 20260801; node fuzz/validate-log.mjs fuzz/log.txt; make verify
+The repository contains genuine, reproducible Differential Fuzz Survivor evidence.
+
+
+
+Measure shared-workload p99, RSS, startup, and throughput
+bench/workload.jsonl, bench/run.mjs, bench/run.test.mjs, bench/methodology.md, bench/results.json, Makefile
+Read `compat/cases/smoke.jsonl`, `compat/cases/conversion.jsonl`, `compat/cases/operations.jsonl`, `compat/js-runner.mjs`, `src/cmd/tinycolor-compat/main.go`, `bench/methodology.md`, and `bench/results.json`.
+
+RED: add Node built-in tests for `percentile99([1,2,3,4]) === 4`, workload parsing, results-schema validation, and `node bench/run.mjs --quick --output ` producing nonnegative startup/latency/RSS and positive throughput for both `javascript` and `go` without modifying `bench/results.json`. Confirm RED.
+
+GREEN: create a fixed `bench/workload.jsonl` of representative parse, conversion, modifier, palette, and readability requests copied from existing public corpora. Implement `bench/run.mjs` with Node standard libraries and required `--output ` support; default output is `bench/results.json`, while tests and `--quick` verification always pass a temporary output path. Build Go once; hash the workload; collect at least 20 cold-start samples normally (3 with `--quick`) by starting each runner, sending one request, receiving one response, and stopping it; collect at least 1,000 persistent request samples normally (30 quick) for latency and throughput; sort nanosecond samples and select index `ceil(0.99*n)-1`; sample peak child RSS from `/proc//status` on Linux and `Get-Process -Id ` on Windows, emitting null plus a limitation string only when unsupported. Write results containing generatedAt, OS, architecture, CPU, Go/Node versions, workload SHA-256, sample counts, and for both implementations `startupP99Ms`, `latencyP99Ms`, `throughputOpsPerSecond`, and `peakRssBytes`. Write the exact same-host method, lifecycle, sample counts, percentile formula, RSS technique, and limitations in `bench/methodology.md`. Add `make bench` for a full default-output run. Run unit and quick measurement to temporary files, then run `node bench/run.mjs --output bench/results.json` exactly once; retain the actual full result and commit `perf(05-02): publish shared benchmark evidence`.
+
+
+- `node --test bench/run.test.mjs` and `node bench/run.mjs --quick --output ` exit 0 without modifying `bench/results.json`.
+- `bench/results.json` parses and contains all four required metrics for both implementations, or only `peakRssBytes:null` with a nonempty RSS limitation on an unsupported platform.
+- `bench/results.json` records normal-mode sample counts of at least 20 cold starts and 1,000 persistent requests; quick mode can never write this committed path.
+- `bench/methodology.md` explicitly states these are same-host observations and not universal speedup claims.
+
+node --test bench/run.test.mjs; $tmp = Join-Path ([System.IO.Path]::GetTempPath()) 'tinycolor-bench-quick.json'; node bench/run.mjs --quick --output $tmp; node -e "const r=require('./bench/results.json'); if(r.samples.coldStarts<20||r.samples.requests<1000)process.exit(1); for (const k of ['javascript','go']) for (const m of ['startupP99Ms','latencyP99Ms','throughputOpsPerSecond','peakRssBytes']) if (!(m in r.implementations[k])) process.exit(1)"; git diff --check
+The repository contains reproducible, machine-readable original-versus-port measurements for p99 latency, RSS, startup, and throughput.
+
+
+Run `make verify`, the one-second fuzz smoke, validate the committed 60-second log, run benchmark tests, and parse results JSON. Never replace a failed or unsupported measurement with an estimated value.
+- DEL-01 includes an actual differential fuzz session and shared-workload benchmark report.
+- QLT-03 can reproduce each evidence artifact from checked-in code and commands.
+
diff --git a/.planning/phases/05-delivery-evidence/05-02-SUMMARY.md b/.planning/phases/05-delivery-evidence/05-02-SUMMARY.md
new file mode 100644
index 00000000..a5da7be9
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-02-SUMMARY.md
@@ -0,0 +1,38 @@
+---
+phase: 5
+plan: 2
+subsystem: fuzz-benchmark-evidence
+tags: [fuzz, differential, benchmark, p99, rss]
+provides: [validated 60-second fuzz log, shared-workload benchmark evidence]
+---
+
+# Phase 5 Plan 2 summary
+
+Published a deterministic persistent-process differential harness and validated
+60-second zero-divergence log, then measured the original JavaScript adapter and
+Go adapter with one representative workload on the same host.
+
+## Evidence
+
+- `fuzz/log.txt`: 60.012 seconds, seed 20260801, 1,091,630 cases, 0 divergences.
+- `bench/results.json`: 20 cold starts and 1,000 persistent requests per runtime.
+- JavaScript: 41.4131 ms startup p99, 0.2795 ms latency p99, 7,834.77 ops/s,
+ 42,868,736-byte peak RSS.
+- Go: 11.0109 ms startup p99, 0.1808 ms latency p99, 16,756.79 ops/s,
+ 12,668,928-byte peak RSS.
+- Benchmark tests, normalized workload SHA-256 validation, all Node checks, all
+ 164 fixed corpus cases, Go tests, and Go vet passed.
+
+GNU Make is unavailable in the local PowerShell environment, so its constituent
+commands were executed directly with the repository-local Go cache. The final
+Ubuntu Actions run remains the direct `make verify` proof.
+
+## Commits
+
+- `6b4506a`, `0e54f1a`: persistent fuzz harness and exact-parity fixes.
+- `ad69b3e`: validated 60-second fuzz evidence.
+- `03683ec`: shared benchmark workload, runner, tests, methodology, and results.
+
+## Remaining external action
+
+Record and publish the five-minute demo video; no URL is claimed yet.
diff --git a/.planning/phases/05-delivery-evidence/05-03-PLAN.md b/.planning/phases/05-delivery-evidence/05-03-PLAN.md
new file mode 100644
index 00000000..359a62a8
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-03-PLAN.md
@@ -0,0 +1,149 @@
+---
+phase: 5
+plan: 3
+type: execute
+subsystem: submission-documentation-closeout
+owner: D-deepali
+tags: [readme, decisions, compatibility, attribution, demo, closeout]
+wave: 3
+depends_on: [05-02]
+files_modified: [README.md, PORT.md, DECISIONS.md, COMPATIBILITY.md, docs/ARCHITECTURE.md, docs/TESTING.md, docs/DEMO.md, docs/TEAM-OWNERSHIP.md, .planning/STATE.md, .planning/ROADMAP.md, .planning/phases/05-delivery-evidence/05-03-SUMMARY.md, .planning/phases/05-delivery-evidence/05-VERIFICATION.md]
+autonomous: false
+requirements: [DEL-01, QLT-03]
+must_haves:
+ truths:
+ - "A judge can find build, CLI, verification, fuzz, benchmark, limitations, attribution, and demo instructions from the root README."
+ - "Every number and bonus claim points to checked-in evidence and every unavailable external action remains explicitly unclaimed."
+ - "At least ten architectural divergences have concrete rationales and the milestone closes only after the full gate passes."
+ artifacts:
+ - path: "README.md"
+ provides: "Judge-facing migration, build, use, evidence, and limitation guide"
+ - path: "DECISIONS.md"
+ provides: "At least ten non-trivial accepted architectural decisions"
+ - path: "docs/DEMO.md"
+ provides: "Reproducible five-minute live demo script"
+ - path: ".planning/phases/05-delivery-evidence/05-VERIFICATION.md"
+ provides: "Goal-backward Phase 5 completion evidence"
+ key_links:
+ - from: "README.md"
+ to: "fuzz/log.txt"
+ via: "evidence link and reproduction command"
+ pattern: "fuzz/log.txt"
+ - from: "README.md"
+ to: "bench/results.json"
+ via: "benchmark evidence link"
+ pattern: "bench/results.json"
+---
+
+Turn measured Phase 5 outputs into an honest, judge-readable submission and close the roadmap with reproducible evidence.
+@AGENT.md
+@.planning/phases/05-delivery-evidence/05-CONTEXT.md
+@.planning/phases/05-delivery-evidence/05-RESEARCH.md
+@.planning/phases/05-delivery-evidence/05-VALIDATION.md
+@.planning/phases/05-delivery-evidence/05-01-SUMMARY.md
+@.planning/phases/05-delivery-evidence/05-02-SUMMARY.md
+@README.md
+@PORT.md
+@DECISIONS.md
+@COMPATIBILITY.md
+@LICENSE
+@.port-mortem.toml
+@docs/ARCHITECTURE.md
+@docs/TESTING.md
+@docs/TEAM-OWNERSHIP.md
+@fuzz/log.txt
+@bench/methodology.md
+@bench/results.json
+
+
+Write the judge-facing port documentation and decision log
+README.md, PORT.md, DECISIONS.md, docs/ARCHITECTURE.md, docs/TESTING.md, docs/DEMO.md, docs/TEAM-OWNERSHIP.md
+Read all listed files completely plus `LICENSE`, `.port-mortem.toml`, `Makefile`, CLI help output, `fuzz/README.md`, `fuzz/log.txt`, `bench/methodology.md`, and `bench/results.json`. Extract actual command output and numbers before writing claims.
+
+Replace the upstream-oriented root README with a concise Track H port guide containing: migration rationale; exact pinned source URL/commit and MIT attribution; required tree; `make build` and Docker build/run; CLI syntax and examples for all five commands including `--json`; Go package example using the existing exported API; `make verify`; untouched-oracle manifest; links to fixed corpus, 60-second fuzz log, benchmark results/methodology, compatibility matrix, decisions, ownership, and demo script; known limitations; and a submission checklist. Preserve a link to the original upstream README/history rather than copying its entire usage manual. Reduce `PORT.md` to a compatibility redirect or remove it if README now contains every unique fact.
+
+Expand `DECISIONS.md` from seven to at least twelve substantive rows by adding: dual human/JSONL executable, cross-platform SHA-256 manifest verifier, seeded standard-library fuzzing, persistent children for evidence runners, shared benchmark workload/lifecycle, and honest external-evidence policy. Each row must name a real divergence and rationale; no empty bonus-padding bullets. Update architecture/testing documents to match actual commands only. Add `docs/DEMO.md` with a timestamped five-minute sequence: clean status/hash check, build, CLI parse/convert/lighten/palette/contrast, fixed differential corpus, fuzz log/reproduction, benchmark results, decisions/limitations, and closing artifact path. Keep public GitHub visibility and actual video URL as unchecked manual items. Update ownership only to record Phase 5 integration handoffs; do not reassign completed Phase 1-4 source ownership. Commit `docs(05-03): write submission guide and decisions`.
+
+
+- README contains `make build`, `make verify`, all five CLI command names, `fuzz/log.txt`, `bench/results.json`, `DECISIONS.md`, upstream URL, kickoff commit, and MIT attribution.
+- `DECISIONS.md` has at least twelve non-header table rows, each with nonempty decision and why columns.
+- `docs/DEMO.md` has a five-minute sequence and explicitly marks video recording/upload and public-visibility checks as manual/unverified until completed.
+- Documentation contains no `TBD`, `TODO`, `not_run`, obsolete `bin/tinycolor-compat`, or unsupported whole-suite/CI/video claim.
+
+rg -n "make build|make verify|parse|convert|lighten|palette|contrast|fuzz/log.txt|bench/results.json|bgrins/TinyColor|MIT" README.md; node -e "const fs=require('fs'); const rows=fs.readFileSync('DECISIONS.md','utf8').split(/\r?\n/).filter(x=>/^\| D-/.test(x)); if(rows.length<12) process.exit(1)"; if (rg -n "TBD|TODO|not_run|bin/tinycolor-compat" README.md DECISIONS.md COMPATIBILITY.md docs) { exit 1 }; git diff --check
+The submission explains what was ported, how to reproduce it, what differs, and which external deliverables still require human action.
+
+
+
+Refresh honest parity, coverage, safety, benchmark, and bonus evidence
+COMPATIBILITY.md
+Read `COMPATIBILITY.md`, every fixed corpus, `fuzz/log.txt`, `bench/results.json`, `tests/original/manifest.sha256`, and the actual output from the commands below. Do not carry forward stale Phase 4 counts without rerunning them.
+
+Run the complete gate and capture exact case/pass/mismatch counts per corpus. Run `go -C src test -cover ./...` and record package coverage separately; do not invent a JavaScript coverage comparison when Deno coverage is unavailable. Count Go escape hatches with `rg -n '\bunsafe\b' src -g '*.go'` and report the exact code occurrence count, excluding documentation. Verify the three oracle hashes. Read, do not recompute by hand, the fuzz duration/seed/cases/divergences and benchmark metrics. Update `COMPATIBILITY.md` with a current evidence table and dedicated sections for fixed-corpus pass rate per file, original-suite/Deno status, Go coverage by package, unsafe count, fuzz session, benchmark summary/link, known differences, and bonus eligibility. Claim Differential Fuzz Survivor only for a >=60 second zero-divergence log; claim Zero Unsafe only for zero Go source occurrences; claim Decision Log only when at least ten substantive decisions exist. State CI as configured until a GitHub run URL is verified, public repository status as unverified locally, and demo video as not yet supplied. Commit `docs(05-03): publish final compatibility evidence`.
+
+
+- Every `compat/cases/*.jsonl` file has an explicit `passed/total` and mismatch count in `COMPATIBILITY.md` matching fresh output.
+- Go coverage is labeled by package/tool command and is not described as JavaScript coverage parity.
+- Unsafe count is numeric and includes the exact `rg` reproduction command.
+- Fuzz/bonus claims exactly match `fuzz/log.txt`; benchmark numbers link to `bench/results.json` and retain same-host limitation.
+- Deno, CI execution, repository visibility, and demo video are each marked passed only with observed evidence, otherwise configured/unverified/not supplied.
+
+make verify; go -C src test -cover ./...; node tests/original/verify.mjs; node -e "const r=require('./bench/results.json'); if(!r.implementations?.javascript||!r.implementations?.go) process.exit(1)"; rg -n "smoke.jsonl|parser-hex-rgb.jsonl|parser.jsonl|conversion.jsonl|operations.jsonl|unsafe|coverage|fuzz/log.txt|bench/results.json|Deno|video" COMPATIBILITY.md; git diff --check
+Every score-relevant statement is traceable to a checked-in command or explicitly labeled external/unverified.
+
+
+
+Run the final clean local gate and write verification evidence
+.planning/phases/05-delivery-evidence/05-03-SUMMARY.md, .planning/phases/05-delivery-evidence/05-VERIFICATION.md
+Read all Phase 5 plans/summaries, `05-VALIDATION.md`, `.planning/STATE.md`, `.planning/ROADMAP.md`, `README.md`, `COMPATIBILITY.md`, and actual final command output. Inspect `git status --short` before editing planning state.
+
+Run `make build`, every CLI command once in JSON mode, `make verify`, `node fuzz/validate-log.mjs fuzz/log.txt`, the one-second fuzz smoke, benchmark tests/schema validation using a temporary quick-output path, oracle hash verifier, and `git diff --check`. Confirm `git diff -- mod.js test.js tinycolor.js` is empty and compare current SHA-256 values to the manifest. Create `05-03-SUMMARY.md` listing task commits, commands, exact evidence, deviations, and remaining external actions. Create `05-VERIFICATION.md` with goal-backward evidence for each Phase 5 success criterion and DEL-01/QLT-03; distinguish local pass, configured CI, and external/unverified items. Commit these local evidence documents as `docs(05-03): verify local delivery evidence`. Do not change ROADMAP or STATE yet.
+
+
+- `make build`, `make verify`, focused fuzz/benchmark checks, oracle verification, and `git diff --check` all exit 0 in the closing session.
+- `git diff -- mod.js test.js tinycolor.js` is empty and hashes match the pinned manifest.
+- `05-VERIFICATION.md` maps all three roadmap success criteria and DEL-01/QLT-03 to concrete paths/commands.
+- `05-VERIFICATION.md` leaves public clone access and CI execution pending until observed evidence is supplied.
+- Worktree is clean immediately after this evidence commit except for ignored build/cache artifacts.
+
+make build; ./bin/tinycolor parse --json red; ./bin/tinycolor convert --to hsl --json red; ./bin/tinycolor lighten --amount 10 --json '#000'; ./bin/tinycolor palette --type triad --json red; ./bin/tinycolor contrast --json '#000' '#fff'; make verify; node fuzz/validate-log.mjs fuzz/log.txt; node fuzz/harness.mjs --duration 1 --seed 20260801; node --test bench/run.test.mjs; node tests/original/verify.mjs; git diff --check; git diff --exit-code -- mod.js test.js tinycolor.js
+All local delivery evidence is green and the remaining external proof is explicit.
+
+
+
+Verify public clone access and a successful CI run
+none
+Read the repository `origin` URL, `.github/workflows/port.yml`, the current commit hash, and the pending external-evidence section of `05-VERIFICATION.md`.
+
+Resolve the public repository URL from `git remote get-url origin`. From an unauthenticated context, verify the repository can be read and cloned. Verify a GitHub Actions run for the exact Phase 5 commit completed successfully and capture its URL. If publishing the current branch or changing repository visibility is required, stop and request the user's explicit external-state authorization. Also report whether the five-minute demo video URL has been supplied; absence of a video does not permit a false claim. Do not mark the phase complete without public clone evidence and a successful CI run URL.
+
+
+- An unauthenticated public repository URL and exact successful CI run URL are available for the current Phase 5 commit.
+- Any required push or visibility change was explicitly authorized by the user.
+- Video status is reported honestly as verified URL or not supplied.
+
+git remote get-url origin; git rev-parse HEAD
+Public clone access and CI execution are observed rather than inferred from configuration.
+
+
+
+Close Phase 5 planning state after external proof
+.planning/STATE.md, .planning/ROADMAP.md, .planning/phases/05-delivery-evidence/05-VERIFICATION.md
+Read the successful checkpoint evidence, all Phase 5 summaries, `.planning/STATE.md`, `.planning/ROADMAP.md`, and `05-VERIFICATION.md`.
+
+Add the public repository and successful CI run URLs to `05-VERIFICATION.md`, keeping video status honest. Mark Phase 5 and its three plans complete in ROADMAP, set completion date 2026-08-01, and update progress to 3/3. Update STATE with current CLI, corpus, fuzz, benchmark, public-clone, and CI evidence; remove the stale 58/58 Phase 4 operations count in favor of the fresh 69/69 result and retain the Deno/video status supported by actual evidence. Run `git diff --check`, then commit `docs(05): close delivery evidence phase`.
+
+
+- ROADMAP shows Phase 5 `3/3`, Complete, `2026-08-01` only after external proof exists.
+- STATE includes the public repository and successful CI evidence and contains no stale 58/58 operations count.
+- `05-VERIFICATION.md` includes the exact public repository and CI run URLs and does not invent a video URL.
+
+rg -n "3/3|Complete|2026-08-01" .planning/ROADMAP.md; if (rg -n "58/58" .planning/STATE.md) { exit 1 }; git diff --check
+Phase 5 closes only after both local and external delivery proof are real.
+
+
+No roadmap completion is allowed before all automated commands pass and public clone plus CI execution are observed. Video publication remains explicitly unclaimed unless a verified URL is supplied.
+- DEL-01 is judge-readable, measurable, and demonstrable from a clean checkout.
+- QLT-03 maps every claim to a reproducible command while immutable source hashes remain unchanged.
+- Phase 5 and the milestone close with no hidden known difference or unsupported score claim.
+
diff --git a/.planning/phases/05-delivery-evidence/05-03-SUMMARY.md b/.planning/phases/05-delivery-evidence/05-03-SUMMARY.md
new file mode 100644
index 00000000..81f95182
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-03-SUMMARY.md
@@ -0,0 +1,45 @@
+---
+phase: 5
+plan: 3
+subsystem: submission-documentation-closeout
+tags: [readme, decisions, compatibility, verification]
+provides: [judge-facing guide, honest compatibility evidence, local closeout proof]
+status: local-complete-external-pending
+---
+
+# Phase 5 Plan 3 summary
+
+Replaced the upstream-oriented landing page with a concise port guide, expanded
+the architectural decision log to 15 substantive entries, published current
+compatibility/coverage/safety/benchmark evidence, and added a five-minute demo
+script with its human actions explicitly unclaimed.
+
+## Task commits
+
+- `82a9532` `docs(05-03): write submission guide and decisions`
+- `7c2da11` `docs(05-03): publish final compatibility evidence`
+- Closing local-verification commit: this summary and `05-VERIFICATION.md`.
+
+## Fresh local evidence
+
+- Native build and JSON invocations of parse, convert, lighten, palette, and
+ contrast exited zero.
+- 15 Node tests passed with zero failures.
+- Go tests and vet passed using the repository-local Go cache.
+- Fixed corpora passed 9/9, 26/26, 23/23, 35/35, and 71/71.
+- Fresh one-second seed-20260801 fuzz: 11,103 cases, zero divergences.
+- Recorded fuzz log validated: 60.012 seconds, 1,091,630 cases, zero divergences.
+- Benchmark schema and normalized workload SHA-256 verified; quick measurement
+ wrote only to a temporary path.
+- Immutable Deno suite: 45 passed, 0 failed, 1 ignored.
+- Three oracle hashes verified; `git diff -- mod.js test.js tinycolor.js` empty.
+
+GNU Make is unavailable in this Windows PowerShell environment, so the exact
+commands behind `make build` and `make verify` were executed directly. The
+Ubuntu CI run remains the direct Make proof.
+
+## Remaining external actions
+
+- Push the current `rajeet` commit and verify the exact GitHub Actions run.
+- Verify unauthenticated public access to the repository.
+- Human: record, upload, and link the five-minute demo video.
diff --git a/.planning/phases/05-delivery-evidence/05-CONTEXT.md b/.planning/phases/05-delivery-evidence/05-CONTEXT.md
new file mode 100644
index 00000000..db064835
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-CONTEXT.md
@@ -0,0 +1,128 @@
+# Phase 5: Delivery Evidence - Context
+
+**Gathered:** 2026-08-01
+**Status:** Ready for planning
+**Source:** Approved Phase 5 delivery design
+
+
+## Phase Boundary
+
+Ship the completed Go port as a reproducible Track H submission. This phase
+adds the human CLI, clean-checkout verification, CI, differential fuzz evidence,
+honest benchmark evidence, and judge-facing documentation. It does not add new
+TinyColor behavior or change the immutable JavaScript oracle.
+
+
+
+
+## Implementation Decisions
+
+### Submission layout
+- Keep `README.md`, `DECISIONS.md`, `Dockerfile`, `src/`, `tests/original/`,
+ `tests/port/`, `fuzz/`, `bench/`, and `.port-mortem.toml` in their current
+ top-level locations.
+- Do not create a parallel submission tree.
+- Preserve `mod.js`, `tinycolor.js`, `test.js`, `npm/`, `dist/`, and `demo/` as
+ immutable oracle material.
+
+### CLI and build
+- Reuse the existing Go compatibility binary and `tinycolor` package.
+- Provide `parse`, `convert`, `lighten`, `palette`, and `contrast` commands.
+- Every human command supports deterministic `--json` output.
+- Preserve JSONL compatibility mode for `compat/run.mjs`.
+- `make build` produces `bin/tinycolor`; Docker builds the same executable.
+
+### Verification and CI
+- `make verify` checks Go formatting, tests, vet, immutable oracle hashes,
+ adapter tests, and every fixed differential corpus.
+- GitHub Actions calls the documented repository commands instead of duplicating
+ a separate CI-only procedure.
+- Do not claim the untouched Deno suite passed unless Deno is installed and
+ `deno task test` actually succeeds.
+
+### Differential fuzzing
+- Use Node and Go standard libraries only.
+- Run identical generated public-API requests through the checked-in JavaScript
+ oracle and Go port.
+- Publish a real `fuzz/log.txt` from at least 60 continuous seconds, including
+ duration, seed, case count, and divergence count.
+- Claim Differential Fuzz Survivor only if the recorded run has zero divergences.
+
+### Benchmarks
+- Use a shared workload for JavaScript and Go on the same machine.
+- Report cold startup, latency p99, throughput, and peak RSS with sample counts,
+ tool versions, commands, and limitations.
+- Store machine-readable values in `bench/results.json` and methodology in
+ `bench/methodology.md`; do not generalize one machine's result.
+
+### Documentation
+- Make the root README describe the Go port, migration rationale, one-command
+ build, CLI examples, verification, evidence, known limitations, and upstream
+ attribution.
+- Record at least ten substantive decisions in `DECISIONS.md`.
+- Report corpus pass rates, oracle hash status, unsafe count, coverage where
+ measured, and all known limitations in `COMPATIBILITY.md`.
+- Keep team ownership and MIT attribution explicit.
+- Add a five-minute demo script. Do not claim a recorded or published video
+ without an actual video artifact or URL.
+
+### the agent's Discretion
+- Exact CLI flag spelling and JSON field arrangement, provided outputs are stable
+ and tests demonstrate all five required commands.
+- Exact deterministic fuzz input distribution and benchmark sample sizes.
+- CI runner versions and artifact naming, provided clean-checkout commands match
+ local documentation.
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Approved delivery contract
+- `docs/superpowers/specs/2026-08-01-phase-5-delivery-evidence-design.md` — locked
+ Phase 5 structure, evidence, and honest-claim rules.
+- `.planning/ROADMAP.md` — Phase 5 goal and success criteria.
+- `.planning/REQUIREMENTS.md` — DEL-01 and QLT-03 requirements.
+
+### Project behavior and boundaries
+- `AGENT.md` — immutable oracle, ownership, checks, and definition of done.
+- `PLAN.md` — Wave 4 delivery scope and gate.
+- `src/cmd/tinycolor-compat/main.go` — existing JSONL command boundary to reuse.
+- `compat/run.mjs` — existing differential execution path.
+- `tests/original/manifest.sha256` — pinned oracle hashes.
+
+### Existing delivery files
+- `Makefile` — current build and test entry points.
+- `Dockerfile` — current one-command artifact build.
+- `DECISIONS.md` — accepted architectural decisions.
+- `COMPATIBILITY.md` — current evidence and honest limitations.
+- `docs/TEAM-OWNERSHIP.md` — contributor responsibilities.
+
+
+
+
+## Specific Ideas
+
+- Favor the attainable Differential Fuzz Survivor, Zero Unsafe, and Decision Log
+ bonus evidence.
+- Keep every implementation slice in a separate micro-commit so one feature can
+ be reverted without undoing unrelated Phase 5 work.
+
+
+
+
+## Deferred Ideas
+
+- A GUI, web service, package publication, transpiler, FFI, and JavaScript
+ runtime embedding remain out of scope.
+- Bug Catcher is not a planned claim; add it only if differential testing finds
+ a reproducible upstream defect.
+
+
+
+---
+
+*Phase: 05-delivery-evidence*
+*Context gathered: 2026-08-01 from approved delivery design*
diff --git a/.planning/phases/05-delivery-evidence/05-RESEARCH.md b/.planning/phases/05-delivery-evidence/05-RESEARCH.md
new file mode 100644
index 00000000..16f28a38
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-RESEARCH.md
@@ -0,0 +1,158 @@
+# Phase 5 Research: Delivery Evidence
+
+## Scope and current state
+
+Phase 5 is an integration phase, not a new color-behavior phase. The Go package
+and JSONL adapter already cover the required TinyColor operations. Delivery
+work should expose those paths, make every claim reproducible, and replace
+placeholders with measured evidence.
+
+Current gaps:
+
+- `src/cmd/tinycolor-compat/main.go` only reads JSONL from stdin; there is no
+ human command interface or command-level test.
+- `Makefile` builds `bin/tinycolor-compat` and omits conversion/operation
+ corpora, formatting, hash verification, and a single full gate.
+- `.github/workflows/deno.yml` is the upstream Deno build workflow, not the Go
+ port CI gate.
+- `fuzz/README.md`, `bench/methodology.md`, and `bench/results.json` are Phase 1
+ placeholders.
+- The root `README.md` describes the JavaScript package rather than the port.
+- `DECISIONS.md` has seven entries; the Decision Log bonus needs ten substantive
+ decisions.
+- `COMPATIBILITY.md` has correct fixed-corpus evidence but no Phase 5 CLI, hash,
+ fuzz, benchmark, unsafe, or coverage evidence.
+
+## Recommended architecture
+
+### Human CLI without a second binary
+
+Keep `cmd/tinycolor-compat` as the only executable. Select JSONL mode when no
+positional arguments are present and human mode when a command is present.
+Move argument parsing and rendering into small functions in the same package so
+`main_test.go` can test them without process fixtures. Reuse `tinycolor.Parse`,
+conversion methods, modifiers, palettes, and readability directly; do not route
+human commands through encoded JSONL requests.
+
+Use Go's `flag.FlagSet` per command. Required surface:
+
+- `parse `: inspection/state.
+- `convert --to hex|hex8|rgb|percentage-rgb|hsl|hsv|name`.
+- `lighten [--amount 10]`.
+- `palette --type complement|splitcomplement|triad|tetrad|analogous|monochromatic`.
+- `contrast `: ratio and AA/AAA small/large booleans.
+- `--json` on every command; stable object/array output through `encoding/json`.
+
+Usage errors must return status 2, parse/runtime errors status 1, and success 0.
+Invalid TinyColor input remains a successful black-like TinyColor value because
+that is source behavior; JSON inspection exposes `valid: false`.
+
+### One source of truth for checks
+
+Add `tests/original/verify.mjs` using `node:crypto` to validate
+`manifest.sha256` on Windows and Linux. Expand the Makefile:
+
+- `make build` -> `bin/tinycolor`.
+- `make test` -> port-owned unit/adapter/differential checks.
+- `make verify` -> formatting check, tests, vet, oracle hash verification, and
+ all five fixed corpora.
+- `make fuzz` and `make bench` -> documented evidence generators.
+
+CI should install pinned Go, Node, and Deno versions; call `make verify`; run the
+untouched `deno task test`; and build the binary. Do not duplicate individual
+test commands in YAML beyond the Deno-only source-suite check.
+
+### Differential fuzzing
+
+The current `compat/run.mjs` starts `go run` once per case, which is too slow
+for a 60-second fuzz session. `fuzz/harness.mjs` should build the Go binary once,
+start one JavaScript runner and one Go runner, stream the same JSONL requests to
+both, and compare response lines with `isDeepStrictEqual`. Use an explicit
+seeded PRNG and public operations already accepted by the adapter. Print a
+machine-readable header/footer and every mismatch request. Exit nonzero on any
+divergence.
+
+The committed `fuzz/log.txt` must be output from an actual `--duration 60`
+session. A deterministic seed makes a mismatch reproducible even though the
+case count varies by machine. The fixed corpus remains the correctness gate;
+the fuzz run is additional evidence.
+
+### Honest benchmarks
+
+Use `bench/workload.jsonl` as the identical request stream for both runners.
+`bench/run.mjs` should build once, measure multiple cold process starts, then
+measure a persistent runner for latency and throughput. Compute p99 by sorting
+all elapsed samples and selecting `ceil(0.99*n)-1`. Sample peak RSS while each
+child is alive: read `/proc//status` on Linux and query
+`Get-Process -Id ` on Windows. If RSS sampling is unsupported, emit `null`
+and a limitation instead of inventing a value.
+
+`bench/results.json` needs timestamp, OS/CPU, Go/Node versions, workload hash,
+sample counts, and for each implementation: startup p99, request latency p99,
+throughput, and peak RSS. Run both implementations on the same host in one
+command and state that results are local observations, not universal speedups.
+
+### Documentation
+
+Rewrite `README.md` for judges but retain the upstream project link and MIT
+attribution. Link detailed evidence instead of duplicating it. Expand
+`DECISIONS.md` with the CLI dual mode, immutable manifest verification,
+standard-library fuzz harness, shared benchmark workload, and honest Deno/video
+claim policy. Update `COMPATIBILITY.md` only after commands have produced fresh
+results. Add `docs/DEMO.md` as a five-minute live script; video recording remains
+a manual submission action.
+
+## Pitfalls
+
+- Go `flag` stops parsing at the first positional argument. Either document
+ flags before values or normalize the small supported grammar before parsing;
+ tests must lock the chosen syntax.
+- Do not make invalid TinyColor inputs CLI errors; that would contradict source
+ behavior.
+- Keep JSONL stdin mode byte-compatible with existing adapter tests.
+- Do not benchmark `go run`; compile once before measuring.
+- Do not compare a hot Go function with a cold Node process. Shared workload and
+ lifecycle are required.
+- A generated zero-divergence log is credible only when duration and seed are
+ recorded and the harness itself is checked in.
+- `unsafe` is a lexical/code audit for this pure-Go repository; report the exact
+ command and count rather than merely saying "safe".
+- Go coverage and Deno coverage measure different suites. Report them separately
+ and do not describe their percentage difference as behavioral parity.
+
+## Validation Architecture
+
+### Fast feedback
+
+| Change | Command | Expected result |
+|---|---|---|
+| CLI | `go -C src test ./cmd/tinycolor-compat` | exit 0 |
+| Hash verifier | `node tests/original/verify.mjs` | 3 files verified |
+| Fuzz harness | `node fuzz/harness.mjs --duration 1 --seed 1` | zero divergences |
+| Benchmark runner | `node bench/run.mjs --quick` | valid `bench/results.json` |
+| Documentation | `git diff --check` | exit 0 |
+
+### Full phase gate
+
+```powershell
+make build
+make verify
+node fuzz/harness.mjs --duration 60 --seed 20260801
+node bench/run.mjs
+git diff --check
+```
+
+Then verify `tests/original/manifest.sha256` still matches, validate
+`bench/results.json` with `JSON.parse`, confirm `fuzz/log.txt` records at least
+60 seconds and zero divergences before claiming the bonus, and run
+`deno task test` only where Deno is installed.
+
+### Requirement mapping
+
+| Requirement | Automated evidence |
+|---|---|
+| DEL-01 | CLI tests, `make build`, CI, fuzz run, benchmark JSON, documentation checks |
+| QLT-03 | `make verify`, oracle manifest verifier, fixed corpora, clean-checkout CI |
+
+Existing Go tests and JSONL corpora are sufficient infrastructure. No new test
+framework or dependency is needed.
diff --git a/.planning/phases/05-delivery-evidence/05-VALIDATION.md b/.planning/phases/05-delivery-evidence/05-VALIDATION.md
new file mode 100644
index 00000000..ab84d897
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-VALIDATION.md
@@ -0,0 +1,69 @@
+---
+phase: 5
+slug: delivery-evidence
+status: draft
+nyquist_compliant: true
+wave_0_complete: true
+created: 2026-08-01
+---
+
+# Phase 5 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Go `testing`, Node built-in modules, existing JSONL differential harness |
+| **Config file** | `src/go.mod`, `deno.json`, `Makefile` |
+| **Quick run command** | `go -C src test ./...` |
+| **Full suite command** | `make verify` |
+| **Estimated runtime** | quick under 10 seconds; full under 5 minutes |
+
+## Sampling Rate
+
+- **After every task commit:** Run the task's focused command and `go -C src test ./...` when Go changed.
+- **After every plan wave:** Run `make verify`.
+- **Before `$gsd-verify-work`:** `make verify`, the recorded fuzz command, and benchmark validation must be green.
+- **Max feedback latency:** 10 seconds for focused tests; 5 minutes for the full gate.
+
+## Per-Task Verification Map
+
+| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|-----------|-------------------|-------------|--------|
+| 05-01-01 | 01 | 1 | DEL-01 | CLI unit | `go -C src test ./cmd/tinycolor-compat` | ✅ | ⬜ pending |
+| 05-01-02 | 01 | 1 | QLT-03 | integration | `make build && make verify` | ✅ make / ❌ expanded targets | ⬜ pending |
+| 05-02-01 | 02 | 2 | DEL-01 | differential fuzz | `node fuzz/harness.mjs --duration 1 --seed 1` | ❌ Wave 2 | ⬜ pending |
+| 05-02-02 | 02 | 2 | DEL-01 | fuzz evidence | `node --test fuzz/validate-log.test.mjs && node fuzz/validate-log.mjs fuzz/log.txt` | ❌ Wave 2 | ⬜ pending |
+| 05-02-03 | 02 | 2 | DEL-01 | benchmark smoke | `node bench/run.mjs --quick --output ` | ❌ Wave 2 | ⬜ pending |
+| 05-03-01 | 03 | 3 | DEL-01 | evidence audit | `node tests/original/verify.mjs` | ❌ Wave 1 | ⬜ pending |
+| 05-03-02 | 03 | 3 | QLT-03 | full gate | `make verify` | ✅ make / ❌ expanded target | ⬜ pending |
+| 05-03-04 | 03 | 3 | DEL-01 | external checkpoint | public clone + exact CI run URL | manual | ⬜ pending |
+| 05-03-05 | 03 | 3 | QLT-03 | closeout audit | `git diff --check` | ✅ | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+## Wave 0 Requirements
+
+Existing Go tests, Node adapter tests, JSONL corpora, and Make are sufficient.
+Each new runner supplies its own focused standard-library check; no framework
+installation or test scaffold is required.
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| Five-minute video is actually recorded and published | DEL-01 | Recording and public hosting require a human account and final presentation | Follow `docs/DEMO.md`, record one continuous run, publish it, then add the verified URL to README |
+| Public GitHub visibility | DEL-01 | Repository visibility is external account state | Open the GitHub repository logged out and confirm source and release instructions are readable |
+
+## Validation Sign-Off
+
+- [x] All planned tasks have an automated focused command or existing infrastructure.
+- [x] Sampling continuity has no three consecutive tasks without automated verification.
+- [x] Existing infrastructure covers Wave 0.
+- [x] No watch-mode flags are used.
+- [x] Focused feedback latency target is under 10 seconds.
+- [x] `nyquist_compliant: true` is set in frontmatter.
+
+**Approval:** approved 2026-08-01
diff --git a/.planning/phases/05-delivery-evidence/05-VERIFICATION.md b/.planning/phases/05-delivery-evidence/05-VERIFICATION.md
new file mode 100644
index 00000000..d0316953
--- /dev/null
+++ b/.planning/phases/05-delivery-evidence/05-VERIFICATION.md
@@ -0,0 +1,56 @@
+---
+phase: 5
+status: verified
+verified: 2026-08-01
+requirements: [DEL-01, QLT-03]
+---
+
+# Phase 5 verification
+
+## Goal
+
+A judge can build and use one runnable Go artifact, reproduce exact parity
+evidence, inspect honest performance/safety numbers, and distinguish observed
+local evidence from external actions.
+
+## Roadmap success criteria
+
+| Criterion | Status | Evidence |
+|---|---|---|
+| CI runs format, vet, Go tests, and differential checks | Passed | [Run 30686980719](https://github.com/rajeet-04/TinyColor/actions/runs/30686980719) passed for exact commit `1c218b669d192e37b7019b08395cf348410dde79`. |
+| CLI supports parse, convert, lighten, palette, contrast, and JSON | Local pass | `make build` equivalent plus all five JSON invocations exited zero; implementation under `src/cmd/tinycolor-compat`. |
+| Benchmarks, docs, attribution, ownership, and known differences are complete | Local pass | `bench/results.json`, `bench/methodology.md`, `README.md`, `LICENSE`, `DECISIONS.md`, `COMPATIBILITY.md`, `docs/TEAM-OWNERSHIP.md`. |
+
+## DEL-01 evidence
+
+- One-command build is defined by `make build`; direct Windows build produced
+ `bin/tinycolor.exe` and all five public commands ran successfully.
+- `tests/original/manifest.sha256` verified all three immutable oracle files.
+- Five fixed corpora passed 164/164 with zero mismatches.
+- `fuzz/log.txt` validates a 60.012-second, seed-20260801 run with 1,091,630
+ cases and zero divergences; a fresh one-second smoke passed 11,103 cases.
+- `bench/results.json` contains 20 cold starts, 1,000 persistent requests, and
+ both implementations' startup p99, latency p99, throughput, and peak RSS.
+- `docs/DEMO.md` is ready; recording/upload and its URL remain human actions.
+
+## QLT-03 evidence
+
+- 15 Node verification, fuzz, and benchmark tests passed.
+- All Go packages passed tests and vet; package coverage is recorded in
+ `COMPATIBILITY.md` without claiming JavaScript coverage parity.
+- `rg -n '\bunsafe\b' src -g '*.go'` returned zero source occurrences.
+- Deno source suite passed 45, failed 0, ignored 1.
+- `git diff --check` passed, source diff was empty, and benchmark workload hash
+ matched the committed normalized SHA-256.
+
+## External checkpoint
+
+- Public repository: https://github.com/rajeet-04/TinyColor
+- Unauthenticated `git ls-remote` returned branch `rajeet` at exact commit
+ `1c218b669d192e37b7019b08395cf348410dde79`.
+- Exact-commit CI: [GitHub Actions run 30686980719](https://github.com/rajeet-04/TinyColor/actions/runs/30686980719)
+ passed `make verify`, `deno test test.js`, `make build`, and artifact upload.
+- Demo video: not supplied and explicitly unclaimed.
+
+Phase 5 delivery evidence is verified. The demo recording remains the only
+human submission action.
diff --git a/.port-mortem.toml b/.port-mortem.toml
new file mode 100644
index 00000000..e2f31c43
--- /dev/null
+++ b/.port-mortem.toml
@@ -0,0 +1,4 @@
+track = "H"
+source_url = "https://github.com/bgrins/TinyColor.git"
+kickoff_commit = "b49018c9f2dbca313d80d7a4dad25e26143cfe01"
+oracle_manifest = "tests/original/manifest.sha256"
diff --git a/AGENT.md b/AGENT.md
new file mode 100644
index 00000000..63e35f31
--- /dev/null
+++ b/AGENT.md
@@ -0,0 +1,105 @@
+# TinyColor.js to Go — Working Agreement
+
+## Mission
+
+Build a fresh Go implementation of the public behavior in this checkout of
+[`bgrins/TinyColor`](https://github.com/bgrins/TinyColor), without changing,
+copying into, or replacing its JavaScript implementation. The Go port is a
+compatibility project: observable TinyColor behavior is the specification.
+
+## Repository boundaries
+
+- `mod.js`, `tinycolor.js`, `test.js`, `npm/`, `dist/`, and `demo/` are the
+ immutable JavaScript oracle. Do not edit them except for a separately agreed
+ upstream maintenance change.
+- New Go work belongs under `src/`; `src/go.mod` is the port module boundary.
+- Oracle adapters, generated fixtures, and mismatch reports belong under
+ `compat/`. Do not rewrite the original test suite to make it pass.
+- `tests/original/manifest.sha256` pins the immutable root oracle. Port-owned
+ adapter tests live in `tests/port/`; differential fuzzing and benchmarks live
+ in `fuzz/` and `bench/` respectively.
+- Human-facing project docs belong in `docs/`; compatibility status belongs in
+ `COMPATIBILITY.md`.
+
+## Non-negotiable behavior rules
+
+1. Preserve permissive parsing: whitespace, optional commas/parentheses,
+ uppercase input, no-`#` hex, percentages, and object inputs.
+2. Preserve TinyColor's validation, clamping, hue wrapping, alpha normalization,
+ format selection, rounding, string output, and invalid-input-as-black
+ behavior. A more idiomatic result is not a compatible result.
+3. Preserve mutation where JavaScript mutates (`setAlpha`, instance modifiers)
+ and return independent values where it returns new colors (`clone`, palettes,
+ static utilities).
+4. Treat a differential mismatch as a defect until a documented source-version
+ difference proves otherwise. Do not add broad floating-point tolerances.
+
+## Source-of-truth map
+
+| Behavior | Read first |
+|---|---|
+| Constructor, output methods, instance mutation | `mod.js` lines 5–358 |
+| Input normalization and color conversion | `mod.js` lines 359–654 |
+| Manipulation, palettes, readability | `mod.js` lines 655–1046 |
+| Parsing regexes, names, low-level helpers | `mod.js` lines 1047–end |
+| Existing expected behavior | `test.js` |
+| Public usage and supported formats | `README.md` |
+| Project choices | `DECISIONS.md`, `docs/ARCHITECTURE.md` |
+
+## Go API direction
+
+Expose an idiomatic, explicit API while keeping a JSON-compatible adapter for
+exact comparison. Start with a `Color` value and `Parse(any) (Color, error)`
+for Go callers; retain `Valid()`, `Format()`, `Original()`, output methods,
+modifiers, palettes, and readability methods that mirror TinyColor names in Go
+style. The compatibility adapter—not the public API—may expose dynamic input
+and operation names.
+
+Do not finalize exported signatures until Phase 1 proves the adapter can
+represent every source test category. Any intentional API divergence needs an
+entry in `DECISIONS.md` and must not affect the parity adapter.
+
+## Workflow and ownership
+
+Follow the phases in `PLAN.md` and `.planning/ROADMAP.md`. Keep commits small
+and single-purpose. A pull request must state:
+
+```text
+Feature implemented:
+Source behavior checked:
+Differential command and result:
+Known differences (or none):
+```
+
+| Owner | Primary boundary | Cannot merge without |
+|---|---|---|
+| A — Parsing/model | `src/internal/color`, `src/internal/parser` | parser differential cases |
+| B — Conversion/API | `src/tinycolor`, formatting/readability/palettes | deterministic parity tests |
+| C — Compatibility/quality | `compat`, `src/testdata`, `COMPATIBILITY.md` | reproducible report |
+| D — Delivery | CLI, CI, benchmarks, docs | clean-checkout commands |
+
+Coordinate through exported contracts, never by editing another owner’s files
+without agreement. C can add a regression fixture for any mismatch; the owner
+of the implicated module fixes it.
+
+## Required checks
+
+Run the narrowest applicable check while developing, then run the phase gate:
+
+```powershell
+go test ./... # from src/
+node compat/js-runner.mjs < cases.json # from repository root
+go test -run TestDifferential ./... # from src/
+go vet ./... # from src/
+gofmt -w
+```
+
+Once Deno is installed, additionally run the untouched source suite with
+`deno task test`. Its current absence is an environment limitation, not a
+passing test result.
+
+## Definition of done
+
+A feature is done only when its source behavior is mapped, Go unit tests pass,
+the matching oracle cases pass, the compatibility matrix is updated, and any
+remaining mismatch is named with input, JavaScript result, Go result, and owner.
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
new file mode 100644
index 00000000..97925316
--- /dev/null
+++ b/COMPATIBILITY.md
@@ -0,0 +1,79 @@
+# Compatibility evidence
+
+Target: `bgrins/TinyColor` commit
+`b49018c9f2dbca313d80d7a4dad25e26143cfe01`, pinned by
+`tests/original/manifest.sha256`.
+
+## Fixed differential corpora
+
+Fresh command: `node compat/run.mjs `.
+
+| Corpus | Passed/total | Mismatches |
+|---|---:|---:|
+| `compat/cases/smoke.jsonl` | 9/9 | 0 |
+| `compat/cases/parser-hex-rgb.jsonl` | 26/26 | 0 |
+| `compat/cases/parser.jsonl` | 23/23 | 0 |
+| `compat/cases/conversion.jsonl` | 35/35 | 0 |
+| `compat/cases/operations.jsonl` | 71/71 | 0 |
+| **Total** | **164/164** | **0** |
+
+Random-color behavior is checked through validity, alpha, and channel-range
+invariants rather than exact equality between independent random generators.
+
+## Source suite and oracle integrity
+
+- `node tests/original/verify.mjs`: 3/3 kickoff hashes verified.
+- `deno test test.js`: 45 passed, 0 failed, 1 ignored against the source oracle.
+- `node tests/original-go/run.mjs`: 45 passed, 0 failed, 1 ignored against the
+ native Go binary using a byte-identical temporary copy of `test.js`.
+- The ignored `polyad` test is also ignored by the pinned upstream suite.
+
+The Go-backed facade preserves JavaScript constructor identity, mutation, and
+chaining required by the original assertions. It does not import the source
+implementation or contain TinyColor color algorithms.
+
+## Go coverage and safety
+
+Fresh command: `go -C src test -cover ./...`.
+
+| Package | Statement coverage |
+|---|---:|
+| `cmd/tinycolor-compat` | 33.6% |
+| `internal/color` | 90.5% |
+| `internal/compat` | 57.1% |
+| `internal/parser` | 85.8% |
+| `tinycolor` | 92.8% |
+
+These are Go package coverage figures, not a JavaScript coverage comparison.
+`rg -n '\bunsafe\b' src -g '*.go'` reports **0 Go source occurrences**.
+
+## Differential fuzzing
+
+[`fuzz/log.txt`](fuzz/log.txt) records 60.012 seconds, seed 20260801,
+1,091,630 cases, and zero divergences. Validate it with
+`node fuzz/validate-log.mjs fuzz/log.txt`. This supports the Differential Fuzz
+Survivor claim; exact comparison remains enabled.
+
+## Same-host benchmark
+
+The committed [results](bench/results.json) were measured on Windows x64 with
+20 cold starts and 1,000 persistent requests per implementation:
+
+| Implementation | Startup p99 | Latency p99 | Throughput | Peak RSS |
+|---|---:|---:|---:|---:|
+| JavaScript | 41.4131 ms | 0.2795 ms | 7,834.77 ops/s | 42,868,736 bytes |
+| Go | 11.0109 ms | 0.1808 ms | 16,756.79 ops/s | 12,668,928 bytes |
+
+See [`bench/methodology.md`](bench/methodology.md). These are same-host
+observations, not universal speedup claims.
+
+## Bonus and external evidence status
+
+- Differential Fuzz Survivor: eligible from the validated 60-second log.
+- Zero Unsafe: eligible from zero Go source occurrences.
+- Decision Log: eligible from 17 substantive decisions.
+- GitHub Actions: [`rajeet` branch runs](https://github.com/rajeet-04/TinyColor/actions?query=branch%3Arajeet) execute the full gate, source-oracle Deno suite, build, and artifact upload.
+- Public repository: https://github.com/rajeet-04/TinyColor.
+- Five-minute demo video: not supplied.
+
+No known mismatch remains in the fixed corpus or recorded fuzz session.
diff --git a/DECISIONS.md b/DECISIONS.md
new file mode 100644
index 00000000..da4676f5
--- /dev/null
+++ b/DECISIONS.md
@@ -0,0 +1,21 @@
+# Decisions
+
+| ID | Decision | Why | Status |
+|---|---|---|---|
+| D-001 | Port the checked-out `mod.js` behavior to Go. | It is the exact source and test target selected at kickoff. | Accepted |
+| D-002 | Keep JavaScript oracle files immutable. | Differential evidence is credible only when the reference cannot move with the port. | Accepted |
+| D-003 | Put the independent Go module in `src/`. | It follows the submission layout while keeping the JavaScript oracle at the root. | Accepted |
+| D-004 | Use JSON Lines adapters over stdin/stdout. | Both runtimes can compare dynamic inputs and operations without editing source tests. | Accepted |
+| D-005 | Prefer the Go and Node standard libraries. | TinyColor's algorithms and evidence runners need no third-party dependency or supply-chain surface. | Accepted |
+| D-006 | Preserve JavaScript quirks at the adapter boundary while exposing typed Go values. | Compatibility semantics should not force dynamic JavaScript shapes onto every Go caller. | Accepted |
+| D-007 | Run the pinned source suite explicitly as `deno test test.js`. | Broad Deno discovery includes generated npm and port-owned tests outside the original suite. | Accepted |
+| D-008 | Use a finite V8-derived luminance transfer table. | Go `math.Pow` differs by one ULP for some rounded channels; the finite table preserves exact parity without tolerance. | Accepted |
+| D-009 | Keep human CLI commands and zero-argument JSONL mode in one executable. | One artifact serves judges and automated differential tools without duplicate color logic. | Accepted |
+| D-010 | Verify kickoff files with a cross-platform SHA-256 manifest parser. | Hash checks must behave consistently with pinned CRLF oracle files and reject path traversal. | Accepted |
+| D-011 | Seed the differential fuzzer and use persistent child processes. | A reproducible request prefix plus one process per runtime gives sustained public-API coverage without startup noise. | Accepted |
+| D-012 | Compare fuzz responses exactly. | Broad floating-point tolerances would hide observable compatibility defects. | Accepted |
+| D-013 | Benchmark both adapters with the same fixed JSONL workload and lifecycle. | Shared inputs and same-host cold/persistent runs make the measurements directly reproducible. | Accepted |
+| D-014 | Normalize benchmark workload newlines before hashing. | Evidence hashes remain stable across Windows and Unix checkouts without changing request content. | Accepted |
+| D-015 | Publish unsupported or external deliverables as unverified. | Honest missing evidence is preferable to unreproducible CI, public-access, or video claims. | Accepted |
+| D-016 | Return an empty typed Go palette for non-positive counts instead of reproducing TinyColor's non-terminating negative `analogous` loop. | Compatibility must not make a public Go call hang indefinitely; adapter zero values still retain TinyColor's documented defaults. | Accepted |
+| D-017 | Run a byte-identical copy of `test.js` through a test-only synchronous facade that invokes the native Go binary. | This executes the original assertions against the port without editing kickoff files, copying color algorithms into JavaScript, or adding a second WebAssembly implementation. | Accepted |
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..9f44b078
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,10 @@
+FROM golang:1.26 AS build
+WORKDIR /app/src
+COPY src/go.mod ./
+RUN go mod download
+COPY src/ ./
+RUN go build -o /tinycolor ./cmd/tinycolor-compat
+
+FROM scratch
+COPY --from=build /tinycolor /tinycolor
+ENTRYPOINT ["/tinycolor"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..4b12c059
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+.PHONY: build test test-original-go verify fmt-check hashes fuzz bench clean
+
+GOCACHE ?= $(CURDIR)/.cache/go-build
+export GOCACHE
+GOEXE := $(shell go env GOEXE)
+BINARY := bin/tinycolor$(GOEXE)
+
+build:
+ node -e "require('node:fs').mkdirSync('bin', { recursive: true })"
+ go -C src build -o ../$(BINARY) ./cmd/tinycolor-compat
+
+test:
+ node tests/port/adapter.test.mjs
+ node --test tests/original/verify.test.mjs tests/original-go/run.test.mjs
+ node --test fuzz/harness.test.mjs fuzz/validate-log.test.mjs
+ node fuzz/validate-log.mjs fuzz/log.txt
+ go -C src test ./...
+ node compat/run.mjs compat/cases/smoke.jsonl
+ node compat/run.mjs compat/cases/parser-hex-rgb.jsonl
+ node compat/run.mjs compat/cases/parser.jsonl
+ node compat/run.mjs compat/cases/conversion.jsonl
+ node compat/run.mjs compat/cases/operations.jsonl
+
+test-original-go:
+ node tests/original-go/run.mjs
+
+verify: fmt-check hashes test test-original-go
+ go -C src vet ./...
+
+fmt-check:
+ node -e "const { readdirSync } = require('node:fs'); const { join } = require('node:path'); const { execFileSync } = require('node:child_process'); const files = []; const walk = (dir) => readdirSync(dir, { withFileTypes: true }).forEach((entry) => entry.isDirectory() ? walk(join(dir, entry.name)) : entry.name.endsWith('.go') && files.push(join(dir, entry.name))); walk('src'); const output = execFileSync('gofmt', ['-l', ...files], { encoding: 'utf8' }).trim(); if (output) { console.error(output); process.exit(1); }"
+
+hashes:
+ node tests/original/verify.mjs
+
+fuzz:
+ node fuzz/harness.mjs --duration 60 --seed 20260801
+
+bench:
+ node bench/run.mjs --output bench/results.json
+
+clean:
+ node -e "const fs = require('node:fs'); for (const file of ['bin/tinycolor', 'bin/tinycolor.exe']) fs.rmSync(file, { force: true });"
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 00000000..84fdeb53
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,134 @@
+# Delivery Plan — TinyColor.js to Go
+
+## Outcome
+
+Ship an independent Go library and CLI that reproduce the behavior of this
+checkout's `mod.js`, prove parity with a repeatable Node-to-Go differential
+harness, and explain every known difference. The original JavaScript source
+remains intact in the repository.
+
+## Guardrails
+
+- Compatibility is against the checked-out source, not a remembered npm API.
+- The port uses only the Go standard library unless a dependency is approved in
+ `DECISIONS.md`.
+- Do not claim whole-suite parity while `deno task test` is unavailable.
+- A zero mismatch report covers only the exercised corpus; label the corpus and
+ command precisely.
+
+## Waves
+
+| Wave | Scope | Owners | Exit gate |
+|---|---|---|---|
+| 0 | Baseline and oracle protocol | C, D | one JSON case reaches Node and Go |
+| 1 | Color model plus HEX/RGB/name parsing | A, C | parser corpus is differential-green |
+| 2 | HSL/HSV, conversion, formatting, analysis | A, B, C | conversion and output corpus is green |
+| 3 | Mutation, utilities, readability, palettes | B, C | source categories are represented and green |
+| 4 | CLI, CI, benchmarks, release documentation | D, all | clean checkout can verify and demonstrate |
+
+## Wave 0 — Foundation and behavioral oracle
+
+1. Create `src/` as an independent module; add a minimal public package, a JSON
+ Lines request/response protocol, and a Go runner executable. Implement only
+ the fixed smoke-corpus inputs needed to prove the protocol; all generalized
+ parsing and public API behavior remains in later waves.
+2. Add `compat/js-runner.mjs`, which imports local `./mod.js`, accepts the same
+ request object, and serializes values deterministically. The process must
+ report structured errors rather than swallowing JavaScript exceptions.
+3. Add a differential driver that launches both runners, normalizes JSON
+ numbers only where JSON itself requires it, and emits each mismatch with
+ case ID, operation, source output, Go output, and suspected package.
+4. Seed `compat/cases/smoke.jsonl` from existing tests: `red`, `#000`, invalid
+ text, transparent, `rgba(255, 0, 0, .5)`, HSL, HSV, objects, and `fromRatio`.
+5. Record baseline tool versions and the missing-Deno constraint in
+ `COMPATIBILITY.md`.
+
+**Gate:** `node compat/run.mjs compat/cases/smoke.jsonl` exits non-zero until
+the Go operation exists, then exits zero and prints a case count and mismatch
+count. The original JavaScript files remain unmodified (`git diff --` shows no
+changes under the oracle paths).
+
+## Wave 1 — Normalized model and parser parity
+
+1. Define private normalized RGBA storage, validity, detected format, original
+ input metadata, and explicit Go input structs. Keep the dynamic decoder only
+ in the compatibility boundary.
+2. Port `bound01`, `boundAlpha`, percentage conversion, and hue wrapping before
+ implementing parsers. Add boundary regressions for negative, overflow,
+ `1`, `1.0`, `%`, alpha, and invalid values.
+3. Implement HEX (`3/4/6/8`), RGB/RGBA, CSS percentage RGB, HSL/HSLA,
+ HSV/HSVA, named colors, and `transparent` by following `stringInputToObject`
+ and `inputToRGB` in `mod.js` exactly.
+4. Convert every parser-related `Deno.test` group into JSONL cases or explicit
+ Go table cases that invoke both runners. Do not hand-copy expected outputs
+ from memory.
+
+**Gate:** all cases categorized Parsing, Object Input, Invalid Input, Names,
+and Alpha normalize with zero unexplained mismatches. `COMPATIBILITY.md` names
+the corpus revision and count.
+
+## Wave 2 — Conversion, representation, and analysis
+
+1. Port RGB↔HSL, RGB↔HSV, RGB/hex/ARGB conversion with JavaScript-equivalent
+ rounding at each observable boundary.
+2. Implement `ToRGB`, percentage RGB, HSL, HSV, hex/hex8, name, filter, and
+ general string formatting. Test alpha fallback in `toString` and compact
+ hex behavior independently.
+3. Implement brightness, luminance, `IsDark`, `IsLight`, equality, random
+ (injectable randomness for tests), and cloning behavior.
+4. Add exact-string and exact-number cases from the existing conversion,
+ formatting, filter, and analysis tests.
+
+**Gate:** all output strings match byte-for-byte; numeric JSON fields match
+source results or a documented IEEE-754 serialization normalization. No blanket
+epsilon comparison is allowed.
+
+## Wave 3 — Manipulation and combinations
+
+1. Implement static and instance equivalents of lighten, brighten, darken,
+ saturate, desaturate, greyscale, and spin. Verify defaults, explicit zero,
+ clamping, hue wrap, alpha preservation, and receiver mutation.
+2. Implement `Mix`, readability, `IsReadable`, and `MostReadable` using the
+ source decision paths and WCAG option defaults.
+3. Implement complement, analogous, monochromatic, split complement, triad,
+ and tetrad; preserve output order and source default counts.
+4. Add seeded randomized inputs and preserve every discovered mismatch as a
+ deterministic JSONL regression before fixing it.
+
+**Gate:** source categories Modifications, Spin, Mix, Readability, and every
+palette family are covered by deterministic cases; fuzz seeds reproduce every
+failure.
+
+## Wave 4 — Submission-quality delivery
+
+1. Add a `tinycolor` CLI with `parse`, `convert`, `lighten`, `palette`, and
+ `contrast` commands. Its `--json` output must use the compatibility schema.
+2. Add GitHub Actions for Go formatting, tests, vet, differential smoke tests,
+ and platform builds. Keep Node as the oracle runtime; add Deno source-test
+ execution only when its runner is provisioned in CI.
+3. Add Go benchmarks for parsing, conversion, manipulation, allocations, and
+ the documented Node comparison methodology. Do not claim direct speedups
+ from incomparable machines or workloads.
+4. Finish README additions, architecture, testing guide, demo script,
+ compatibility matrix, decisions, and attribution/license notices.
+
+**Gate:** a new contributor can clone, run the documented setup, execute Go
+tests and the differential report, use the CLI, read performance methodology,
+and see the known-difference list.
+
+## Explicitly deferred
+
+- Any behavior outside the current `mod.js` and `test.js` checkout.
+- CSS Color Level 4 syntaxes not accepted by this source.
+- Replacing the Go API with a JavaScript interpreter or vendoring TinyColor.
+- A GUI, web service, or package publication before parity evidence exists.
+
+## Risk controls
+
+| Risk | Control |
+|---|---|
+| JavaScript coercion differs from Go | keep coercion in adapter; turn each mismatch into a fixture |
+| Floating point string drift | compare rendered strings; document only narrow JSON normalization |
+| Unchanged tests are hard to call from Go | use JSONL adapters, never edit `test.js` |
+| Four people collide | enforce module ownership and wave gates |
+| Deno is absent locally | Node adapter is the working oracle; mark Deno suite unverified |
diff --git a/PORT.md b/PORT.md
new file mode 100644
index 00000000..5e296449
--- /dev/null
+++ b/PORT.md
@@ -0,0 +1,4 @@
+# Port guide
+
+The complete build, usage, compatibility, evidence, and limitation guide is in
+[`README.md`](README.md).
diff --git a/README.md b/README.md
index 71b6d767..e2d1418b 100644
--- a/README.md
+++ b/README.md
@@ -1,492 +1,135 @@
-# TinyColor
+# TinyColor Go port
-## JavaScript color tooling
+An idiomatic Go port of the behavior in the pinned
+[`bgrins/TinyColor`](https://github.com/bgrins/TinyColor) checkout. The
+JavaScript source remains in this repository as an immutable oracle. Exact
+JSONL differential checks and a byte-identical original-suite runner exercise
+the compiled Go port without modifying the source tests.
-TinyColor is a small, fast library for color manipulation and conversion in JavaScript. It allows many forms of input, while providing color conversions and other color utility functions. It has no dependencies.
+Source commit: `b49018c9f2dbca313d80d7a4dad25e26143cfe01`. TinyColor and this port retain
+Brian Grinstead's MIT license in [`LICENSE`](LICENSE). The original JavaScript
+project documentation remains available in the upstream repository history.
-## Including in node
+## Build once
-`tinycolor` can be installed from npm:
+Requirements: Go 1.26+, Node.js, and GNU Make.
- npm install tinycolor2
-
-Then it can be used in your script like so:
-
-```js
-var tinycolor = require("tinycolor2");
-var color = tinycolor("red");
+```sh
+make build
```
-Or in a module like so:
+This creates `bin/tinycolor` on Linux/macOS or `bin/tinycolor.exe` on Windows.
+Docker produces the same runnable CLI in one command:
-```js
-import tinycolor from "tinycolor2";
-var color = tinycolor("red");
+```sh
+docker build -t tinycolor-go .
+docker run --rm tinycolor-go parse --json red
```
-## Including in a browser
-
-The package can be bundled from npm, but if you prefer to download it locally you have two choices:
-
-### ESM
-
-It can be used as a module by downloading [npm/esm/tinycolor.js](https://github.com/bgrins/TinyColor/blob/master/npm/esm/tinycolor.js) or using https://esm.sh/tinycolor2.
-
-```html
-
-```
+## Try it in GitHub Codespaces
-### UMD
+[](https://codespaces.new/rajeet-04/TinyColor?ref=rajeet&quickstart=1)
-You can use it directly in a script tag by downloading the UMD file from [npm/cjs/tinycolor.js](https://github.com/bgrins/TinyColor/blob/master/npm/cjs/tinycolor.js):
+The Codespaces environment installs Go, Node.js, Deno, and GNU Make, then
+builds the native CLI. In the terminal, run:
-```html
-
-
+```sh
+./bin/tinycolor parse --json red
+./bin/tinycolor convert --to hsl --json red
+./bin/tinycolor palette --type triad --json red
```
-## Usage
-
-Call `tinycolor(input)` or `new tinycolor(input)`, and you will have an object with the following properties. See Accepted String Input and Accepted Object Input below for more information about what is accepted.
-
-## Accepted String Input
-
-The string parsing is very permissive. It is meant to make typing a color as input as easy as possible. All commas, percentages, parenthesis are optional, and most input allow either 0-1, 0%-100%, or 0-n (where n is either 100, 255, or 360 depending on the value).
-
-HSL and HSV both require either 0%-100% or 0-1 for the `S`/`L`/`V` properties. The `H` (hue) can have values between 0%-100% or 0-360.
-
-RGB input requires either 0-255 or 0%-100%.
-
-If you call `tinycolor.fromRatio`, RGB and Hue input can also accept 0-1.
+The browser launch link opens the `rajeet` branch used for this submission.
-Here are some examples of string input:
+## CLI
-### Hex, 8-digit (RGBA) Hex
-```js
-tinycolor("#000");
-tinycolor("000");
-tinycolor("#369C");
-tinycolor("369C");
-tinycolor("#f0f0f6");
-tinycolor("f0f0f6");
-tinycolor("#f0f0f688");
-tinycolor("f0f0f688");
+```sh
+./bin/tinycolor parse --json red
+./bin/tinycolor convert --to hsl --json red
+./bin/tinycolor lighten --amount 10 --json '#000'
+./bin/tinycolor palette --type triad --json red
+./bin/tinycolor contrast --json '#000' '#fff'
```
-### RGB, RGBA
-```js
-tinycolor("rgb (255, 0, 0)");
-tinycolor("rgb 255 0 0");
-tinycolor("rgba (255, 0, 0, .5)");
-tinycolor({ r: 255, g: 0, b: 0 });
-tinycolor.fromRatio({ r: 1, g: 0, b: 0 });
-tinycolor.fromRatio({ r: .5, g: .5, b: .5 });
-```
-### HSL, HSLA
-```js
-tinycolor("hsl(0, 100%, 50%)");
-tinycolor("hsla(0, 100%, 50%, .5)");
-tinycolor("hsl(0, 100%, 50%)");
-tinycolor("hsl 0 1.0 0.5");
-tinycolor({ h: 0, s: 1, l: .5 });
-tinycolor.fromRatio({ h: 1, s: 0, l: 0 });
-tinycolor.fromRatio({ h: .5, s: .5, l: .5 });
-```
-### HSV, HSVA
-```js
-tinycolor("hsv(0, 100%, 100%)");
-tinycolor("hsva(0, 100%, 100%, .5)");
-tinycolor("hsv (0 100% 100%)");
-tinycolor("hsv 0 1 1");
-tinycolor({ h: 0, s: 100, v: 100 });
-tinycolor.fromRatio({ h: 1, s: 0, v: 0 });
-tinycolor.fromRatio({ h: .5, s: .5, v: .5 });
-```
-### Named
-Case insenstive names are accepted, using the [list of colors in the CSS spec](https://www.w3.org/TR/css-color-4/#named-colors).
+Remove `.exe` from the examples on Unix; add it on Windows. Without `--json`,
+commands print human-readable output. `convert` supports `hex`, `hex8`, `rgb`,
+`percentage-rgb`, `hsl`, `hsv`, and `name`; `palette` supports `complement`,
+`splitcomplement`, `triad`, `tetrad`, `analogous`, and `monochromatic`.
-```js
-tinycolor("RED");
-tinycolor("blanchedalmond");
-tinycolor("darkblue");
-```
-### Accepted Object Input
+## Go package
-If you are calling this from code, you may want to use object input. Here are some examples of the different types of accepted object inputs:
+The Go module lives under `src/`:
- { r: 255, g: 0, b: 0 }
- { r: 255, g: 0, b: 0, a: .5 }
- { h: 0, s: 100, l: 50 }
- { h: 0, s: 100, v: 100 }
+```go
+package main
-## Methods
+import (
+ "fmt"
-### getFormat
+ "github.com/rajeet-04/tinycolor-go/tinycolor"
+)
-Returns the format used to create the tinycolor instance
-```js
-var color = tinycolor("red");
-color.getFormat(); // "name"
-color = tinycolor({r:255, g:255, b:255});
-color.getFormat(); // "rgb"
+func main() {
+ color, _ := tinycolor.FromCompat("#336699", false)
+ color.Lighten(10)
+ fmt.Println(color.ToHSLString())
+}
```
-### getOriginalInput
+## Verify parity
-Returns the input passed into the constructor used to create the tinycolor instance
-```js
-var color = tinycolor("red");
-color.getOriginalInput(); // "red"
-color = tinycolor({r:255, g:255, b:255});
-color.getOriginalInput(); // "{r: 255, g: 255, b: 255}"
+```sh
+make verify
+node tests/original-go/run.mjs
+deno test test.js
```
-### isValid
+`make verify` checks formatting, the three kickoff hashes in
+[`tests/original/manifest.sha256`](tests/original/manifest.sha256), Node and Go
+tests, all fixed differential corpora, the recorded fuzz log, the byte-identical
+original suite against Go, and `go vet`. The direct Deno command separately
+checks the untouched source oracle. Both original-suite runs report 45 passed,
+0 failed, and the same upstream `polyad` test ignored.
-Return a boolean indicating whether the color was successfully parsed. Note: if the color is not valid then it will act like `black` when being used with other methods.
-```js
-var color1 = tinycolor("red");
-color1.isValid(); // true
-color1.toHexString(); // "#ff0000"
+Evidence:
-var color2 = tinycolor("not a color");
-color2.isValid(); // false
-color2.toString(); // "#000000"
-```
-### getBrightness
+- [Compatibility matrix](COMPATIBILITY.md) — fixed-corpus, Deno, coverage, and safety counts.
+- [Differential fuzz log](fuzz/log.txt) and [reproduction guide](fuzz/README.md) — 60.012 seconds, seed 20260801, 1,091,630 cases, zero divergences.
+- [Benchmark results](bench/results.json) and [methodology](bench/methodology.md) — same-host startup p99, latency p99, throughput, and peak RSS.
+- [Architectural decisions](DECISIONS.md), [architecture](docs/ARCHITECTURE.md), [testing](docs/TESTING.md), and [team ownership](docs/TEAM-OWNERSHIP.md).
+- [Five-minute demo script](docs/DEMO.md).
+- [GitHub Actions runs for `rajeet`](https://github.com/rajeet-04/TinyColor/actions?query=branch%3Arajeet).
-Returns the perceived brightness of a color, from `0-255`, as defined by [Web Content Accessibility Guidelines (Version 1.0)](http://www.w3.org/TR/AERT#color-contrast).
-```js
-var color1 = tinycolor("#fff");
-color1.getBrightness(); // 255
+## Repository layout
-var color2 = tinycolor("#000");
-color2.getBrightness(); // 0
+```text
+src/ Go module, public API, and CLI
+tests/original/ kickoff hash manifest and verifier
+tests/original-go/ byte-identical suite runner and test-only Go facade
+tests/port/ port-owned adapter tests
+compat/ JSONL oracle, driver, and fixed corpora
+fuzz/ differential harness and 60-second log
+bench/ shared workload, runner, methodology, and results
+docs/ architecture, testing, ownership, and demo guide
```
-### isLight
-
-Return a boolean indicating whether the color's perceived brightness is light.
-```js
-var color1 = tinycolor("#fff");
-color1.isLight(); // true
-var color2 = tinycolor("#000");
-color2.isLight(); // false
-```
-### isDark
-
-Return a boolean indicating whether the color's perceived brightness is dark.
-```js
-var color1 = tinycolor("#fff");
-color1.isDark(); // false
-
-var color2 = tinycolor("#000");
-color2.isDark(); // true
-```
-### getLuminance
-
-Returns the perceived luminance of a color, from `0-1` as defined by [Web Content Accessibility Guidelines (Version 2.0).](http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef)
-```js
-var color1 = tinycolor("#fff");
-color1.getLuminance(); // 1
-
-var color2 = tinycolor("#000");
-color2.getLuminance(); // 0
-```
-### getAlpha
+## Known limits
-Returns the alpha value of a color, from `0-1`.
-```js
-var color1 = tinycolor("rgba(255, 0, 0, .5)");
-color1.getAlpha(); // 0.5
+- Compatibility is claimed for the pinned checkout and measured public corpus,
+ not every future TinyColor revision or arbitrary JavaScript coercion.
+- Random colors are checked by validity/range invariants because independent
+ runtimes do not share a random stream.
+- Benchmark figures are observations from one host, not universal speedup
+ claims.
+- The required demo video must still be recorded, uploaded, and linked by a
+ human; no video URL is claimed in this repository yet.
-var color2 = tinycolor("rgb(255, 0, 0)");
-color2.getAlpha(); // 1
-
-var color3 = tinycolor("transparent");
-color3.getAlpha(); // 0
-```
-### setAlpha
-
-Sets the alpha value on a current color. Accepted range is in between `0-1`.
-```js
-var color = tinycolor("red");
-color.getAlpha(); // 1
-color.setAlpha(.5);
-color.getAlpha(); // .5
-color.toRgbString(); // "rgba(255, 0, 0, .5)"
-```
-### String Representations
-
-The following methods will return a property for the `alpha` value, which can be ignored: `toHsv`, `toHsl`, `toRgb`
-
-### toHsv
-```js
-var color = tinycolor("red");
-color.toHsv(); // { h: 0, s: 1, v: 1, a: 1 }
-```
-### toHsvString
-```js
-var color = tinycolor("red");
-color.toHsvString(); // "hsv(0, 100%, 100%)"
-color.setAlpha(0.5);
-color.toHsvString(); // "hsva(0, 100%, 100%, 0.5)"
-```
-### toHsl
-```js
-var color = tinycolor("red");
-color.toHsl(); // { h: 0, s: 1, l: 0.5, a: 1 }
-```
-### toHslString
-```js
-var color = tinycolor("red");
-color.toHslString(); // "hsl(0, 100%, 50%)"
-color.setAlpha(0.5);
-color.toHslString(); // "hsla(0, 100%, 50%, 0.5)"
-```
-### toHex
-```js
-var color = tinycolor("red");
-color.toHex(); // "ff0000"
-```
-### toHexString
-```js
-var color = tinycolor("red");
-color.toHexString(); // "#ff0000"
-```
-### toHex8
-```js
-var color = tinycolor("red");
-color.toHex8(); // "ff0000ff"
-```
-### toHex8String
-```js
-var color = tinycolor("red");
-color.toHex8String(); // "#ff0000ff"
-```
-### toRgb
-```js
-var color = tinycolor("red");
-color.toRgb(); // { r: 255, g: 0, b: 0, a: 1 }
-```
-### toRgbString
-```js
-var color = tinycolor("red");
-color.toRgbString(); // "rgb(255, 0, 0)"
-color.setAlpha(0.5);
-color.toRgbString(); // "rgba(255, 0, 0, 0.5)"
-```
-### toPercentageRgb
-```js
-var color = tinycolor("red");
-color.toPercentageRgb() // { r: "100%", g: "0%", b: "0%", a: 1 }
-```
-### toPercentageRgbString
-```js
-var color = tinycolor("red");
-color.toPercentageRgbString(); // "rgb(100%, 0%, 0%)"
-color.setAlpha(0.5);
-color.toPercentageRgbString(); // "rgba(100%, 0%, 0%, 0.5)"
-```
-### toName
-```js
-var color = tinycolor("red");
-color.toName(); // "red"
-```
-### toFilter
-```
-var color = tinycolor("red");
-color.toFilter(); // "progid:DXImageTransform.Microsoft.gradient(startColorstr=#ffff0000,endColorstr=#ffff0000)"
-```
-### toString
-
-Print to a string, depending on the input format. You can also override this by passing one of `"rgb", "prgb", "hex6", "hex3", "hex8", "name", "hsl", "hsv"` into the function.
-```js
-var color1 = tinycolor("red");
-color1.toString(); // "red"
-color1.toString("hsv"); // "hsv(0, 100%, 100%)"
-
-var color2 = tinycolor("rgb(255, 0, 0)");
-color2.toString(); // "rgb(255, 0, 0)"
-color2.setAlpha(.5);
-color2.toString(); // "rgba(255, 0, 0, 0.5)"
-```
-### Color Modification
-
-These methods manipulate the current color, and return it for chaining. For instance:
-```js
-tinycolor("red").lighten().desaturate().toHexString() // "#f53d3d"
-```
-### lighten
-
-`lighten: function(amount = 10) -> TinyColor`. Lighten the color a given amount, from 0 to 100. Providing 100 will always return white.
-```js
-tinycolor("#f00").lighten().toString(); // "#ff3333"
-tinycolor("#f00").lighten(100).toString(); // "#ffffff"
-```
-### brighten
+## Submission checklist
-`brighten: function(amount = 10) -> TinyColor`. Brighten the color a given amount, from 0 to 100.
-```js
-tinycolor("#f00").brighten().toString(); // "#ff1919"
-```
-### darken
-
-`darken: function(amount = 10) -> TinyColor`. Darken the color a given amount, from 0 to 100. Providing 100 will always return black.
-```js
-tinycolor("#f00").darken().toString(); // "#cc0000"
-tinycolor("#f00").darken(100).toString(); // "#000000"
-```
-### desaturate
-
-`desaturate: function(amount = 10) -> TinyColor`. Desaturate the color a given amount, from 0 to 100. Providing 100 will is the same as calling `greyscale`.
-```js
-tinycolor("#f00").desaturate().toString(); // "#f20d0d"
-tinycolor("#f00").desaturate(100).toString(); // "#808080"
-```
-### saturate
-
-`saturate: function(amount = 10) -> TinyColor`. Saturate the color a given amount, from 0 to 100.
-```js
-tinycolor("hsl(0, 10%, 50%)").saturate().toString(); // "hsl(0, 20%, 50%)"
-```
-### greyscale
-
-`greyscale: function() -> TinyColor`. Completely desaturates a color into greyscale. Same as calling `desaturate(100)`.
-```js
-tinycolor("#f00").greyscale().toString(); // "#808080"
-```
-### spin
-
-`spin: function(amount = 0) -> TinyColor`. Spin the hue a given amount, from -360 to 360. Calling with 0, 360, or -360 will do nothing (since it sets the hue back to what it was before).
-```js
-tinycolor("#f00").spin(180).toString(); // "#00ffff"
-tinycolor("#f00").spin(-90).toString(); // "#7f00ff"
-tinycolor("#f00").spin(90).toString(); // "#80ff00"
-
-// spin(0) and spin(360) do nothing
-tinycolor("#f00").spin(0).toString(); // "#ff0000"
-tinycolor("#f00").spin(360).toString(); // "#ff0000"
-```
-### Color Combinations
-
-Combination functions return an array of TinyColor objects unless otherwise noted.
-
-### analogous
-
-`analogous: function(, results = 6, slices = 30) -> array`.
-```js
-var colors = tinycolor("#f00").analogous();
-
-colors.map(function(t) { return t.toHexString(); }); // [ "#ff0000", "#ff0066", "#ff0033", "#ff0000", "#ff3300", "#ff6600" ]
-```
-### monochromatic
-
-`monochromatic: function(, results = 6) -> array`.
-```js
-var colors = tinycolor("#f00").monochromatic();
-
-colors.map(function(t) { return t.toHexString(); }); // [ "#ff0000", "#2a0000", "#550000", "#800000", "#aa0000", "#d40000" ]
-```
-### splitcomplement
-
-`splitcomplement: function() -> array`.
-```js
-var colors = tinycolor("#f00").splitcomplement();
-
-colors.map(function(t) { return t.toHexString(); }); // [ "#ff0000", "#ccff00", "#0066ff" ]
-```
-### triad
-
-`triad: function() -> array`.
-```js
-var colors = tinycolor("#f00").triad();
-
-colors.map(function(t) { return t.toHexString(); }); // [ "#ff0000", "#00ff00", "#0000ff" ]
-```
-### tetrad
-
-`tetrad: function() -> array`.
-```js
-var colors = tinycolor("#f00").tetrad();
-
-colors.map(function(t) { return t.toHexString(); }); // [ "#ff0000", "#80ff00", "#00ffff", "#7f00ff" ]
-
-```
-### complement
-
-`complement: function() -> TinyColor`.
-```js
-tinycolor("#f00").complement().toHexString(); // "#00ffff"
-```
-## Color Utilities
-```js
-tinycolor.equals(color1, color2)
-tinycolor.mix(color1, color2, amount = 50)
-```
-### random
-
-Returns a random color.
-```js
-var color = tinycolor.random();
-color.toRgb(); // "{r: 145, g: 40, b: 198, a: 1}"
-```
-
-### Readability
-
-TinyColor assesses readability based on the [Web Content Accessibility Guidelines (Version 2.0)](http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef).
-
-#### readability
-
-`readability: function(TinyColor, TinyColor) -> Object`.
-Returns the contrast ratio between two colors.
-```js
-tinycolor.readability("#000", "#000"); // 1
-tinycolor.readability("#000", "#111"); // 1.1121078324840545
-tinycolor.readability("#000", "#fff"); // 21
-```
-Use the values in your own calculations, or use one of the convenience functions below.
-
-#### isReadable
-
-`isReadable: function(TinyColor, TinyColor, Object) -> Boolean`. Ensure that foreground and background color combinations meet WCAG guidelines. `Object` is optional, defaulting to `{level: "AA",size: "small"}`. `level` can be `"AA"` or "AAA" and `size` can be `"small"` or `"large"`.
-
-Here are links to read more about the [AA](http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html) and [AAA](http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast7.html) requirements.
-```js
-tinycolor.isReadable("#000", "#111", {}); // false
-tinycolor.isReadable("#ff0088", "#5c1a72",{level:"AA",size:"small"}); //false
-tinycolor.isReadable("#ff0088", "#5c1a72",{level:"AA",size:"large"}), //true
-```
-#### mostReadable
-
-`mostReadable: function(TinyColor, [TinyColor, Tinycolor ...], Object) -> Boolean`.
-Given a base color and a list of possible foreground or background colors for that base, returns the most readable color.
-If none of the colors in the list is readable, `mostReadable` will return the better of black or white if `includeFallbackColors:true`.
-```js
-tinycolor.mostReadable("#000", ["#f00", "#0f0", "#00f"]).toHexString(); // "#00ff00"
-tinycolor.mostReadable("#123", ["#124", "#125"],{includeFallbackColors:false}).toHexString(); // "#112255"
-tinycolor.mostReadable("#123", ["#124", "#125"],{includeFallbackColors:true}).toHexString(); // "#ffffff"
-tinycolor.mostReadable("#ff0088", ["#2e0c3a"],{includeFallbackColors:true,level:"AAA",size:"large"}).toHexString() // "#2e0c3a",
-tinycolor.mostReadable("#ff0088", ["#2e0c3a"],{includeFallbackColors:true,level:"AAA",size:"small"}).toHexString() // "#000000",
-```
-See [index.html](https://github.com/bgrins/TinyColor/blob/master/index.html) in the project for a demo.
-
-## Common operations
-
-### clone
-
-`clone: function() -> TinyColor`.
-Instantiate a new TinyColor object with the same color. Any changes to the new one won't affect the old one.
-```js
-var color1 = tinycolor("#F00");
-var color2 = color1.clone();
-color2.setAlpha(.5);
-
-color1.toString(); // "#ff0000"
-color2.toString(); // "rgba(255, 0, 0, 0.5)"
-```
+- [x] Public source URL and pinned kickoff commit recorded.
+- [x] One-command native and Docker builds documented.
+- [x] Immutable oracle hashes and byte-identical original suite verified against Go.
+- [x] Exact fixed-corpus and 60-second differential evidence published.
+- [x] Same-host benchmark methodology and results published.
+- [x] Decisions, ownership, limitations, and demo script documented.
+- [ ] Five-minute demo video recorded and published.
diff --git a/bench/methodology.md b/bench/methodology.md
new file mode 100644
index 00000000..2bb44686
--- /dev/null
+++ b/bench/methodology.md
@@ -0,0 +1,24 @@
+# Benchmark methodology
+
+Run `node bench/run.mjs --output bench/results.json` from the repository root.
+It builds the Go compatibility CLI once, then sends the same fixed requests in
+`bench/workload.jsonl` to the original JavaScript adapter and the Go adapter on
+the same host. The workload covers parsing, conversion, mutation, mixing,
+readability, and palettes.
+
+Normal mode takes 20 cold starts and 1,000 persistent requests per
+implementation. Cold-start time is process launch through receipt of the first
+JSONL response. Persistent latency is one sequential JSONL request/response;
+throughput is all persistent requests divided by their total elapsed time. p99
+is the sorted sample at `ceil(0.99 * n) - 1`.
+
+Peak RSS is sampled after each cold-start response and ten times after the
+persistent workload from `/proc//status` on Linux and `Get-Process ...
+WorkingSet64` on Windows. Sampling is outside latency and throughput timing.
+Platforms without either mechanism record `null` and an `rssLimitation` instead
+of estimating a value.
+
+These are same-host observations, not universal speedup claims. Use
+`node bench/run.mjs --quick --output ` only as a smoke check;
+quick mode uses 3 cold starts and 30 requests and refuses to overwrite the
+committed evidence file.
diff --git a/bench/results.json b/bench/results.json
new file mode 100644
index 00000000..a4113981
--- /dev/null
+++ b/bench/results.json
@@ -0,0 +1,27 @@
+{
+ "generatedAt": "2026-08-01T05:53:35.885Z",
+ "os": "win32",
+ "architecture": "x64",
+ "cpu": "AMD Ryzen 9 8940HX with Radeon Graphics ",
+ "nodeVersion": "v24.18.0",
+ "goVersion": "go version go1.26.1 windows/amd64",
+ "workloadSha256": "3d91bf1f988e4a69173c8413d08ee6ba5b0d4179b9905778e4093da778206f8f",
+ "samples": {
+ "coldStarts": 20,
+ "requests": 1000
+ },
+ "implementations": {
+ "javascript": {
+ "startupP99Ms": 41.4131,
+ "latencyP99Ms": 0.2795,
+ "throughputOpsPerSecond": 7834.77401769562,
+ "peakRssBytes": 42868736
+ },
+ "go": {
+ "startupP99Ms": 11.0109,
+ "latencyP99Ms": 0.1808,
+ "throughputOpsPerSecond": 16756.790270337297,
+ "peakRssBytes": 12668928
+ }
+ }
+}
diff --git a/bench/run.mjs b/bench/run.mjs
new file mode 100644
index 00000000..bcf8194b
--- /dev/null
+++ b/bench/run.mjs
@@ -0,0 +1,179 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { execFileSync, spawn } from "node:child_process";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { cpus } from "node:os";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import readline from "node:readline";
+
+const root = resolve(import.meta.dirname, "..");
+const committedOutput = resolve(root, "bench/results.json");
+const runnerEnv = { ...process.env, GOCACHE: process.env.GOCACHE ?? resolve(root, ".cache", "go-build") };
+
+export const percentile99 = (values) => {
+ assert.ok(values.length > 0, "percentile requires at least one sample");
+ const sorted = [...values].sort((a, b) => a - b);
+ return sorted[Math.ceil(sorted.length * 0.99) - 1];
+};
+
+export const parseWorkload = (text) => text.split(/\r?\n/).filter(Boolean).map((line, index) => {
+ const request = JSON.parse(line);
+ if (!request.id || !request.operation) throw new Error(`workload line ${index + 1} requires id and operation`);
+ return request;
+});
+
+export const validateResults = (results) => {
+ assert.equal(typeof results.generatedAt, "string");
+ assert.ok(results.samples.coldStarts >= 20);
+ assert.ok(results.samples.requests >= 1000);
+ for (const name of ["javascript", "go"]) {
+ const metrics = results.implementations?.[name];
+ assert.ok(metrics, `missing ${name} metrics`);
+ for (const key of ["startupP99Ms", "latencyP99Ms", "throughputOpsPerSecond"]) {
+ assert.ok(Number.isFinite(metrics[key]) && metrics[key] >= 0, `${name}.${key} must be nonnegative`);
+ }
+ assert.ok(metrics.peakRssBytes === null || (Number.isFinite(metrics.peakRssBytes) && metrics.peakRssBytes >= 0));
+ }
+ return true;
+};
+
+const nsToMs = (nanoseconds) => Number(nanoseconds) / 1e6;
+const version = (command, args) => execFileSync(command, args, { cwd: root, encoding: "utf8" }).trim();
+const commands = () => ({
+ javascript: [process.execPath, ["compat/js-runner.mjs"]],
+ go: [resolve(root, "bin", process.platform === "win32" ? "tinycolor.exe" : "tinycolor"), []],
+});
+
+class Runner {
+ constructor(command, args) {
+ this.child = spawn(command, args, { cwd: root, env: runnerEnv, stdio: ["pipe", "pipe", "pipe"] });
+ this.pending = [];
+ this.stderr = "";
+ readline.createInterface({ input: this.child.stdout, crlfDelay: Infinity }).on("line", (line) => {
+ this.pending.shift()?.resolve(JSON.parse(line));
+ });
+ this.child.stderr.on("data", (chunk) => { this.stderr += chunk; });
+ this.child.on("exit", (code) => {
+ const error = new Error(`runner exited with ${code}: ${this.stderr}`);
+ for (const pending of this.pending.splice(0)) pending.reject(error);
+ });
+ }
+
+ request(value) {
+ return new Promise((resolveRequest, reject) => {
+ if (this.child.exitCode !== null) return reject(new Error(`runner exited: ${this.stderr}`));
+ this.pending.push({ resolve: resolveRequest, reject });
+ this.child.stdin.write(`${JSON.stringify(value)}\n`, (error) => {
+ if (error) reject(error);
+ });
+ });
+ }
+
+ close() {
+ if (this.child.exitCode === null) this.child.kill();
+ }
+}
+
+const readRss = (pid) => {
+ try {
+ if (process.platform === "linux") {
+ const match = /^VmRSS:\s+(\d+)\s+kB$/m.exec(readFileSync(`/proc/${pid}/status`, "utf8"));
+ return match ? Number(match[1]) * 1024 : null;
+ }
+ if (process.platform === "win32") {
+ const value = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).WorkingSet64`], { encoding: "utf8" }).trim();
+ return Number(value);
+ }
+ } catch {
+ // The child can exit between the response and RSS sample.
+ }
+ return null;
+};
+
+const measure = async (command, args, workload, coldStarts, requests) => {
+ const startup = [];
+ let peakRss = 0;
+ let rssSupported = false;
+ for (let index = 0; index < coldStarts; index++) {
+ const started = process.hrtime.bigint();
+ const runner = new Runner(command, args);
+ await runner.request(workload[index % workload.length]);
+ startup.push(nsToMs(process.hrtime.bigint() - started));
+ const rss = readRss(runner.child.pid);
+ if (rss !== null) {
+ rssSupported = true;
+ peakRss = Math.max(peakRss, rss);
+ }
+ runner.close();
+ }
+
+ const runner = new Runner(command, args);
+ const latencies = [];
+ const throughputStarted = process.hrtime.bigint();
+ for (let index = 0; index < requests; index++) {
+ const started = process.hrtime.bigint();
+ await runner.request(workload[index % workload.length]);
+ latencies.push(nsToMs(process.hrtime.bigint() - started));
+ }
+ const totalSeconds = Number(process.hrtime.bigint() - throughputStarted) / 1e9;
+ for (let index = 0; index < 10; index++) {
+ const rss = readRss(runner.child.pid);
+ if (rss !== null) {
+ rssSupported = true;
+ peakRss = Math.max(peakRss, rss);
+ }
+ await new Promise((resolveTick) => setImmediate(resolveTick));
+ }
+ runner.close();
+
+ return {
+ startupP99Ms: percentile99(startup),
+ latencyP99Ms: percentile99(latencies),
+ throughputOpsPerSecond: requests / totalSeconds,
+ peakRssBytes: rssSupported ? peakRss : null,
+ ...(rssSupported ? {} : { rssLimitation: `RSS sampling is unsupported on ${process.platform}` }),
+ };
+};
+
+const main = async () => {
+ const args = process.argv.slice(2);
+ const quick = args.includes("--quick");
+ const outputIndex = args.indexOf("--output");
+ if (outputIndex !== -1 && !args[outputIndex + 1]) throw new Error("usage: node bench/run.mjs [--quick] [--output ]");
+ const output = outputIndex === -1 ? committedOutput : resolve(root, args[outputIndex + 1]);
+ if (quick && output === committedOutput) throw new Error("--quick must not write bench/results.json");
+
+ const coldStarts = quick ? 3 : 20;
+ const requests = quick ? 30 : 1000;
+ const workloadText = readFileSync(resolve(root, "bench/workload.jsonl"), "utf8").replace(/\r\n/g, "\n");
+ const workload = parseWorkload(workloadText);
+ execFileSync("go", ["-C", "src", "build", "-o", `../bin/${process.platform === "win32" ? "tinycolor.exe" : "tinycolor"}`, "./cmd/tinycolor-compat"], { cwd: root, env: runnerEnv, stdio: "inherit" });
+
+ const implementations = {};
+ for (const [name, [command, commandArgs]] of Object.entries(commands())) {
+ implementations[name] = await measure(command, commandArgs, workload, coldStarts, requests);
+ }
+ const results = {
+ generatedAt: new Date().toISOString(),
+ os: process.platform,
+ architecture: process.arch,
+ cpu: cpus()[0]?.model ?? "unknown",
+ nodeVersion: process.version,
+ goVersion: version("go", ["version"]),
+ workloadSha256: createHash("sha256").update(workloadText).digest("hex"),
+ samples: { coldStarts, requests },
+ implementations,
+ };
+ if (!quick) validateResults(results);
+ mkdirSync(dirname(output), { recursive: true });
+ writeFileSync(output, `${JSON.stringify(results, null, 2)}\n`);
+ console.log(`wrote ${output}`);
+};
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/bench/run.test.mjs b/bench/run.test.mjs
new file mode 100644
index 00000000..64e84cf0
--- /dev/null
+++ b/bench/run.test.mjs
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { parseWorkload, percentile99, validateResults } from "./run.mjs";
+
+test("percentile99 selects the nearest-rank p99 sample", () => {
+ assert.equal(percentile99([1, 2, 3, 4]), 4);
+});
+
+test("workload parser accepts a representative public request", () => {
+ assert.deepEqual(parseWorkload('{"id":"x","operation":"inspect","input":"red"}\n'), [
+ { id: "x", operation: "inspect", input: "red" },
+ ]);
+});
+
+test("results schema rejects a missing implementation", () => {
+ assert.throws(() => validateResults({ samples: { coldStarts: 20, requests: 1000 } }));
+});
+
+test("quick benchmark writes complete measurements to a temporary output", () => {
+ const output = join(mkdtempSync(join(tmpdir(), "tinycolor-bench-")), "results.json");
+ execFileSync(process.execPath, ["bench/run.mjs", "--quick", "--output", output], { stdio: "inherit" });
+ const results = JSON.parse(readFileSync(output, "utf8"));
+ for (const name of ["javascript", "go"]) {
+ const metrics = results.implementations[name];
+ assert.ok(metrics.startupP99Ms >= 0);
+ assert.ok(metrics.latencyP99Ms >= 0);
+ assert.ok(metrics.throughputOpsPerSecond > 0);
+ assert.ok(metrics.peakRssBytes === null || metrics.peakRssBytes >= 0);
+ }
+});
diff --git a/bench/workload.jsonl b/bench/workload.jsonl
new file mode 100644
index 00000000..2555599a
--- /dev/null
+++ b/bench/workload.jsonl
@@ -0,0 +1,9 @@
+{"id":"bench-parse-name","operation":"inspect","input":"rebeccapurple"}
+{"id":"bench-parse-rgba","operation":"inspect","input":"rgba(12, 34, 56, 0.75)"}
+{"id":"bench-convert-hsl","operation":"output","input":"#336699","args":{"method":"toHslString"}}
+{"id":"bench-convert-hex8","operation":"output","input":"hsla(210, 50%, 40%, 0.5)","args":{"method":"toHex8String"}}
+{"id":"bench-modify","operation":"modify","input":"#80402080","args":{"method":"lighten","amount":20}}
+{"id":"bench-mix","operation":"mix","input":"rgba(255,0,0,0.25)","args":{"other":"#0000ff80","amount":35}}
+{"id":"bench-readability","operation":"readability","input":"#123456","args":{"other":"#fefefe"}}
+{"id":"bench-readable","operation":"isReadable","input":"#ff0088","args":{"other":"#2e0c3a","options":{"level":"AA","size":"small"}}}
+{"id":"bench-palette","operation":"palette","input":"#336699","args":{"method":"analogous","results":6,"slices":30}}
diff --git a/compat/cases/conversion.jsonl b/compat/cases/conversion.jsonl
new file mode 100644
index 00000000..debdf612
--- /dev/null
+++ b/compat/cases/conversion.jsonl
@@ -0,0 +1,35 @@
+{"id":"hex","operation":"output","input":"red","args":{"method":"toHex"}}
+{"id":"hex8","operation":"output","input":"rgba(255,0,0,.5)","args":{"method":"toHex8"}}
+{"id":"hexstr","operation":"output","input":"red","args":{"method":"toHexString"}}
+{"id":"hex8str","operation":"output","input":"rgba(255,0,0,.5)","args":{"method":"toHex8String"}}
+{"id":"rgb","operation":"output","input":"red","args":{"method":"toRgbString"}}
+{"id":"name","operation":"output","input":"red","args":{"method":"toName"}}
+{"id":"namefalse","operation":"output","input":"rgba(255,0,0,.5)","args":{"method":"toName"}}
+{"id":"string-explicit-hex","operation":"output","input":"red","args":{"method":"toString","format":"hex"}}
+{"id":"brightness","operation":"analysis","input":"#000","args":{"method":"brightness"}}
+{"id":"luminance","operation":"analysis","input":"#fff","args":{"method":"luminance"}}
+{"id":"dark","operation":"analysis","input":"#000","args":{"method":"isDark"}}
+{"id":"light","operation":"analysis","input":"#fff","args":{"method":"isLight"}}
+{"id":"equal","operation":"equals","input":"#ff000066","args":{"other":"rgba(255,0,0,.4)"}}
+{"id":"clone","operation":"clone","input":"red"}
+{"id":"random","operation":"randomInvariant"}
+{"id":"prgb","operation":"output","input":"red","args":{"method":"toPercentageRgbString"}}
+{"id":"prgba","operation":"output","input":"rgba(255,0,0,.5)","args":{"method":"toPercentageRgbString"}}
+{"id":"hsl","operation":"output","input":"red","args":{"method":"toHslString"}}
+{"id":"hsla","operation":"output","input":"rgba(255,0,0,.5)","args":{"method":"toHslString"}}
+{"id":"hsv","operation":"output","input":"red","args":{"method":"toHsvString"}}
+{"id":"hsva","operation":"output","input":"rgba(255,0,0,.5)","args":{"method":"toHsvString"}}
+{"id":"transparent-name","operation":"output","input":"transparent","args":{"method":"toName"}}
+{"id":"string-omitted-rgb","operation":"output","input":"rgba(255,0,0,.5)","args":{"method":"toString"}}
+{"id":"string-empty","operation":"output","input":"red","args":{"method":"toString","format":""}}
+{"id":"string-prgb","operation":"output","input":"red","args":{"method":"toString","format":"prgb"}}
+{"id":"string-hsl","operation":"output","input":"red","args":{"method":"toString","format":"hsl"}}
+{"id":"string-hsv","operation":"output","input":"red","args":{"method":"toString","format":"hsv"}}
+{"id":"string-unknown","operation":"output","input":"red","args":{"method":"toString","format":"wat"}}
+{"id":"filter-start","operation":"output","input":"red","args":{"method":"toFilter"}}
+{"id":"filter-second","operation":"output","input":"red","args":{"method":"toFilter","secondColor":"blue"}}
+{"id":"filter-empty","operation":"output","input":"red","args":{"method":"toFilter","secondColor":""}}
+{"id":"filter-zero","operation":"output","input":"red","args":{"method":"toFilter","secondColor":0}}
+{"id":"filter-transparent","operation":"output","input":"transparent","args":{"method":"toFilter","secondColor":"red"}}
+{"id":"brightness-white","operation":"analysis","input":"#fff","args":{"method":"brightness"}}
+{"id":"equal-false","operation":"equals","input":"#f00","args":{"other":"#0f0"}}
diff --git a/compat/cases/operations.jsonl b/compat/cases/operations.jsonl
new file mode 100644
index 00000000..b4f68828
--- /dev/null
+++ b/compat/cases/operations.jsonl
@@ -0,0 +1,71 @@
+{"id":"mod-lighten-normal","operation":"modify","input":"#804020","args":{"method":"lighten","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-lighten-zero","operation":"modify","input":"#804020","args":{"method":"lighten","amount":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-lighten-clamp","operation":"modify","input":"#fefefe","args":{"method":"lighten","amount":100},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-lighten-alpha","operation":"modify","input":"#80402080","args":{"method":"lighten","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-brighten-normal","operation":"modify","input":"#804020","args":{"method":"brighten","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-brighten-zero","operation":"modify","input":"#804020","args":{"method":"brighten","amount":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-brighten-clamp","operation":"modify","input":"#fefefe","args":{"method":"brighten","amount":100},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-brighten-alpha","operation":"modify","input":"#80402080","args":{"method":"brighten","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-brighten-positive-half","operation":"modify","input":"#000","args":{"method":"brighten","amount":0.19607843137254902},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-brighten-negative-half","operation":"modify","input":"#010101","args":{"method":"brighten","amount":-0.19607843137254902},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-darken-normal","operation":"modify","input":"#804020","args":{"method":"darken","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-darken-zero","operation":"modify","input":"#804020","args":{"method":"darken","amount":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-darken-clamp","operation":"modify","input":"#010101","args":{"method":"darken","amount":100},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-darken-alpha","operation":"modify","input":"#80402080","args":{"method":"darken","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-saturate-normal","operation":"modify","input":"#808040","args":{"method":"saturate","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-saturate-zero","operation":"modify","input":"#808040","args":{"method":"saturate","amount":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-saturate-clamp","operation":"modify","input":"#808040","args":{"method":"saturate","amount":100},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-saturate-negative-rounding","operation":"modify","input":"#400140","args":{"method":"saturate","amount":-100},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-saturate-alpha","operation":"modify","input":"#80804080","args":{"method":"saturate","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-desaturate-normal","operation":"modify","input":"#804020","args":{"method":"desaturate","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-desaturate-zero","operation":"modify","input":"#804020","args":{"method":"desaturate","amount":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-desaturate-clamp","operation":"modify","input":"#804020","args":{"method":"desaturate","amount":100},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-desaturate-alpha","operation":"modify","input":"#80402080","args":{"method":"desaturate","amount":20},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-greyscale-normal","operation":"modify","input":"#804020","args":{"method":"greyscale"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-greyscale-alpha","operation":"modify","input":"#80402080","args":{"method":"greyscale"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-spin-negative","operation":"modify","input":"#ff0000","args":{"method":"spin","amount":-120},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-spin-over-360","operation":"modify","input":"#ff0000","args":{"method":"spin","amount":480},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mod-spin-omitted","operation":"modify","input":"#ff0000","args":{"method":"spin"},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-spin-null","operation":"modify","input":"#ff0000","args":{"method":"spin","amount":null},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-lighten-null-default","operation":"modify","input":"#804020","args":{"method":"lighten","amount":null},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-lighten-false-default","operation":"modify","input":"#804020","args":{"method":"lighten","amount":false},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mod-lighten-string-number","operation":"modify","input":"#804020","args":{"method":"lighten","amount":"20"},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mix-default","operation":"mix","input":"#ff0000","args":{"other":"#000000"},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mix-zero","operation":"mix","input":"#ff0000","args":{"other":"#000000","amount":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mix-null-default","operation":"mix","input":"#ff0000","args":{"other":"#000000","amount":null},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mix-false-default","operation":"mix","input":"#ff0000","args":{"other":"#000000","amount":false},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mix-string-number","operation":"mix","input":"#ff0000","args":{"other":"#000000","amount":"20"},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"mix-ninety","operation":"mix","input":"#ff0000","args":{"other":"#000000","amount":90},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mix-transparent-alpha","operation":"mix","input":"transparent","args":{"other":"#00000080","amount":25},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mix-out-of-range-high","operation":"mix","input":"#ff0000","args":{"other":"#000000","amount":150},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"mix-out-of-range-low","operation":"mix","input":"#ff0000","args":{"other":"#000000","amount":-50},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readability-same","operation":"readability","input":"#000","args":{"other":"#000"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readability-low","operation":"readability","input":"#000","args":{"other":"#111"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readability-max","operation":"readability","input":"#000","args":{"other":"#fff"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readability-object-precision","operation":"readability","input":{"r":127,"g":64,"b":15,"a":0},"args":{"other":"#80007f"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readable-aa-small","operation":"isReadable","input":"#ff0088","args":{"other":"#2e0c3a","options":{"level":"AA","size":"small"}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readable-aa-large","operation":"isReadable","input":"#ff0088","args":{"other":"#5c1a72","options":{"level":"AA","size":"large"}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readable-aaa-small","operation":"isReadable","input":"#db91b8","args":{"other":"#2e0c3a","options":{"level":"AAA","size":"small"}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readable-aaa-large","operation":"isReadable","input":"#ff0088","args":{"other":"#2e0c3a","options":{"level":"AAA","size":"large"}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"readable-default-options","operation":"isReadable","input":"#ff0088","args":{"other":"#5c1a72","options":{}},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"readable-mixed-case","operation":"isReadable","input":"#db91b8","args":{"other":"#2e0c3a","options":{"level":"aaa","size":"LARGE"}},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"readable-falsy-options","operation":"isReadable","input":"#ff0088","args":{"other":"#2e0c3a","options":{"level":false,"size":0}},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"readable-number-level-error","operation":"isReadable","input":"#000","args":{"other":"#fff","options":{"level":1,"size":"small"}},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"readable-true-size-error","operation":"isReadable","input":"#000","args":{"other":"#fff","options":{"level":"AA","size":true}},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"most-readable-first-tie","operation":"mostReadable","input":"#fff","args":{"candidates":["white","#fff"],"options":{"includeFallbackColors":false}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"most-readable-fallback","operation":"mostReadable","input":"#123","args":{"candidates":["#124","#125"],"options":{"includeFallbackColors":true}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"most-readable-no-fallback","operation":"mostReadable","input":"#123","args":{"candidates":["#124","#125"],"options":{"includeFallbackColors":false}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"most-readable-empty-fallback","operation":"mostReadable","input":"#fff","args":{"candidates":[],"options":{"includeFallbackColors":true}},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"most-readable-empty-dark-fallback","operation":"mostReadable","input":"#123","args":{"candidates":[],"options":{"includeFallbackColors":true}},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"most-readable-empty-no-fallback","operation":"mostReadable","input":"#fff","args":{"candidates":[],"options":{"includeFallbackColors":false}},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"palette-complement","operation":"palette","input":"red","args":{"method":"complement"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"palette-split-complement","operation":"palette","input":"red","args":{"method":"splitcomplement"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"palette-triad","operation":"palette","input":"red","args":{"method":"triad"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"palette-tetrad","operation":"palette","input":"red","args":{"method":"tetrad"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"palette-analogous-default","operation":"palette","input":"red","args":{"method":"analogous"},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"palette-analogous-custom","operation":"palette","input":"#336699","args":{"method":"analogous","results":4,"slices":12},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"palette-analogous-zero-default","operation":"palette","input":"red","args":{"method":"analogous","results":0,"slices":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"palette-monochromatic-default","operation":"palette","input":"red","args":{"method":"monochromatic"},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"palette-monochromatic-zero-default","operation":"palette","input":"red","args":{"method":"monochromatic","results":0},"owner":"C-mrashis","suspectedPackage":"compat"}
+{"id":"palette-wrapped-split","operation":"palette","input":"#ff0066","args":{"method":"splitcomplement"},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
+{"id":"palette-analogous-alpha","operation":"palette","input":"rgba(255, 0, 0, .5)","args":{"method":"analogous","results":3,"slices":30},"owner":"B-xthxr","suspectedPackage":"src/tinycolor"}
diff --git a/compat/cases/parser-hex-rgb.jsonl b/compat/cases/parser-hex-rgb.jsonl
new file mode 100644
index 00000000..77774e1f
--- /dev/null
+++ b/compat/cases/parser-hex-rgb.jsonl
@@ -0,0 +1,26 @@
+{"id":"hex-3","operation":"inspect","input":"#f00"}
+{"id":"hex-3-no-hash","operation":"inspect","input":"F00"}
+{"id":"hex-4","operation":"inspect","input":"#f008"}
+{"id":"hex-6","operation":"inspect","input":"ff0000"}
+{"id":"hex-8","operation":"inspect","input":"#ff000080"}
+{"id":"hex-invalid","operation":"inspect","input":"##123456"}
+{"id":"rgb-space","operation":"inspect","input":"rgb 255 0 0"}
+{"id":"rgb-paren","operation":"inspect","input":"rgb(255, 0, 0)"}
+{"id":"rgba-alpha","operation":"inspect","input":"rgba 255 0 0 .5"}
+{"id":"rgb-percent","operation":"inspect","input":"rgb 100% 0% 0%"}
+{"id":"rgb-percent-object","operation":"inspect","input":{"r":"90%","g":"45%","b":"0%","a":0.4}}
+{"id":"rgb-clamped","operation":"inspect","input":"rgb 300 -1 0"}
+{"id":"rgb-alpha-invalid","operation":"inspect","input":{"r":255,"g":20,"b":10,"a":-1}}
+{"id":"rgb-alpha-zero","operation":"inspect","input":{"r":255,"g":20,"b":10,"a":0}}
+{"id":"name-red","operation":"inspect","input":"red"}
+{"id":"name-alias-aqua","operation":"inspect","input":"aqua"}
+{"id":"name-trim-case","operation":"inspect","input":" InDiAnReD "}
+{"id":"name-grey","operation":"inspect","input":"grey"}
+{"id":"transparent","operation":"inspect","input":"transparent"}
+{"id":"invalid-string","operation":"inspect","input":"this is not a color"}
+{"id":"invalid-object","operation":"inspect","input":{"r":"invalid","g":"invalid","b":"invalid"}}
+{"id":"original-empty","operation":"inspect","input":""}
+{"id":"original-null","operation":"inspect","input":null}
+{"id":"original-object","operation":"inspect","input":{"r":255,"g":0,"b":0}}
+{"id":"ratio-white","operation":"fromRatio","input":{"r":1,"g":1,"b":1}}
+{"id":"ratio-alpha","operation":"fromRatio","input":{"r":1,"g":0,"b":0,"a":0.5}}
diff --git a/compat/cases/parser.jsonl b/compat/cases/parser.jsonl
new file mode 100644
index 00000000..ad81e62e
--- /dev/null
+++ b/compat/cases/parser.jsonl
@@ -0,0 +1,23 @@
+{"id":"hsl-red","operation":"inspect","input":"hsl(0, 100%, 50%)"}
+{"id":"hsl-decimal","operation":"inspect","input":"hsl 251 100 0.38"}
+{"id":"hsla-alpha","operation":"inspect","input":"hsla(251,100%,38%,.5)"}
+{"id":"hsl-wrap","operation":"inspect","input":"hsl(-109,100%,50%)"}
+{"id":"hsv-red","operation":"inspect","input":"hsv(0,100%,100%)"}
+{"id":"hsv-decimal","operation":"inspect","input":"hsv 251.1 .887 .918"}
+{"id":"hsva-alpha","operation":"inspect","input":"hsva 251.1 .887 .918 .5"}
+{"id":"hsv-wrap","operation":"inspect","input":"hsv(720,100%,100%)"}
+{"id":"hsl-object","operation":"inspect","input":{"h":251,"s":100,"l":0.38}}
+{"id":"hsv-object","operation":"inspect","input":{"h":251.1,"s":0.887,"v":0.918}}
+{"id":"object-precedence-rgb","operation":"inspect","input":{"r":255,"g":0,"b":0,"h":120,"s":100,"v":100}}
+{"id":"object-precedence-hsv","operation":"inspect","input":{"h":120,"s":100,"v":100,"l":50}}
+{"id":"hsl-invalid","operation":"inspect","input":{"h":"invalid","s":"invalid","l":"invalid"}}
+{"id":"hsv-invalid","operation":"inspect","input":{"h":"invalid","s":"invalid","v":"invalid"}}
+{"id":"ratio-hsl","operation":"fromRatio","input":{"h":0,"s":1,"l":0.5,"a":0.5}}
+{"id":"ratio-hsv","operation":"fromRatio","input":{"h":0,"s":1,"v":1,"a":0.5}}
+{"id":"ratio-rgb-alpha-invalid","operation":"fromRatio","input":{"r":1,"g":0,"b":0,"a":10}}
+{"id":"hex8","operation":"inspect","input":"#ff000080"}
+{"id":"rgb-percent","operation":"inspect","input":"rgb 100% 0% 0%"}
+{"id":"name-alias","operation":"inspect","input":"aqua"}
+{"id":"transparent","operation":"inspect","input":"transparent"}
+{"id":"invalid","operation":"inspect","input":"this is not a color"}
+{"id":"original-null","operation":"inspect","input":null}
diff --git a/compat/cases/smoke.jsonl b/compat/cases/smoke.jsonl
new file mode 100644
index 00000000..f1790066
--- /dev/null
+++ b/compat/cases/smoke.jsonl
@@ -0,0 +1,9 @@
+{"id":"name-red","operation":"inspect","input":"red"}
+{"id":"hex-black","operation":"inspect","input":"#000"}
+{"id":"invalid","operation":"inspect","input":"not a color"}
+{"id":"transparent","operation":"inspect","input":"transparent"}
+{"id":"rgba-red","operation":"inspect","input":"rgba(255, 0, 0, .5)"}
+{"id":"hsl-red","operation":"inspect","input":"hsl(0, 100%, 50%)"}
+{"id":"hsv-red","operation":"inspect","input":"hsv(0, 100%, 100%)"}
+{"id":"rgb-object","operation":"inspect","input":{"r":255,"g":0,"b":0}}
+{"id":"ratio-red","operation":"fromRatio","input":{"r":1,"g":0,"b":0}}
diff --git a/compat/js-runner.mjs b/compat/js-runner.mjs
new file mode 100644
index 00000000..d78c8840
--- /dev/null
+++ b/compat/js-runner.mjs
@@ -0,0 +1,142 @@
+import readline from "node:readline";
+import tinycolor from "../mod.js";
+
+const inspect = (color) => ({
+ valid: color.isValid(),
+ format: color.getFormat(),
+ alpha: color.getAlpha(),
+ rgb: color.toRgb(),
+ value: color.toString(),
+ original: color.getOriginalInput(),
+});
+
+const respond = (id, result, error) => {
+ if ((result === undefined) === (error === undefined)) {
+ throw new Error("response must contain exactly one of result or error");
+ }
+ return error === undefined ? { id, result } : { id, error };
+};
+
+const handle = (request) => {
+ const args = request.args ?? {};
+ const options = args.options ?? {};
+ const color = () => tinycolor(request.input, options);
+ switch (request.operation) {
+ case "inspect":
+ return respond(request.id, inspect(tinycolor(request.input)));
+ case "string":
+ return respond(request.id, tinycolor(request.input).toString(request.args?.format));
+ case "fromRatio":
+ return respond(request.id, inspect(tinycolor.fromRatio(request.input, request.args)));
+ case "output":
+ return output(request.id, color(), args);
+ case "analysis":
+ return analysis(request.id, color(), args.method);
+ case "equals":
+ return respond(request.id, tinycolor.equals(request.input, args.other));
+ case "clone":
+ return respond(request.id, inspect(color().clone()));
+ case "modify":
+ return modify(request.id, color(), args);
+ case "mix":
+ return respond(request.id, inspect(tinycolor.mix(request.input, args.other, amount(args, 50))));
+ case "readability":
+ return respond(request.id, tinycolor.readability(request.input, args.other));
+ case "isReadable":
+ return respond(request.id, tinycolor.isReadable(request.input, args.other, args.options));
+ case "mostReadable": {
+ const result = tinycolor.mostReadable(request.input, args.candidates ?? [], args.options);
+ return respond(request.id, result ? inspect(result) : null);
+ }
+ case "palette":
+ return palette(request.id, color(), args);
+ case "randomInvariant":
+ return randomInvariant(request.id);
+ default:
+ return respond(request.id, undefined, "unsupported operation");
+ }
+};
+
+const amount = (args, defaultAmount) => args.amount === 0 ? 0 : args.amount || defaultAmount;
+
+const modify = (id, color, args) => {
+ const before = inspect(color);
+ let returned;
+ switch (args.method) {
+ case "lighten": returned = color.lighten(amount(args, 10)); break;
+ case "brighten": returned = color.brighten(amount(args, 10)); break;
+ case "darken": returned = color.darken(amount(args, 10)); break;
+ case "saturate": returned = color.saturate(amount(args, 10)); break;
+ case "desaturate": returned = color.desaturate(amount(args, 10)); break;
+ case "greyscale": returned = color.greyscale(); break;
+ case "spin": returned = Object.hasOwn(args, "amount") ? color.spin(args.amount) : color.spin(); break;
+ default: return respond(id, undefined, "unsupported method");
+ }
+ return respond(id, { before, after: inspect(color), sameReceiver: returned === color });
+};
+
+const palette = (id, color, args) => {
+ let result;
+ switch (args.method) {
+ case "complement": result = [color.complement()]; break;
+ case "splitcomplement": result = color.splitcomplement(); break;
+ case "triad": result = color.triad(); break;
+ case "tetrad": result = color.tetrad(); break;
+ case "analogous": result = color.analogous(args.results || 6, args.slices || 30); break;
+ case "monochromatic": result = color.monochromatic(args.results || 6); break;
+ default: return respond(id, undefined, "unsupported method");
+ }
+ return respond(id, result.map(inspect));
+};
+
+const output = (id, color, args) => {
+ let result;
+ switch (args.method) {
+ case "toHex": result = color.toHex(); break;
+ case "toHex8": result = color.toHex8(); break;
+ case "toHexString": result = color.toHexString(); break;
+ case "toHex8String": result = color.toHex8String(); break;
+ case "toRgbString": result = color.toRgbString(); break;
+ case "toPercentageRgbString": result = color.toPercentageRgbString(); break;
+ case "toHslString": result = color.toHslString(); break;
+ case "toHsvString": result = color.toHsvString(); break;
+ case "toString": result = color.toString(args.format); break;
+ case "toName": result = color.toName(); break;
+ case "toFilter": result = color.toFilter(args.secondColor || undefined); break;
+ default: return respond(id, undefined, "unsupported method");
+ }
+ return respond(id, result);
+};
+
+const analysis = (id, color, method) => {
+ const methods = {
+ brightness: () => color.getBrightness(),
+ luminance: () => color.getLuminance(),
+ isDark: () => color.isDark(),
+ isLight: () => color.isLight(),
+ };
+ return methods[method]
+ ? respond(id, methods[method]())
+ : respond(id, undefined, "unsupported method");
+};
+
+const randomInvariant = (id) => {
+ const rgb = tinycolor.random().toRgb();
+ return respond(id, {
+ valid: true,
+ alpha: 1,
+ rgbInRange: rgb.r >= 0 && rgb.r <= 255 && rgb.g >= 0 && rgb.g <= 255 && rgb.b >= 0 && rgb.b <= 255,
+ });
+};
+
+const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
+for await (const line of lines) {
+ if (!line) continue;
+ try {
+ const request = JSON.parse(line);
+ if (!request.id || !request.operation) throw new Error("id and operation are required");
+ console.log(JSON.stringify(handle(request)));
+ } catch (error) {
+ console.log(JSON.stringify(respond("", undefined, error instanceof SyntaxError ? "malformed JSON" : error.message)));
+ }
+}
diff --git a/compat/run.mjs b/compat/run.mjs
new file mode 100644
index 00000000..db9e8783
--- /dev/null
+++ b/compat/run.mjs
@@ -0,0 +1,41 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { spawnSync } from "node:child_process";
+import { isDeepStrictEqual } from "node:util";
+
+const root = process.cwd();
+const corpus = process.argv[2];
+if (!corpus) throw new Error("usage: node compat/run.mjs ");
+
+const cases = readFileSync(corpus, "utf8").split(/\r?\n/).filter(Boolean).map(JSON.parse);
+const goEnv = { ...process.env, GOCACHE: process.env.GOCACHE ?? resolve(root, ".cache", "go-build") };
+
+function run(command, args, cwd, request) {
+ const output = spawnSync(command, args, { cwd, input: `${JSON.stringify(request)}\n`, encoding: "utf8", env: goEnv });
+ if (output.status !== 0) return { id: request.id, error: output.stderr.trim() || `${command} exited ${output.status}` };
+ return JSON.parse(output.stdout.trim());
+}
+
+function same(left, right) {
+ return isDeepStrictEqual(left, right);
+}
+
+let passed = 0;
+let mismatches = 0;
+for (const row of cases) {
+ const { owner, suspectedPackage, ...request } = row;
+ const js = run(process.execPath, ["compat/js-runner.mjs"], root, request);
+ const go = run("go", ["run", "./cmd/tinycolor-compat"], resolve(root, "src"), request);
+ if (same(js, go)) {
+ passed++;
+ continue;
+ }
+ mismatches++;
+ const protocol = Object.hasOwn(js, "result") !== Object.hasOwn(go, "result");
+ console.log(JSON.stringify({ case: request.id, operation: request.operation, request, javascript: js, go, owner: owner ?? (protocol ? "compat" : "rajeet-04"), suspectedPackage: suspectedPackage ?? (protocol ? "compat" : "src/tinycolor") }));
+}
+
+console.log(`cases: ${cases.length}`);
+console.log(`passed: ${passed}`);
+console.log(`mismatches: ${mismatches}`);
+process.exitCode = mismatches === 0 ? 0 : 1;
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 00000000..acaa5bcc
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,47 @@
+# Architecture
+
+```text
+immutable JS source (mod.js, test.js)
+ │
+ Node JSONL oracle
+ │
+cases ── differential driver ── Go JSONL runner
+ │
+ src/tinycolor public API
+ │ │
+ internal/color internal/parser
+ │ │
+ conversion, formatting, utilities
+```
+
+## Layers
+
+1. **Compatibility boundary (`compat/` and runner):** accepts JSON-safe dynamic
+ input, dispatches named operations, and returns a stable record. It owns no
+ color math.
+2. **Parsing/model (`src/internal/...`):** turns strings and typed Go inputs into
+ normalized RGBA plus validity and source-format metadata.
+3. **Public library (`src/tinycolor`):** exposes explicit Go values and methods.
+ It owns conversions, formatting, mutation, utilities, readability, and
+ palettes.
+4. **CLI (`src/cmd/tinycolor-compat`):** a thin user-facing wrapper; it calls the same
+ library and does not reimplement parsing or conversion.
+
+The same zero-argument executable is used by `compat/run.mjs`, the fuzz harness,
+and the benchmark runner. Human subcommands select the judge-facing CLI instead.
+
+## Compatibility protocol
+
+Each line is a single JSON request with `id`, `operation`, `input`, and optional
+`args`. Each response echoes `id` and contains exactly one of `result` or
+`error`. The runner must write protocol traffic only to stdout and diagnostics
+only to stderr. This makes streamed comparison reliable and keeps CLI output
+separate from harness output.
+
+## Data rules
+
+Internal color channels retain floating precision until an observable TinyColor
+method rounds them. Alpha is stored in `[0,1]`; detected format and validity are
+separate from RGB channels because invalid TinyColor values still render as
+black. Original input is adapter metadata: typed Go callers should not need to
+recover JavaScript object identity.
diff --git a/docs/DEMO.md b/docs/DEMO.md
new file mode 100644
index 00000000..4a4998fd
--- /dev/null
+++ b/docs/DEMO.md
@@ -0,0 +1,19 @@
+# Five-minute demo script
+
+The recording and upload remain manual and unverified until a public URL is
+added to `README.md`.
+
+| Time | Demonstration |
+|---|---|
+| 0:00–0:30 | Show `git status --short`, the source URL/commit in `.port-mortem.toml`, and run `node tests/original/verify.mjs`. |
+| 0:30–1:00 | Run `make build`, then show the single artifact under `bin/`. |
+| 1:00–2:10 | Run JSON examples for `parse`, `convert`, `lighten`, `palette`, and `contrast` from `README.md`. |
+| 2:10–2:50 | Run `make verify` and point out all five exact corpus totals. |
+| 2:50–3:30 | Open `fuzz/log.txt`, validate it, then run the one-second seeded fuzz smoke. |
+| 3:30–4:10 | Open `bench/results.json` and explain the shared workload, p99 formula, throughput, RSS, and same-host limitation. |
+| 4:10–4:40 | Show `DECISIONS.md`, zero-unsafe command, coverage table, and known limits in `COMPATIBILITY.md`. |
+| 4:40–5:00 | Show the public repository and successful exact-commit Actions run, then close on the runnable binary path. |
+
+Before submission, verify repository visibility while logged out, record one
+continuous take, publish it, and add the video URL. Those account actions are
+not claimed by this script.
diff --git a/docs/TEAM-OWNERSHIP.md b/docs/TEAM-OWNERSHIP.md
new file mode 100644
index 00000000..8ed83060
--- /dev/null
+++ b/docs/TEAM-OWNERSHIP.md
@@ -0,0 +1,40 @@
+# Team Ownership and Handoffs
+
+## A — Model and parser lead @rajeet-04
+
+Owns normalized color data, inputs, low-level bounds, format metadata, named
+colors, and string/object parsing. Delivers parser fixtures and a documented
+package contract before B depends on it.
+
+## B — Conversion and behavior lead @xthxr
+
+Owns conversion, formatting, instance/static operations, readability, and
+palettes. Consumes A's parsed color contract; does not change it unilaterally.
+
+## C — Equivalence and quality lead @mrashis
+
+Owns the Node and Go adapters, case schema, differential driver, seeded corpus,
+regressions, and compatibility matrix. C can block a merge on unexplained
+behavioral divergence.
+
+## D — Integration and delivery lead @deepali
+
+Owns module setup, CLI, CI, benchmarks, README integration, demo instructions,
+and release hygiene. D does not declare parity from a benchmark or unit test;
+the differential report is required.
+
+## Handoff format
+
+Every handoff includes the commit, public contract or command added, passing
+checks, corpus coverage, and known gaps. Do not hand off an uncommitted shared
+working tree as the only artifact.
+
+## Phase 5 integration handoff
+
+- @rajeet-04 integrated exact fuzz parity, the validated 60-second log, and the
+ shared benchmark evidence on branch `rajeet`.
+- @mrashis owns any new differential mismatch reproduction.
+- @deepali owns final submission presentation and the manual demo-video URL.
+- @xthxr remains the review lead for conversion, readability, and palette
+ behavior; Phase 1–4 ownership is unchanged.
+
diff --git a/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 00000000..5aa67bf9
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,26 @@
+# Testing strategy
+
+| Layer | Purpose | Command |
+|---|---|---|
+| Full local gate | Format, hashes, unit, differential, fuzz-log, vet | `make verify` |
+| Original suite against Go | Byte-identical upstream assertions served by native Go | `node tests/original-go/run.mjs` |
+| Original source suite | Untouched upstream implementation behavior | `deno test test.js` |
+| Go unit/coverage | Package behavior and honest coverage | `go -C src test -cover ./...` |
+| Fixed differential | Exact JS/Go response parity | `node compat/run.mjs compat/cases/operations.jsonl` |
+| Fuzz smoke | Seeded broad exact comparison | `node fuzz/harness.mjs --duration 1 --seed 20260801` |
+| Fuzz evidence | Validate recorded 60-second run | `node fuzz/validate-log.mjs fuzz/log.txt` |
+| Benchmark smoke | Real quick measurement to temporary output | `node bench/run.mjs --quick --output ` |
+| Benchmark evidence | Full same-host measurement | `node bench/run.mjs --output bench/results.json` |
+
+Strings, booleans, arrays, errors, formats, and parsed numeric results are
+compared exactly. No global epsilon is used. Every discovered mismatch becomes
+a deterministic regression before the shared implementation boundary is fixed.
+
+The fixed corpora cover source-test inputs, permissive and malformed parsing,
+numeric boundaries, alpha and hue behavior, conversions, mutation, WCAG
+readability, mixing, and ordered palettes. Random output is invariant-tested.
+
+The Go-backed runner verifies the kickoff manifest, copies `test.js` without
+changing its bytes, and places a test-only `mod.js` facade beside it in a
+temporary directory. The facade invokes the compiled Go binary directly and is
+removed with the temporary overlay after the run.
diff --git a/docs/superpowers/plans/2026-08-01-original-suite-go-bridge.md b/docs/superpowers/plans/2026-08-01-original-suite-go-bridge.md
new file mode 100644
index 00000000..ac3250d4
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-01-original-suite-go-bridge.md
@@ -0,0 +1,389 @@
+# Original Suite Against Go Bridge Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Run the byte-identical original `test.js` suite against the native Go port with 45 passed, 0 failed, and 1 ignored.
+
+**Architecture:** A temporary overlay pairs an unchanged copy of `test.js` with a test-only synchronous JavaScript facade. The facade invokes a one-request mode of the existing Go compatibility binary directly through `Deno.Command`; it preserves JavaScript object identity while all color behavior comes from Go.
+
+**Tech Stack:** Go 1.26 standard library, Deno 2 standard APIs, Node.js test runner, GNU Make, GitHub Actions.
+
+## Global Constraints
+
+- Do not modify root `test.js`, `mod.js`, or `tinycolor.js`.
+- Do not import or execute the original TinyColor implementation from the facade.
+- Do not add dependencies, WebAssembly, FFI, native addons, or JavaScript color algorithms.
+- Reuse the existing compatibility request decoder and dispatcher.
+- Add one focused failing check before each production behavior change.
+- Keep commits small and single-purpose.
+
+---
+
+### Task 1: One-request Go process mode
+
+**Files:**
+- Modify: `src/cmd/tinycolor-compat/main_test.go`
+- Modify: `src/cmd/tinycolor-compat/main.go`
+
+**Interfaces:**
+- Consumes: existing `compat.Decode`, `handle`, and `write` functions.
+- Produces: `tinycolor bridge `, which writes exactly one encoded compatibility response.
+
+- [ ] **Step 1: Write the failing command test**
+
+Add a table row to `TestRunJSONLAndUsageErrors`:
+
+```go
+{
+ name: "one-request bridge",
+ args: []string{"bridge", `{"id":"bridge-red","operation":"output","input":"red","args":{"method":"toHexString"}}`},
+ wantStdout: "{\"id\":\"bridge-red\",\"result\":\"#ff0000\"}\n",
+},
+```
+
+Add rows proving a missing request returns status 2 and malformed JSON returns a structured error without a panic.
+
+- [ ] **Step 2: Run the focused test and verify RED**
+
+Run:
+
+```powershell
+$env:GOCACHE='R:\Code\TinyColor\.cache\go-build'
+go -C src test ./cmd/tinycolor-compat -run TestRunJSONLAndUsageErrors -count=1
+```
+
+Expected: FAIL because `bridge` is treated as an unknown command.
+
+- [ ] **Step 3: Add the minimum one-request path**
+
+Route `bridge` in `run` and decode its single argument through the existing protocol:
+
+```go
+case "bridge":
+ if len(args) != 2 {
+ usage(stderr, "bridge ")
+ return 2
+ }
+ request, err := compat.Decode([]byte(args[1]))
+ if err != nil {
+ response, _ := compat.Failure("", err.Error())
+ write(response, stdout, stderr)
+ return 1
+ }
+ write(handle(request), stdout, stderr)
+ return 0
+```
+
+- [ ] **Step 4: Run focused and package tests and verify GREEN**
+
+```powershell
+go -C src test ./cmd/tinycolor-compat -count=1
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```powershell
+git add src/cmd/tinycolor-compat/main.go src/cmd/tinycolor-compat/main_test.go
+git commit -m "feat(compat): add one-request bridge mode"
+```
+
+---
+
+### Task 2: Source-suite adapter operations
+
+**Files:**
+- Modify: `src/tinycolor/color.go`
+- Modify: `src/tinycolor/color_test.go`
+- Modify: `src/internal/parser/names.go`
+- Modify: `src/internal/parser/parser_test.go`
+- Modify: `src/cmd/tinycolor-compat/main.go`
+- Modify: `src/cmd/tinycolor-compat/main_test.go`
+
+**Interfaces:**
+- Consumes: existing `Color` conversion, formatting, mutation, palette, and readability methods.
+- Produces: bridge results for object conversions, compact hex, alpha mutation, random snapshots, gradient filters, and the Go-owned color-name map.
+
+- [ ] **Step 1: Write failing Go API tests for alpha and names**
+
+Add `TestSetAlphaMatchesTinyColorBounds` to `src/tinycolor/color_test.go`, asserting that `0.9` is retained and `-1`, `2`, `nil`, and `"test"` normalize to `1` while returning the same receiver.
+
+Add `TestNamesReturnsIndependentCopy` to `src/internal/parser/parser_test.go`, asserting `Names()["red"] == "ff0000"` and that mutating the returned map does not alter a second call.
+
+- [ ] **Step 2: Run focused tests and verify RED**
+
+```powershell
+go -C src test ./tinycolor ./internal/parser -run 'Test(SetAlphaMatchesTinyColorBounds|NamesReturnsIndependentCopy)' -count=1
+```
+
+Expected: build failure because `SetAlpha` and `Names` do not exist.
+
+- [ ] **Step 3: Implement the two minimal Go APIs**
+
+Add:
+
+```go
+func (c *Color) SetAlpha(value any) *Color {
+ c.model.A = color.BoundAlpha(value)
+ return c
+}
+```
+
+and an exported `parser.Names()` that returns a copied map using the standard `maps.Clone` function.
+
+- [ ] **Step 4: Add failing bridge result cases**
+
+Extend the command test table with exact requests for:
+
+- `output/toRgb`, `output/toPercentageRgb`, `output/toHsl`, and `output/toHsv`;
+- compact `toHex`, `toHexString`, `toHex8`, `toHex8String`, `toString("hex3")`, and `toString("hex4")`;
+- `setAlpha` returning the updated inspection;
+- `random` returning a valid `prgb` inspection;
+- `names` containing `red` and `rebeccapurple`;
+- `toFilter` with `gradientType: true`.
+
+Run the focused command tests. Expected: FAIL with unsupported operation or method.
+
+- [ ] **Step 5: Extend the existing dispatcher only**
+
+Add `setAlpha`, `random`, and `names` cases to `handle`. Extend `output` with lowercase-keyed object maps and a boolean `compact` argument. Use one helper that compresses `rrggbb` or `rrggbbaa` only when every pair has equal digits. Pass `gradientType` to `ToFilter` rather than duplicating filter generation.
+
+The object shapes must be:
+
+```go
+map[string]any{"r": rgb.R, "g": rgb.G, "b": rgb.B, "a": rgb.A}
+map[string]any{"r": fmt.Sprintf("%d%%", rgb.R), "g": fmt.Sprintf("%d%%", rgb.G), "b": fmt.Sprintf("%d%%", rgb.B), "a": rgb.A}
+map[string]any{"h": hsl.H, "s": hsl.S, "l": hsl.L, "a": hsl.A}
+map[string]any{"h": hsv.H, "s": hsv.S, "v": hsv.V, "a": hsv.A}
+```
+
+- [ ] **Step 6: Run all Go and fixed compatibility tests**
+
+```powershell
+gofmt -w src/tinycolor/color.go src/tinycolor/color_test.go src/internal/parser/names.go src/internal/parser/parser_test.go src/cmd/tinycolor-compat/main.go src/cmd/tinycolor-compat/main_test.go
+go -C src test ./... -count=1
+node compat/run.mjs compat/cases/smoke.jsonl
+node compat/run.mjs compat/cases/parser-hex-rgb.jsonl
+node compat/run.mjs compat/cases/parser.jsonl
+node compat/run.mjs compat/cases/conversion.jsonl
+node compat/run.mjs compat/cases/operations.jsonl
+```
+
+Expected: all pass with zero mismatches.
+
+- [ ] **Step 7: Commit**
+
+```powershell
+git add src
+git commit -m "feat(compat): expose original-suite operations"
+```
+
+---
+
+### Task 3: Byte-identical overlay and synchronous facade
+
+**Files:**
+- Create: `tests/original-go/mod.js`
+- Create: `tests/original-go/facade.test.mjs`
+- Create: `tests/original-go/run.mjs`
+- Create: `tests/original-go/run.test.mjs`
+
+**Interfaces:**
+- Consumes: `TINYCOLOR_GO_BINARY`, `tinycolor bridge `, root `test.js`, and `tests/original/manifest.sha256`.
+- Produces: a default-exported TinyColor-compatible facade and a runner that exits with Deno's source-suite status.
+
+- [ ] **Step 1: Write the failing runner integrity test**
+
+In `run.test.mjs`, import a planned `prepareOverlay()` and assert:
+
+```js
+const overlay = await prepareOverlay();
+try {
+ assert.equal(
+ createHash("sha256").update(readFileSync(overlay.testFile)).digest("hex"),
+ createHash("sha256").update(readFileSync("test.js")).digest("hex"),
+ );
+ assert.equal(readFileSync(overlay.testFile).equals(readFileSync("test.js")), true);
+} finally {
+ overlay.cleanup();
+}
+```
+
+Run `node --test tests/original-go/run.test.mjs`. Expected: FAIL because the module does not exist.
+
+- [ ] **Step 2: Implement overlay preparation**
+
+Use `mkdtempSync`, `copyFileSync`, `copyFileSync` for the facade as `mod.js`, byte comparison, and `rmSync(..., { recursive: true, force: true })`. Call the existing hash verifier before copying. Export `prepareOverlay()` for the Node test.
+
+- [ ] **Step 3: Write the failing Deno facade smoke test**
+
+Build the binary, set `TINYCOLOR_GO_BINARY`, then assert through the facade:
+
+```js
+assertEquals(tinycolor("red").toHexString(), "#ff0000");
+const color = tinycolor("red");
+assert(color.lighten(10) === color);
+assertEquals(color.toHexString(), "#ff3333");
+assertEquals(tinycolor.fromRatio({ r: 1, g: 0, b: 0 }).toRgb(), { r: 255, g: 0, b: 0, a: 1 });
+```
+
+Run the Deno smoke test. Expected: FAIL because the facade does not exist.
+
+- [ ] **Step 4: Implement the minimal facade transport and state wrapper**
+
+Implement `invoke(operation, input, args)` with direct `Deno.Command` execution, response-ID validation, and no shell. Implement input unwrapping and a facade constructor that stores original input, current RGBA, format, and gradient type from Go inspection responses.
+
+Provide all instance APIs exercised by `test.js`:
+
+```text
+getOriginalInput getFormat getAlpha setAlpha isValid clone
+toRgb toPercentageRgb toHsl toHsv
+toRgbString toPercentageRgbString toHslString toHsvString
+toHex toHexString toHex8 toHex8String toName toFilter toString
+getBrightness getLuminance isDark isLight
+lighten brighten darken saturate desaturate greyscale spin
+complement analogous monochromatic splitcomplement triad tetrad
+```
+
+Provide static APIs:
+
+```text
+fromRatio random equals mix readability isReadable mostReadable names
+```
+
+Modifiers replace the facade's current Go snapshot and return the same facade. Static and palette results wrap returned Go inspections. `names` comes from the Go `names` operation at module initialization.
+
+- [ ] **Step 5: Run the smoke test and verify GREEN**
+
+```powershell
+$env:TINYCOLOR_GO_BINARY='R:\Code\TinyColor\bin\tinycolor.exe'
+deno test --allow-run=$env:TINYCOLOR_GO_BINARY tests/original-go/facade.test.mjs
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```powershell
+git add tests/original-go
+git commit -m "test(original): add Go-backed TinyColor facade"
+```
+
+---
+
+### Task 4: Full original-suite parity gate
+
+**Files:**
+- Modify: `tests/original-go/mod.js`
+- Modify: `src/cmd/tinycolor-compat/main.go`
+- Modify: `src/cmd/tinycolor-compat/main_test.go`
+- Modify: `Makefile`
+- Modify: `.github/workflows/port.yml`
+
+**Interfaces:**
+- Consumes: the complete original `test.js` API inventory and overlay runner.
+- Produces: `make test-original-go` and a required CI gate.
+
+- [ ] **Step 1: Run the full overlay suite and verify integration RED**
+
+```powershell
+node tests/original-go/run.mjs
+```
+
+Expected: the runner reaches the original suite but reports unsupported facade behavior or assertion mismatches until every mapped method is correct.
+
+- [ ] **Step 2: Close failures one behavior at a time**
+
+For each failure, first add the smallest focused case to `main_test.go` or `facade.test.mjs`, run it to observe the same failure, then change only the implicated dispatcher or facade method. The complete mapped scope is the instance and static API list in Task 3; `polyad` remains the one ignored upstream test and must not be implemented for this deliverable.
+
+- [ ] **Step 3: Verify the exact full-suite result**
+
+Run `node tests/original-go/run.mjs`. Expected final Deno summary:
+
+```text
+45 passed | 0 failed | 1 ignored
+```
+
+Then run `node tests/original/verify.mjs`. Expected: `verified: 3`.
+
+- [ ] **Step 4: Add Make and CI gates**
+
+Add:
+
+```make
+test-original-go: build
+ node tests/original-go/run.mjs
+```
+
+Include `test-original-go` in `verify` or `test`, and keep the separate `deno test test.js` oracle check in CI. GitHub Actions must therefore run both the source implementation suite and the byte-identical Go-backed suite.
+
+- [ ] **Step 5: Run the complete local gate**
+
+On Windows, run the Makefile commands directly with repository-local `GOCACHE`; on CI-compatible shells run `make verify` and `deno test test.js`. Expected: all gates pass.
+
+- [ ] **Step 6: Commit**
+
+```powershell
+git add tests/original-go src Makefile .github/workflows/port.yml
+git commit -m "test(original): run unchanged suite against Go"
+```
+
+---
+
+### Task 5: Publish honest completion evidence
+
+**Files:**
+- Modify: `DECISIONS.md`
+- Modify: `README.md`
+- Modify: `COMPATIBILITY.md`
+- Modify: `docs/TESTING.md`
+- Modify: `.planning/STATE.md`
+
+**Interfaces:**
+- Consumes: exact local command output and final CI URL.
+- Produces: reproducible submission evidence that distinguishes source-oracle and Go-backed suite runs.
+
+- [ ] **Step 1: Record the architectural decision**
+
+Add D-017: use a byte-identical temporary source-test overlay and synchronous test-only facade invoking the native Go binary. Rationale: this runs original assertions against the port without changing kickoff files, copying algorithms into JavaScript, or introducing a second WASM implementation.
+
+- [ ] **Step 2: Update evidence documents**
+
+Document both exact results:
+
+```text
+deno test test.js 45 passed, 0 failed, 1 ignored against source oracle
+node tests/original-go/run.mjs 45 passed, 0 failed, 1 ignored against native Go port
+```
+
+Update the submission checklist item for original-suite-against-port from partial to complete. Keep the demo video unchecked.
+
+- [ ] **Step 3: Run final verification**
+
+Run all of the following fresh:
+
+```powershell
+node tests/original/verify.mjs
+node tests/original-go/run.mjs
+node tests/port/adapter.test.mjs
+node --test tests/original/verify.test.mjs tests/original-go/run.test.mjs
+node --test fuzz/harness.test.mjs fuzz/validate-log.test.mjs bench/run.test.mjs
+node fuzz/validate-log.mjs fuzz/log.txt
+go -C src test ./... -count=1
+go -C src vet ./...
+git diff --check
+```
+
+Expected: zero failures, three verified oracle hashes, a validated fuzz log, and a clean diff check.
+
+- [ ] **Step 4: Commit documentation**
+
+```powershell
+git add DECISIONS.md README.md COMPATIBILITY.md docs/TESTING.md .planning/STATE.md
+git commit -m "docs: publish original-suite Go evidence"
+```
+
+- [ ] **Step 5: Push and verify GitHub Actions**
+
+Push `rajeet`, query the exact HEAD run, and confirm `status=completed` and `conclusion=success`. Add the successful exact-commit URL only if documentation does not already point to the final run; otherwise report it in the handoff.
diff --git a/docs/superpowers/specs/2026-08-01-original-suite-go-bridge-design.md b/docs/superpowers/specs/2026-08-01-original-suite-go-bridge-design.md
new file mode 100644
index 00000000..b2fc4134
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-01-original-suite-go-bridge-design.md
@@ -0,0 +1,143 @@
+# Original Suite Against Go Bridge Design
+
+## Goal
+
+Run the pinned, byte-identical `test.js` suite against the compiled Go port and
+report its exact pass, fail, and ignored counts. The checked-in JavaScript oracle
+files remain unchanged and continue to match `tests/original/manifest.sha256`.
+
+## Hackathon interpretation
+
+The original suite is the test specification, not part of the port. A thin
+JavaScript test facade may preserve JavaScript-only object identity, constructor,
+and chaining semantics, but every color calculation must be performed by the
+submitted native Go artifact. The facade must never import or execute the
+original `mod.js` or `tinycolor.js` implementation.
+
+This is distinct from the prohibited pattern of a port shelling out to its source
+implementation: the tests invoke the Go port. The shipped Go library and CLI do
+not invoke JavaScript.
+
+## Architecture
+
+### Byte-identical test overlay
+
+`tests/original-go/run.mjs` will:
+
+1. Verify the existing kickoff manifest.
+2. Build the native `tinycolor-compat` binary.
+3. Create a temporary directory.
+4. Copy root `test.js` into that directory without transforming it.
+5. Verify the copied bytes have the same SHA-256 as root `test.js`.
+6. Place the checked-in Go-backed facade beside it as `mod.js`.
+7. run `deno test` on the copied `test.js` with permission to execute only the
+ compiled Go binary.
+8. Remove the temporary directory after the child process exits.
+
+The suite therefore resolves its unchanged `import tinycolor from "./mod.js"`
+to the test facade while the source test file itself stays byte-identical.
+
+### One-request native bridge
+
+The compatibility executable will gain a test-only one-request mode that accepts
+one JSON request as a direct process argument and writes one JSON response to
+stdout. The existing request decoder and `handle` path remain the single dispatch
+implementation for persistent JSONL fuzzing and one-request suite calls.
+
+The facade will use `Deno.Command(...).outputSync()` without a shell. Each call
+starts the compiled Go executable directly, so JavaScript's synchronous TinyColor
+API remains synchronous. Requests are small enough to remain below normal command
+line limits; malformed responses or non-zero exits fail the suite immediately
+with the Go stderr attached.
+
+### JavaScript facade boundary
+
+`tests/original-go/mod.js` will expose the function/constructor shape expected by
+the source suite. A facade color stores:
+
+- the original JavaScript input reference for `getOriginalInput()`;
+- the current RGBA snapshot returned by Go;
+- the source format and gradient option required for default string behavior.
+
+The facade itself may implement only JavaScript runtime semantics:
+
+- calling `tinycolor(existingFacade)` returns the same object;
+- `new tinycolor(existingFacade)` returns the same object;
+- instance modifiers mutate and return the same facade;
+- `clone()` returns an independent facade;
+- palette and static utility results are wrapped as facade instances.
+
+Parsing, normalization, formatting, alpha bounds, conversion, modification,
+mixing, readability, and palette generation must come from Go bridge responses.
+No TinyColor color algorithm may be reimplemented in the facade.
+
+## Go adapter coverage
+
+The existing compatibility operations will be reused and minimally extended for
+source-suite methods that currently lack a result shape:
+
+- object outputs: RGB, percentage RGB, HSL, and HSV;
+- alpha mutation;
+- random-color snapshots;
+- filter gradient options;
+- any source-suite method discovered by the failing full-suite run.
+
+Extensions belong in the existing compatibility dispatcher and public Go color
+methods. They must be independently covered by focused Go or Node adapter tests
+before the facade uses them.
+
+## Test-driven delivery
+
+Implementation proceeds in narrow red-green cycles:
+
+1. A runner-integrity test fails until the copied `test.js` hash is proven equal.
+2. A facade smoke test fails until a basic source assertion is served by Go.
+3. The full source suite is run against the facade; each unsupported method or
+ mismatch becomes one focused regression before the minimum adapter extension.
+4. The source suite must finish with 45 passed, 0 failed, and 1 upstream-ignored
+ `polyad` test.
+5. `make verify` must also retain the existing oracle, Go, Node, fuzz-log,
+ compatibility-corpus, and vet gates.
+
+The root `test.js`, `mod.js`, and `tinycolor.js` hashes are checked before and
+after the work. A changed oracle file is a hard failure.
+
+## Commands and CI
+
+`make test-original-go` will run the bridge-backed original suite. `make verify`
+will include that target so GitHub Actions cannot pass while exercising only the
+JavaScript oracle. The README and `COMPATIBILITY.md` will distinguish:
+
+- the original suite against original JavaScript; and
+- the same byte-identical suite against the Go-backed facade.
+
+Both commands and exact counts will be published. GitHub Actions must pass at the
+final pushed commit before the deliverable is marked complete.
+
+## Error handling
+
+- Missing Deno or Go exits with an actionable command failure.
+- A binary exit, timeout, invalid JSON response, or response-ID mismatch fails the
+ current assertion instead of falling back to JavaScript.
+- The temporary overlay is deleted in a `finally` path.
+- No mismatch is hidden with floating-point tolerance or rewritten expectations.
+
+## Non-goals
+
+- Do not edit, patch, transpile, or regenerate `test.js`.
+- Do not replace the production `mod.js` oracle.
+- Do not add WebAssembly, FFI, native addons, or third-party dependencies.
+- Do not reproduce TinyColor algorithms in JavaScript.
+- Do not claim coverage for future upstream TinyColor revisions.
+
+## Acceptance criteria
+
+- All three kickoff oracle hashes remain unchanged.
+- The copied source test has the same SHA-256 as root `test.js`.
+- The facade never imports the original TinyColor implementation.
+- Every observable color result used by the suite is returned by the Go binary.
+- The bridge-backed run reports 45 passed, 0 failed, and 1 ignored.
+- Existing verification and differential checks remain green.
+- README, compatibility evidence, Makefile, and CI contain the reproducible
+ bridge-backed command.
+- The final commit is pushed and its GitHub Actions run succeeds.
diff --git a/docs/superpowers/specs/2026-08-01-phase-4-operations-design.md b/docs/superpowers/specs/2026-08-01-phase-4-operations-design.md
new file mode 100644
index 00000000..3156758f
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-01-phase-4-operations-design.md
@@ -0,0 +1,42 @@
+# Phase 4 Operations Design
+
+## Scope
+
+Port the remaining TinyColor operational behavior in three independently
+verifiable slices:
+
+1. Mutating instance modifiers and pure utility operations: lighten, brighten,
+ darken, saturate, desaturate, greyscale, spin, and mix.
+2. WCAG readability operations: readability, isReadable, and mostReadable.
+3. Combination operations: complement, analogous, monochromatic, split
+ complement, triad, and tetrad.
+
+## Architecture
+
+`src/tinycolor` remains the single Go facade over the normalized parser/model.
+It owns source-equivalent conversion-based operations and mutable `Color`
+instance methods. The JSONL runners only whitelist and dispatch these public
+methods; they do not contain conversion, palette, or WCAG formulas.
+
+Each slice receives direct Go regression tests and fixed JSONL cases evaluated
+against the immutable local `mod.js` oracle. The Go API uses explicit typed
+arguments; the compatibility adapter owns JavaScript truthiness/default coercion
+where it cannot be represented in that API.
+
+## Behavioral Constraints
+
+- Match source defaults, including explicit zero amounts, clamping, hue
+ wrapping, alpha preservation, and receiver mutation for instance modifiers.
+- Keep utility and palette results independent of their inputs, preserve source
+ result order, and retain source palette defaults.
+- Implement WCAG option defaults and fallback-color recursion exactly. Do not
+ use a broad numeric tolerance in the differential runner.
+- Use only the Go standard library and do not change the JavaScript oracle.
+
+## Verification
+
+Each slice follows red-green-refactor with a focused Go test and a fixed
+Node-to-Go JSONL corpus. After every micro-commit, run its focused test and
+corpus. Phase completion requires `go test ./...`, `go vet ./...`, all prior
+corpora, the Phase 4 corpus, `git diff --check`, and no diff under `mod.js`,
+`test.js`, or `tinycolor.js`.
\ No newline at end of file
diff --git a/docs/superpowers/specs/2026-08-01-phase-5-delivery-evidence-design.md b/docs/superpowers/specs/2026-08-01-phase-5-delivery-evidence-design.md
new file mode 100644
index 00000000..9cc4396b
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-01-phase-5-delivery-evidence-design.md
@@ -0,0 +1,90 @@
+# Phase 5 Delivery Evidence Design
+
+## Goal
+
+Turn the completed TinyColor Go port into a reproducible Track H submission
+that a judge can build, exercise, measure, and audit from a clean checkout.
+
+## Submission structure
+
+Keep the existing advisory layout as the actual submission layout:
+
+```text
+TinyColor/
+├── README.md
+├── DECISIONS.md
+├── Dockerfile
+├── src/
+├── tests/original/
+├── tests/port/
+├── fuzz/
+│ ├── harness.mjs
+│ └── log.txt
+├── bench/
+│ ├── methodology.md
+│ └── results.json
+└── .port-mortem.toml
+```
+
+Existing project files remain in place. No parallel submission tree or new
+dependency is introduced.
+
+## CLI
+
+Reuse the existing Go compatibility binary and TinyColor package. Add a human
+command interface for `parse`, `convert`, `lighten`, `palette`, and `contrast`.
+Every command accepts `--json`; JSON output is deterministic and suitable for
+demo comparisons. Invalid commands and missing values return nonzero status
+with a concise stderr message. The JSONL compatibility mode remains available
+so the differential driver does not change.
+
+## Reproducible checks and CI
+
+`make build` produces `bin/tinycolor`. `make verify` runs formatting checks,
+Go tests, vet, immutable-oracle hash verification, adapter tests, and every
+fixed differential corpus. GitHub Actions invokes the same commands rather
+than maintaining a second CI-only procedure. Docker continues to build the
+same binary in one command.
+
+## Differential fuzz evidence
+
+Add a standard-library Node harness that generates deterministic and randomized
+public-API requests, sends the identical stream to the checked-in JavaScript
+oracle and Go port, and reports duration, seed, case count, and divergences.
+Publish an actual run lasting at least 60 continuous seconds in `fuzz/log.txt`.
+A zero-divergence bonus is claimed only if that run finishes with zero
+divergences; any mismatch is retained as a reproducible input.
+
+## Benchmark evidence
+
+Use one shared workload file for the original JavaScript and Go runners.
+Measure cold startup, request latency p99, throughput, and peak RSS with the
+same host and tool versions. Record the commands and limitations in
+`bench/methodology.md` and machine-readable measurements in
+`bench/results.json`. Results state sample counts and do not infer universal
+speedups from one machine.
+
+## Documentation and scoring evidence
+
+Rewrite the root README around the Go port while retaining upstream attribution
+and links. Expand `DECISIONS.md` to at least ten non-trivial, defensible
+divergences. Update `COMPATIBILITY.md` with pass rates per corpus, immutable
+hash status, unsafe count, coverage data where measured, and known limitations.
+Keep team ownership and license attribution explicit. Add a five-minute demo
+script, but do not claim that a video was recorded or published unless an
+actual video artifact or URL is supplied.
+
+## Verification
+
+The phase closes only after CLI tests pass, `make build` and `make verify`
+succeed, all pinned oracle hashes match, the 60-second fuzz session is recorded,
+benchmark JSON validates, documentation contains no unsupported claims, and
+the worktree passes `git diff --check`. Deno remains an honest environment gap
+unless it is installed and the untouched source suite is run successfully.
+
+## Deliberate exclusions
+
+- No GUI, package publication, transpiler, JavaScript runtime embedding, or FFI.
+- No new dependency when Go or Node standard libraries cover the need.
+- No speculative Bug Catcher claim; document it only if differential testing
+ produces a real upstream defect.
diff --git a/fuzz/README.md b/fuzz/README.md
new file mode 100644
index 00000000..45dd690a
--- /dev/null
+++ b/fuzz/README.md
@@ -0,0 +1,30 @@
+# Differential fuzzing
+
+`harness.mjs` sends one deterministic JSONL request stream to persistent
+JavaScript and Go adapters and compares every parsed response exactly.
+
+One-second smoke:
+
+```text
+node fuzz/harness.mjs --duration 1 --seed 1
+```
+
+Full evidence run:
+
+```text
+node fuzz/harness.mjs --duration 60 --seed 20260801
+```
+
+Validate the checked-in evidence:
+
+```text
+node fuzz/validate-log.mjs fuzz/log.txt
+```
+
+Rerun a failure with the logged seed and duration. Each divergence is a JSON
+object containing the request and both responses; the four final lines report
+duration, seed, case count, and divergence count.
+
+Claim zero divergences only from a real saved run whose reported duration meets
+the claim. A deterministic seed reproduces the request prefix, while case count
+can vary with machine speed.
diff --git a/fuzz/harness.mjs b/fuzz/harness.mjs
new file mode 100644
index 00000000..5781f19e
--- /dev/null
+++ b/fuzz/harness.mjs
@@ -0,0 +1,254 @@
+import { execFileSync, spawn } from "node:child_process";
+import { mkdirSync } from "node:fs";
+import { resolve } from "node:path";
+import { createInterface } from "node:readline";
+import { pathToFileURL } from "node:url";
+import { isDeepStrictEqual } from "node:util";
+
+const root = resolve(import.meta.dirname, "..");
+const defaults = { duration: 60, seed: 20260801 };
+const operations = ["inspect", "output", "modify", "mix", "readability", "isReadable", "palette"];
+const outputMethods = [
+ "toHex", "toHex8", "toHexString", "toHex8String", "toRgbString",
+ "toPercentageRgbString", "toHslString", "toHsvString", "toString",
+ "toName", "toFilter",
+];
+const modifyMethods = ["lighten", "brighten", "darken", "saturate", "desaturate", "greyscale", "spin"];
+const paletteMethods = ["complement", "splitcomplement", "triad", "tetrad", "analogous", "monochromatic"];
+const components = [0, 1, 15, 32, 64, 127, 128, 192, 254, 255];
+const alphas = [0, 0.25, 0.5, 0.75, 1];
+
+export function createPrng(seed) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let value = state;
+ value = Math.imul(value ^ value >>> 15, value | 1);
+ value ^= value + Math.imul(value ^ value >>> 7, value | 61);
+ return ((value ^ value >>> 14) >>> 0) / 0x100000000;
+ };
+}
+
+const pick = (random, values) => values[Math.floor(random() * values.length)];
+
+function colorInput(random) {
+ const r = pick(random, components);
+ const g = pick(random, components);
+ const b = pick(random, components);
+ const a = pick(random, alphas);
+ switch (Math.floor(random() * 4)) {
+ case 0: return `#${[r, g, b].map((value) => value.toString(16).padStart(2, "0")).join("")}`;
+ case 1: return `rgb(${r}, ${g}, ${b})`;
+ case 2: return `rgba(${r}, ${g}, ${b}, ${a})`;
+ default: return { r, g, b, a };
+ }
+}
+
+export function generateRequest(random, index) {
+ const operation = operations[index % operations.length];
+ const input = colorInput(random);
+ const request = { id: `fuzz-${index}`, operation, input, args: {} };
+ switch (operation) {
+ case "output": {
+ const method = pick(random, outputMethods);
+ request.args.method = method;
+ if (method === "toString") request.args.format = pick(random, ["hex", "hex8", "rgb", "prgb", "hsl", "hsv", "name"]);
+ if (method === "toFilter") request.args.secondColor = colorInput(random);
+ break;
+ }
+ case "modify": {
+ const method = pick(random, modifyMethods);
+ request.args.method = method;
+ if (method !== "greyscale") request.args.amount = method === "spin"
+ ? pick(random, [-360, -180, -120, 0, 120, 180, 360])
+ : pick(random, [-100, -50, 0, 10, 20, 50, 100]);
+ break;
+ }
+ case "mix":
+ request.args = { other: colorInput(random), amount: pick(random, [-50, 0, 25, 50, 75, 100, 150]) };
+ break;
+ case "readability":
+ request.args.other = colorInput(random);
+ break;
+ case "isReadable":
+ request.args = {
+ other: colorInput(random),
+ options: { level: pick(random, ["AA", "AAA"]), size: pick(random, ["small", "large"]) },
+ };
+ break;
+ case "palette": {
+ const method = pick(random, paletteMethods);
+ request.args.method = method;
+ if (method === "analogous") Object.assign(request.args, { results: pick(random, [1, 3, 6]), slices: pick(random, [6, 12, 30]) });
+ if (method === "monochromatic") request.args.results = pick(random, [1, 3, 6]);
+ break;
+ }
+ }
+ return request;
+}
+
+function validateOptions(options) {
+ if (!Number.isFinite(options.duration) || options.duration <= 0) throw new Error("--duration must be a positive number");
+ if (!Number.isSafeInteger(options.seed)) throw new Error("--seed must be an integer");
+ return options;
+}
+
+export function parseOptions(args) {
+ const options = { ...defaults };
+ for (let index = 0; index < args.length; index += 2) {
+ const flag = args[index];
+ const value = args[index + 1];
+ if (value === undefined) throw new Error(`${flag} requires a value`);
+ if (flag === "--duration") options.duration = Number(value);
+ else if (flag === "--seed") options.seed = Number(value);
+ else throw new Error(`unknown option: ${flag}`);
+ }
+ return validateOptions(options);
+}
+
+function startRunner(command, args, env) {
+ const child = spawn(command, args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"] });
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ let stderr = "";
+ let spawnError;
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
+ child.on("error", (error) => { spawnError = error; });
+ const closed = new Promise((resolveClose) => child.once("close", (code, signal) => resolveClose({ code, signal })));
+ return {
+ child,
+ closed,
+ get failure() { return spawnError; },
+ get stderr() { return stderr.trim(); },
+ lines: createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator](),
+ };
+}
+
+function withTimeout(promise, milliseconds, message) {
+ let timer;
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); }),
+ ]).finally(() => clearTimeout(timer));
+}
+
+function writeLine(runner, line) {
+ return new Promise((resolveWrite, reject) => {
+ const fail = (error) => reject(new Error(`write failure: ${error.message}`));
+ runner.child.stdin.once("error", fail);
+ runner.child.stdin.write(`${line}\n`, (error) => {
+ runner.child.stdin.off("error", fail);
+ if (error) fail(error);
+ else resolveWrite();
+ });
+ });
+}
+
+async function exchange(runner, request) {
+ await writeLine(runner, JSON.stringify(request));
+ const { value, done } = await runner.lines.next();
+ if (done) {
+ const detail = runner.failure?.message || runner.stderr || "child exited before responding";
+ throw new Error(detail);
+ }
+ let response;
+ try {
+ response = JSON.parse(value);
+ } catch {
+ throw new Error(`invalid JSON response: ${value}`);
+ }
+ if (response.id !== request.id) throw new Error(`response ID mismatch: expected ${request.id}, got ${response.id}`);
+ return response;
+}
+
+async function stopRunner(runner) {
+ if (!runner) return;
+ if (!runner.child.stdin.destroyed) runner.child.stdin.end();
+ let result;
+ try {
+ result = await withTimeout(runner.closed, 1_000, "child shutdown timeout");
+ } catch (error) {
+ runner.child.kill();
+ await runner.closed;
+ throw error;
+ }
+ if (runner.failure) throw runner.failure;
+ if (result.code !== 0) throw new Error(runner.stderr || `child exited ${result.code ?? result.signal}`);
+}
+
+function buildBinary(env) {
+ mkdirSync(resolve(root, "bin"), { recursive: true });
+ const suffix = execFileSync("go", ["env", "GOEXE"], { cwd: root, env, encoding: "utf8" }).trim();
+ execFileSync("go", ["-C", "src", "build", "-o", `../bin/tinycolor${suffix}`, "./cmd/tinycolor-compat"], {
+ cwd: root,
+ env,
+ stdio: ["ignore", "ignore", "inherit"],
+ });
+ return resolve(root, "bin", `tinycolor${suffix}`);
+}
+
+export async function runHarness({ duration = defaults.duration, seed = defaults.seed, onDivergence = console.log } = {}) {
+ validateOptions({ duration, seed });
+ const env = { ...process.env, GOCACHE: process.env.GOCACHE ?? resolve(root, ".cache", "go-build") };
+ const binary = buildBinary(env);
+ const javascript = startRunner(process.execPath, ["compat/js-runner.mjs"], env);
+ const go = startRunner(binary, [], env);
+ const random = createPrng(seed);
+ let cases = 0;
+ let divergences = 0;
+ let start;
+ let failure;
+
+ try {
+ start = process.hrtime.bigint();
+ const durationNanoseconds = BigInt(Math.ceil(duration * 1e9));
+ do {
+ const request = generateRequest(random, cases);
+ const [javascriptResponse, goResponse] = await withTimeout(
+ Promise.all([exchange(javascript, request), exchange(go, request)]),
+ 5_000,
+ `timeout waiting for ${request.id}`,
+ );
+ cases++;
+ if (!isDeepStrictEqual(javascriptResponse, goResponse)) {
+ divergences++;
+ onDivergence(JSON.stringify({ request, javascript: javascriptResponse, go: goResponse }));
+ }
+ } while (process.hrtime.bigint() - start < durationNanoseconds);
+ } catch (error) {
+ failure = error;
+ }
+
+ const stopped = await Promise.allSettled([stopRunner(javascript), stopRunner(go)]);
+ if (!failure) failure = stopped.find(({ status }) => status === "rejected")?.reason;
+ const elapsedSeconds = start ? Number(process.hrtime.bigint() - start) / 1e9 : 0;
+ const summary = { elapsedSeconds, seed, cases, divergences };
+ if (failure) {
+ failure.summary = summary;
+ throw failure;
+ }
+ return summary;
+}
+
+function printSummary(summary) {
+ console.log(`duration_seconds: ${summary.elapsedSeconds.toFixed(3)}`);
+ console.log(`seed: ${summary.seed}`);
+ console.log(`cases: ${summary.cases}`);
+ console.log(`divergences: ${summary.divergences}`);
+}
+
+async function main() {
+ let options = defaults;
+ try {
+ options = parseOptions(process.argv.slice(2));
+ const summary = await runHarness({ ...options, onDivergence: console.log });
+ printSummary(summary);
+ if (summary.divergences) process.exitCode = 1;
+ } catch (error) {
+ console.error(error.message);
+ printSummary(error.summary ?? { elapsedSeconds: 0, seed: options.seed, cases: 0, divergences: 0 });
+ process.exitCode = 1;
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await main();
diff --git a/fuzz/harness.test.mjs b/fuzz/harness.test.mjs
new file mode 100644
index 00000000..0537c46a
--- /dev/null
+++ b/fuzz/harness.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { createPrng, generateRequest, parseOptions, runHarness } from "./harness.mjs";
+
+const methods = {
+ output: new Set([
+ "toHex", "toHex8", "toHexString", "toHex8String", "toRgbString",
+ "toPercentageRgbString", "toHslString", "toHsvString", "toString",
+ "toName", "toFilter",
+ ]),
+ modify: new Set(["lighten", "brighten", "darken", "saturate", "desaturate", "greyscale", "spin"]),
+ palette: new Set(["complement", "splitcomplement", "triad", "tetrad", "analogous", "monochromatic"]),
+};
+const operations = new Set(["inspect", "output", "modify", "mix", "readability", "isReadable", "palette"]);
+
+test("seed 1 repeats the same PRNG sequence", () => {
+ const first = createPrng(1);
+ const second = createPrng(1);
+ assert.deepEqual(Array.from({ length: 8 }, first), Array.from({ length: 8 }, second));
+});
+
+test("request generation is deterministic and adapter-supported", () => {
+ const first = createPrng(20260801);
+ const second = createPrng(20260801);
+ const requests = Array.from({ length: 70 }, (_, index) => generateRequest(first, index));
+
+ assert.deepEqual(requests, Array.from({ length: 70 }, (_, index) => generateRequest(second, index)));
+ for (const [index, request] of requests.entries()) {
+ assert.equal(request.id, `fuzz-${index}`);
+ assert.ok(operations.has(request.operation), request.operation);
+ if (methods[request.operation]) assert.ok(methods[request.operation].has(request.args.method), request.args.method);
+ }
+ assert.deepEqual(new Set(requests.map(({ operation }) => operation)), operations);
+});
+
+test("option parsing validates duration and seed", () => {
+ assert.deepEqual(parseOptions([]), { duration: 60, seed: 20260801 });
+ assert.deepEqual(parseOptions(["--duration", "0.25", "--seed", "1"]), { duration: 0.25, seed: 1 });
+ for (const args of [
+ ["--duration", "0"],
+ ["--duration", "-1"],
+ ["--duration", "nope"],
+ ["--seed", "1.5"],
+ ["--seed", "nope"],
+ ]) assert.throws(() => parseOptions(args));
+});
+
+test("one-second differential run uses persistent adapters", { timeout: 30_000 }, async () => {
+ const summary = await runHarness({ duration: 1, seed: 1, onDivergence() {} });
+ assert.ok(summary.elapsedSeconds >= 1, summary.elapsedSeconds);
+ assert.ok(summary.cases > 0, summary.cases);
+ assert.equal(summary.divergences, 0);
+});
diff --git a/fuzz/log.txt b/fuzz/log.txt
new file mode 100644
index 00000000..d0f6498e
--- /dev/null
+++ b/fuzz/log.txt
@@ -0,0 +1,4 @@
+duration_seconds: 60.012
+seed: 20260801
+cases: 1091630
+divergences: 0
diff --git a/fuzz/validate-log.mjs b/fuzz/validate-log.mjs
new file mode 100644
index 00000000..7afe12d6
--- /dev/null
+++ b/fuzz/validate-log.mjs
@@ -0,0 +1,38 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+export function validateLog(text) {
+ const read = (name) => {
+ const matches = [...text.matchAll(new RegExp(`^${name}: (.+)$`, "gm"))];
+ if (matches.length !== 1) throw new Error(`${name} must appear exactly once`);
+ const value = Number(matches[0][1]);
+ if (!Number.isFinite(value)) throw new Error(`${name} must be numeric`);
+ return value;
+ };
+ const result = {
+ duration_seconds: read("duration_seconds"),
+ seed: read("seed"),
+ cases: read("cases"),
+ divergences: read("divergences"),
+ };
+ if (result.duration_seconds < 60) throw new Error("duration_seconds must be at least 60");
+ if (!Number.isSafeInteger(result.seed) || result.seed !== 20260801) throw new Error("seed must be 20260801");
+ if (!Number.isSafeInteger(result.cases) || result.cases <= 0) throw new Error("cases must be a positive integer");
+ if (!Number.isSafeInteger(result.divergences) || result.divergences !== 0) throw new Error("divergences must be zero");
+ return result;
+}
+
+function main() {
+ try {
+ const path = process.argv[2];
+ if (!path) throw new Error("usage: node fuzz/validate-log.mjs ");
+ validateLog(readFileSync(path, "utf8"));
+ console.log("fuzz log verified");
+ } catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main();
diff --git a/fuzz/validate-log.test.mjs b/fuzz/validate-log.test.mjs
new file mode 100644
index 00000000..6e9afa35
--- /dev/null
+++ b/fuzz/validate-log.test.mjs
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { validateLog } from "./validate-log.mjs";
+
+const valid = `duration_seconds: 60.001
+seed: 20260801
+cases: 1
+divergences: 0
+`;
+
+test("accepts a complete zero-divergence 60-second log", () => {
+ assert.deepEqual(validateLog(valid), {
+ duration_seconds: 60.001,
+ seed: 20260801,
+ cases: 1,
+ divergences: 0,
+ });
+});
+
+test("rejects invalid evidence fields", () => {
+ for (const [name, value] of [
+ ["duration_seconds", "59.999"],
+ ["duration_seconds", "nope"],
+ ["seed", "1"],
+ ["seed", "1.5"],
+ ["cases", "0"],
+ ["cases", "1.5"],
+ ["divergences", "1"],
+ ["divergences", "nope"],
+ ]) {
+ assert.throws(() => validateLog(valid.replace(new RegExp(`^${name}: .+$`, "m"), `${name}: ${value}`)), name);
+ }
+});
+
+test("rejects every missing evidence field", () => {
+ for (const name of ["duration_seconds", "seed", "cases", "divergences"]) {
+ assert.throws(() => validateLog(valid.replace(new RegExp(`^${name}: .+\\n`, "m"), "")), name);
+ }
+});
diff --git a/src/cmd/tinycolor-compat/main.go b/src/cmd/tinycolor-compat/main.go
new file mode 100644
index 00000000..0c435cd3
--- /dev/null
+++ b/src/cmd/tinycolor-compat/main.go
@@ -0,0 +1,608 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/rajeet-04/tinycolor-go/internal/compat"
+ "github.com/rajeet-04/tinycolor-go/internal/parser"
+ "github.com/rajeet-04/tinycolor-go/tinycolor"
+)
+
+func main() {
+ os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
+}
+
+func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ if len(args) == 0 {
+ return runJSONL(stdin, stdout, stderr)
+ }
+ switch args[0] {
+ case "bridge":
+ if len(args) != 2 {
+ usage(stderr, "bridge ")
+ return 2
+ }
+ request, err := compat.Decode([]byte(args[1]))
+ if err != nil {
+ response, _ := compat.Failure("", err.Error())
+ write(response, stdout, stderr)
+ return 1
+ }
+ write(handle(request), stdout, stderr)
+ return 0
+ case "parse":
+ return runParse(args[1:], stdout, stderr)
+ case "convert":
+ return runConvert(args[1:], stdout, stderr)
+ case "lighten":
+ return runLighten(args[1:], stdout, stderr)
+ case "palette":
+ return runPalette(args[1:], stdout, stderr)
+ case "contrast":
+ return runContrast(args[1:], stdout, stderr)
+ default:
+ usage(stderr, "")
+ return 2
+ }
+}
+
+func runJSONL(stdin io.Reader, stdout, stderr io.Writer) int {
+ scanner := bufio.NewScanner(stdin)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ request, err := compat.Decode(line)
+ if err != nil {
+ response, _ := compat.Failure("", err.Error())
+ write(response, stdout, stderr)
+ continue
+ }
+ write(handle(request), stdout, stderr)
+ }
+ return 0
+}
+
+func runParse(args []string, stdout, stderr io.Writer) int {
+ flags := flag.NewFlagSet("parse", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ jsonOutput := flags.Bool("json", false, "output JSON")
+ if flags.Parse(args) != nil || flags.NArg() != 1 {
+ usage(stderr, "parse [--json] ")
+ return 2
+ }
+ color, _ := tinycolor.FromCompat(flags.Arg(0), false)
+ if *jsonOutput {
+ inspection := color.Inspect()
+ inspection["hex"] = color.ToHex()
+ return writeJSON(stdout, inspection)
+ }
+ fmt.Fprintln(stdout, color.String())
+ return 0
+}
+
+func runConvert(args []string, stdout, stderr io.Writer) int {
+ flags := flag.NewFlagSet("convert", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ to := flags.String("to", "", "hex, hex8, rgb, percentage-rgb, hsl, hsv, or name")
+ jsonOutput := flags.Bool("json", false, "output JSON")
+ if flags.Parse(args) != nil || flags.NArg() != 1 || *to == "" {
+ usage(stderr, "convert --to hex|hex8|rgb|percentage-rgb|hsl|hsv|name [--json] ")
+ return 2
+ }
+ color, _ := tinycolor.FromCompat(flags.Arg(0), false)
+ var result string
+ switch *to {
+ case "hex":
+ result = color.ToHexString()
+ case "hex8":
+ result = color.ToHex8String()
+ case "rgb":
+ result = color.ToRGBString()
+ case "percentage-rgb":
+ result = color.ToPercentageRGBString()
+ case "hsl":
+ result = color.ToHSLString()
+ case "hsv":
+ result = color.ToHSVString()
+ case "name":
+ result = color.ToString("name")
+ default:
+ usage(stderr, "convert --to hex|hex8|rgb|percentage-rgb|hsl|hsv|name [--json] ")
+ return 2
+ }
+ if *jsonOutput {
+ return writeJSON(stdout, result)
+ }
+ fmt.Fprintln(stdout, result)
+ return 0
+}
+
+func runLighten(args []string, stdout, stderr io.Writer) int {
+ flags := flag.NewFlagSet("lighten", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ amount := flags.Float64("amount", 10, "lightness percentage")
+ jsonOutput := flags.Bool("json", false, "output JSON")
+ if flags.Parse(args) != nil || flags.NArg() != 1 {
+ usage(stderr, "lighten [--amount 10] [--json] ")
+ return 2
+ }
+ color, _ := tinycolor.FromCompat(flags.Arg(0), false)
+ color.Lighten(*amount)
+ result := color.ToHexString()
+ if *jsonOutput {
+ return writeJSON(stdout, result)
+ }
+ fmt.Fprintln(stdout, result)
+ return 0
+}
+
+func runPalette(args []string, stdout, stderr io.Writer) int {
+ flags := flag.NewFlagSet("palette", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ kind := flags.String("type", "", "palette type")
+ results := flags.Int("results", 6, "number of colors")
+ slices := flags.Int("slices", 30, "number of hue slices")
+ jsonOutput := flags.Bool("json", false, "output JSON")
+ if flags.Parse(args) != nil || flags.NArg() != 1 || *kind == "" {
+ usage(stderr, "palette --type complement|splitcomplement|triad|tetrad|analogous|monochromatic [--results 6] [--slices 30] [--json] ")
+ return 2
+ }
+ color, _ := tinycolor.FromCompat(flags.Arg(0), false)
+ var colors []tinycolor.Color
+ switch *kind {
+ case "complement":
+ colors = []tinycolor.Color{color.Complement()}
+ case "splitcomplement":
+ colors = color.SplitComplement()
+ case "triad":
+ colors = color.Triad()
+ case "tetrad":
+ colors = color.Tetrad()
+ case "analogous":
+ colors = color.Analogous(*results, *slices)
+ case "monochromatic":
+ colors = color.Monochromatic(*results)
+ default:
+ usage(stderr, "palette --type complement|splitcomplement|triad|tetrad|analogous|monochromatic [--results 6] [--slices 30] [--json] ")
+ return 2
+ }
+ if *jsonOutput {
+ inspections := make([]map[string]any, len(colors))
+ for index, paletteColor := range colors {
+ inspections[index] = paletteColor.Inspect()
+ }
+ return writeJSON(stdout, inspections)
+ }
+ for _, paletteColor := range colors {
+ fmt.Fprintln(stdout, paletteColor.ToHexString())
+ }
+ return 0
+}
+
+func runContrast(args []string, stdout, stderr io.Writer) int {
+ flags := flag.NewFlagSet("contrast", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ jsonOutput := flags.Bool("json", false, "output JSON")
+ if flags.Parse(args) != nil || flags.NArg() != 2 {
+ usage(stderr, "contrast [--json] ")
+ return 2
+ }
+ first, _ := tinycolor.FromCompat(flags.Arg(0), false)
+ second, _ := tinycolor.FromCompat(flags.Arg(1), false)
+ ratio := tinycolor.Readability(first, second)
+ if *jsonOutput {
+ return writeJSON(stdout, map[string]any{
+ "ratio": ratio,
+ "aaSmall": tinycolor.IsReadable(first, second, tinycolor.WCAG2Options{Level: "AA", Size: "small"}),
+ "aaLarge": tinycolor.IsReadable(first, second, tinycolor.WCAG2Options{Level: "AA", Size: "large"}),
+ "aaaSmall": tinycolor.IsReadable(first, second, tinycolor.WCAG2Options{Level: "AAA", Size: "small"}),
+ "aaaLarge": tinycolor.IsReadable(first, second, tinycolor.WCAG2Options{Level: "AAA", Size: "large"}),
+ })
+ }
+ fmt.Fprintln(stdout, ratio)
+ return 0
+}
+
+func usage(stderr io.Writer, command string) {
+ if command == "" {
+ fmt.Fprintln(stderr, "Usage: tinycolor ")
+ return
+ }
+ fmt.Fprintln(stderr, "Usage: tinycolor "+command)
+}
+
+func writeJSON(stdout io.Writer, value any) int {
+ if err := json.NewEncoder(stdout).Encode(value); err != nil {
+ return 1
+ }
+ return 0
+}
+
+func handle(request compat.Request) compat.Response {
+ var (
+ color tinycolor.Color
+ err error
+ )
+ args, _ := request.Args.(map[string]any)
+ switch request.Operation {
+ case "inspect", "string":
+ color, err = tinycolor.FromCompat(request.Input, false)
+ case "fromRatio":
+ format, _ := args["format"].(string)
+ gradientType, _ := args["gradientType"].(bool)
+ color, err = tinycolor.FromCompatWithOptions(request.Input, true, tinycolor.CompatOptions{Format: format, GradientType: gradientType})
+ case "output", "analysis", "clone", "modify", "mix", "readability", "isReadable", "mostReadable", "palette", "setAlpha":
+ color, err = tinycolor.FromCompatWithOptions(request.Input, false, options(args))
+ case "equals", "randomInvariant", "random", "names":
+ default:
+ response, _ := compat.Failure(request.ID, "unsupported operation")
+ return response
+ }
+ if err != nil {
+ response, _ := compat.Failure(request.ID, err.Error())
+ return response
+ }
+ if request.Operation == "string" {
+ format, _ := args["format"].(string)
+ response, _ := compat.Success(request.ID, color.ToString(format))
+ return response
+ }
+ switch request.Operation {
+ case "output":
+ return output(request.ID, color, args)
+ case "analysis":
+ return analysis(request.ID, color, args["method"])
+ case "clone":
+ response, _ := compat.Success(request.ID, color.Clone().Inspect())
+ return response
+ case "modify":
+ return modify(request.ID, &color, args)
+ case "mix":
+ return mix(request.ID, color, args)
+ case "readability":
+ return readability(request.ID, color, args)
+ case "isReadable":
+ return isReadable(request.ID, color, args)
+ case "mostReadable":
+ return mostReadable(request.ID, color, args)
+ case "palette":
+ return palette(request.ID, color, args)
+ case "setAlpha":
+ color.SetAlpha(args["value"])
+ response, _ := compat.Success(request.ID, color.Inspect())
+ return response
+ case "equals":
+ response, _ := compat.Success(request.ID, tinycolor.Equals(request.Input, args["other"]))
+ return response
+ case "randomInvariant":
+ randomColor := tinycolor.Random()
+ rgb := randomColor.ToRGB()
+ response, _ := compat.Success(request.ID, map[string]any{
+ "valid": randomColor.Valid(),
+ "alpha": float64(1),
+ "rgbInRange": rgb.R >= 0 && rgb.R <= 255 && rgb.G >= 0 && rgb.G <= 255 && rgb.B >= 0 && rgb.B <= 255,
+ })
+ return response
+ case "random":
+ response, _ := compat.Success(request.ID, tinycolor.Random().Inspect())
+ return response
+ case "names":
+ response, _ := compat.Success(request.ID, parser.Names())
+ return response
+ }
+ response, _ := compat.Success(request.ID, color.Inspect())
+ return response
+}
+
+func modify(id string, color *tinycolor.Color, args map[string]any) compat.Response {
+ before := color.Inspect()
+ method, _ := args["method"].(string)
+ var returned *tinycolor.Color
+ switch method {
+ case "lighten":
+ returned = color.Lighten(defaultAmount(args, 10))
+ case "brighten":
+ returned = color.Brighten(defaultAmount(args, 10))
+ case "darken":
+ returned = color.Darken(defaultAmount(args, 10))
+ case "saturate":
+ returned = color.Saturate(defaultAmount(args, 10))
+ case "desaturate":
+ returned = color.Desaturate(defaultAmount(args, 10))
+ case "greyscale":
+ returned = color.Greyscale()
+ case "spin":
+ if _, ok := args["amount"]; ok {
+ returned = color.Spin(number(args["amount"]))
+ } else {
+ returned = color.Spin(math.NaN())
+ }
+ default:
+ response, _ := compat.Failure(id, "unsupported method")
+ return response
+ }
+ response, _ := compat.Success(id, map[string]any{
+ "before": before,
+ "after": color.Inspect(),
+ "sameReceiver": returned == color,
+ })
+ return response
+}
+
+func mix(id string, first tinycolor.Color, args map[string]any) compat.Response {
+ second, err := tinycolor.FromCompat(args["other"], false)
+ if err != nil {
+ response, _ := compat.Failure(id, err.Error())
+ return response
+ }
+ response, _ := compat.Success(id, tinycolor.Mix(first, second, defaultAmount(args, 50)).Inspect())
+ return response
+}
+
+func readability(id string, first tinycolor.Color, args map[string]any) compat.Response {
+ second, _ := tinycolor.FromCompat(args["other"], false)
+ response, _ := compat.Success(id, tinycolor.Readability(first, second))
+ return response
+}
+
+func isReadable(id string, first tinycolor.Color, args map[string]any) compat.Response {
+ second, _ := tinycolor.FromCompat(args["other"], false)
+ options, err := wcagOptions(args)
+ if err != nil {
+ response, _ := compat.Failure("", err.Error())
+ return response
+ }
+ response, _ := compat.Success(id, tinycolor.IsReadable(first, second, options))
+ return response
+}
+
+func mostReadable(id string, base tinycolor.Color, args map[string]any) compat.Response {
+ inputs, _ := args["candidates"].([]any)
+ candidates := make([]tinycolor.Color, 0, len(inputs))
+ for _, input := range inputs {
+ candidate, _ := tinycolor.FromCompat(input, false)
+ candidates = append(candidates, candidate)
+ }
+ options, err := wcagOptions(args)
+ if err != nil {
+ response, _ := compat.Failure("", err.Error())
+ return response
+ }
+ result, ok := tinycolor.MostReadable(base, candidates, options)
+ if !ok {
+ response, _ := compat.Success(id, nil)
+ return response
+ }
+ response, _ := compat.Success(id, result.Inspect())
+ return response
+}
+
+func palette(id string, color tinycolor.Color, args map[string]any) compat.Response {
+ method, _ := args["method"].(string)
+ var colors []tinycolor.Color
+ switch method {
+ case "complement":
+ colors = []tinycolor.Color{color.Complement()}
+ case "splitcomplement":
+ colors = color.SplitComplement()
+ case "triad":
+ colors = color.Triad()
+ case "tetrad":
+ colors = color.Tetrad()
+ case "analogous":
+ colors = color.Analogous(defaultCount(args["results"], 6), defaultCount(args["slices"], 30))
+ case "monochromatic":
+ colors = color.Monochromatic(defaultCount(args["results"], 6))
+ default:
+ response, _ := compat.Failure(id, "unsupported method")
+ return response
+ }
+ inspections := make([]map[string]any, len(colors))
+ for index, paletteColor := range colors {
+ inspections[index] = paletteColor.Inspect()
+ }
+ response, _ := compat.Success(id, inspections)
+ return response
+}
+
+func defaultAmount(args map[string]any, fallback float64) float64 {
+ if value, ok := args["amount"]; ok {
+ if amount, ok := value.(float64); ok && amount == 0 {
+ return 0
+ }
+ if truthy(value) {
+ return number(value)
+ }
+ }
+ return fallback
+}
+
+func defaultCount(value any, fallback int) int {
+ if !truthy(value) {
+ return fallback
+ }
+ return int(number(value))
+}
+
+func number(value any) float64 {
+ switch value := value.(type) {
+ case float64:
+ return value
+ case bool:
+ if value {
+ return 1
+ }
+ return 0
+ case nil:
+ return 0
+ case string:
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return 0
+ }
+ amount, err := strconv.ParseFloat(value, 64)
+ if err == nil {
+ return amount
+ }
+ }
+ return math.NaN()
+}
+
+func options(args map[string]any) tinycolor.CompatOptions {
+ options, _ := args["options"].(map[string]any)
+ format, _ := options["format"].(string)
+ gradientType, _ := options["gradientType"].(bool)
+ return tinycolor.CompatOptions{Format: format, GradientType: gradientType}
+}
+
+func wcagOptions(args map[string]any) (tinycolor.WCAG2Options, error) {
+ raw, _ := args["options"].(map[string]any)
+ level, size := "", ""
+ if value := raw["level"]; truthy(value) {
+ var ok bool
+ if level, ok = value.(string); !ok {
+ return tinycolor.WCAG2Options{}, fmt.Errorf("(parms.level || \"AA\").toUpperCase is not a function")
+ }
+ }
+ if value := raw["size"]; truthy(value) {
+ var ok bool
+ if size, ok = value.(string); !ok {
+ return tinycolor.WCAG2Options{}, fmt.Errorf("(parms.size || \"small\").toLowerCase is not a function")
+ }
+ }
+ return tinycolor.WCAG2Options{
+ Level: level,
+ Size: size,
+ IncludeFallbackColors: truthy(raw["includeFallbackColors"]),
+ }, nil
+}
+
+func analysis(id string, color tinycolor.Color, method any) compat.Response {
+ var result any
+ switch method {
+ case "brightness":
+ result = color.Brightness()
+ case "luminance":
+ result = color.Luminance()
+ case "isDark":
+ result = color.IsDark()
+ case "isLight":
+ result = color.IsLight()
+ default:
+ response, _ := compat.Failure(id, "unsupported method")
+ return response
+ }
+ response, _ := compat.Success(id, result)
+ return response
+}
+
+func output(id string, color tinycolor.Color, args map[string]any) compat.Response {
+ var result any
+ switch args["method"] {
+ case "toRgb":
+ rgb := color.ToRGB()
+ result = map[string]any{"r": rgb.R, "g": rgb.G, "b": rgb.B, "a": rgb.A}
+ case "toPercentageRgb":
+ rgb := color.ToPercentageRGB()
+ result = map[string]any{"r": fmt.Sprintf("%d%%", rgb.R), "g": fmt.Sprintf("%d%%", rgb.G), "b": fmt.Sprintf("%d%%", rgb.B), "a": rgb.A}
+ case "toHsl":
+ hsl := color.ToHSL()
+ result = map[string]any{"h": hsl.H, "s": hsl.S, "l": hsl.L, "a": hsl.A}
+ case "toHsv":
+ hsv := color.ToHSV()
+ result = map[string]any{"h": hsv.H, "s": hsv.S, "v": hsv.V, "a": hsv.A}
+ case "toHex":
+ result = compactHex(color.ToHex(), truthy(args["compact"]))
+ case "toHex8":
+ result = compactHex(color.ToHex8(), truthy(args["compact"]))
+ case "toHexString":
+ result = "#" + compactHex(color.ToHex(), truthy(args["compact"]))
+ case "toHex8String":
+ result = "#" + compactHex(color.ToHex8(), truthy(args["compact"]))
+ case "toRgbString":
+ result = color.ToRGBString()
+ case "toPercentageRgbString":
+ result = color.ToPercentageRGBString()
+ case "toHslString":
+ result = color.ToHSLString()
+ case "toHsvString":
+ result = color.ToHSVString()
+ case "toString":
+ format, _ := args["format"].(string)
+ switch format {
+ case "hex3":
+ result = "#" + compactHex(color.ToHex(), true)
+ case "hex4":
+ result = "#" + compactHex(color.ToHex8(), true)
+ default:
+ result = color.ToString(format)
+ }
+ case "toName":
+ name, ok := color.ToName()
+ if ok {
+ result = name
+ } else {
+ result = false
+ }
+ case "toFilter":
+ var second *tinycolor.Color
+ if truthy(args["secondColor"]) {
+ parsed, _ := tinycolor.FromCompat(args["secondColor"], false)
+ second = &parsed
+ }
+ result = color.ToFilter(second, false)
+ default:
+ response, _ := compat.Failure(id, "unsupported method")
+ return response
+ }
+ response, _ := compat.Success(id, result)
+ return response
+}
+
+func compactHex(hex string, enabled bool) string {
+ if !enabled || (len(hex) != 6 && len(hex) != 8) {
+ return hex
+ }
+ compact := make([]byte, 0, len(hex)/2)
+ for index := 0; index < len(hex); index += 2 {
+ if hex[index] != hex[index+1] {
+ return hex
+ }
+ compact = append(compact, hex[index])
+ }
+ return string(compact)
+}
+
+func truthy(value any) bool {
+ switch value := value.(type) {
+ case nil:
+ return false
+ case bool:
+ return value
+ case float64:
+ return value != 0
+ case string:
+ return value != ""
+ default:
+ return true
+ }
+}
+
+func write(response compat.Response, stdout, stderr io.Writer) {
+ encoded, err := compat.Encode(response)
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ return
+ }
+ fmt.Fprintln(stdout, string(encoded))
+}
diff --git a/src/cmd/tinycolor-compat/main_test.go b/src/cmd/tinycolor-compat/main_test.go
new file mode 100644
index 00000000..173c2cfc
--- /dev/null
+++ b/src/cmd/tinycolor-compat/main_test.go
@@ -0,0 +1,232 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestRunHumanCommands(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ want string
+ verify func(*testing.T, string)
+ }{
+ {
+ name: "parse JSON inspection",
+ args: []string{"parse", "--json", "red"},
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ var got map[string]any
+ if err := json.Unmarshal([]byte(output), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got["hex"] != "ff0000" || got["valid"] != true {
+ t.Fatalf("parse JSON = %#v", got)
+ }
+ },
+ },
+ {
+ name: "parse invalid JSON inspection",
+ args: []string{"parse", "--json", "not-a-color"},
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ var got map[string]any
+ if err := json.Unmarshal([]byte(output), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got["valid"] != false {
+ t.Fatalf("parse JSON = %#v", got)
+ }
+ },
+ },
+ {name: "convert HSL", args: []string{"convert", "--to", "hsl", "red"}, want: "hsl(0, 100%, 50%)\n"},
+ {name: "lighten", args: []string{"lighten", "--amount", "10", "#000"}, want: "#1a1a1a\n"},
+ {
+ name: "triad palette",
+ args: []string{"palette", "--type", "triad", "red"},
+ want: "#ff0000\n#00ff00\n#0000ff\n",
+ },
+ {
+ name: "triad palette JSON inspections",
+ args: []string{"palette", "--type", "triad", "--json", "red"},
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ var got []map[string]any
+ if err := json.Unmarshal([]byte(output), &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 3 || got[0]["valid"] != true || got[0]["value"] != "red" {
+ t.Fatalf("palette JSON = %#v", got)
+ }
+ },
+ },
+ {
+ name: "contrast JSON",
+ args: []string{"contrast", "--json", "#000", "#fff"},
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ var got map[string]any
+ if err := json.Unmarshal([]byte(output), &got); err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]any{"ratio": float64(21), "aaSmall": true, "aaLarge": true, "aaaSmall": true, "aaaLarge": true}
+ for key, value := range want {
+ if got[key] != value {
+ t.Fatalf("contrast %s = %#v, want %#v", key, got[key], value)
+ }
+ }
+ },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ if status := run(test.args, strings.NewReader(""), &stdout, &stderr); status != 0 {
+ t.Fatalf("status = %d, stderr = %q", status, stderr.String())
+ }
+ if test.verify != nil {
+ test.verify(t, stdout.String())
+ return
+ }
+ if stdout.String() != test.want {
+ t.Fatalf("stdout = %q, want %q", stdout.String(), test.want)
+ }
+ })
+ }
+}
+
+func TestRunJSONLAndUsageErrors(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ stdin string
+ wantStatus int
+ wantStdout string
+ verify func(*testing.T, string)
+ }{
+ {
+ name: "JSONL inspect without arguments",
+ stdin: "{\"id\":\"red\",\"operation\":\"inspect\",\"input\":\"red\"}\n",
+ wantStdout: "{\"id\":\"red\",\"result\":{\"alpha\":1,\"format\":\"name\",\"original\":\"red\",\"rgb\":{\"a\":1,\"b\":0,\"g\":0,\"r\":255},\"valid\":true,\"value\":\"red\"}}\n",
+ },
+ {
+ name: "JSONL string forwards explicit format",
+ stdin: "{\"id\":\"string-format\",\"operation\":\"string\",\"input\":\"red\",\"args\":{\"format\":\"hsl\"}}\n",
+ wantStdout: "{\"id\":\"string-format\",\"result\":\"hsl(0, 100%, 50%)\"}\n",
+ },
+ {
+ name: "JSONL fromRatio forwards explicit format",
+ stdin: "{\"id\":\"ratio-format\",\"operation\":\"fromRatio\",\"input\":{\"r\":1,\"g\":0,\"b\":0,\"a\":1},\"args\":{\"format\":\"hex\"}}\n",
+ wantStdout: "{\"id\":\"ratio-format\",\"result\":{\"alpha\":1,\"format\":\"hex\",\"original\":{\"a\":1,\"b\":\"0%\",\"g\":\"0%\",\"r\":\"100%\"},\"rgb\":{\"a\":1,\"b\":0,\"g\":0,\"r\":255},\"valid\":true,\"value\":\"#ff0000\"}}\n",
+ },
+ {
+ name: "JSONL inspect preserves one-percent HSL channels",
+ stdin: "{\"id\":\"hsl-one-percent\",\"operation\":\"inspect\",\"input\":\"hsl(115, 1%, 1%)\"}\n",
+ wantStdout: "{\"id\":\"hsl-one-percent\",\"result\":{\"alpha\":1,\"format\":\"hsl\",\"original\":\"hsl(115, 1%, 1%)\",\"rgb\":{\"a\":1,\"b\":3,\"g\":3,\"r\":3},\"valid\":true,\"value\":\"hsl(115, 1%, 1%)\"}}\n",
+ },
+ {
+ name: "one-request bridge",
+ args: []string{"bridge", `{"id":"bridge-red","operation":"output","input":"red","args":{"method":"toHexString"}}`},
+ wantStdout: "{\"id\":\"bridge-red\",\"result\":\"#ff0000\"}\n",
+ },
+ {
+ name: "bridge RGB object",
+ args: []string{"bridge", `{"id":"rgb","operation":"output","input":"red","args":{"method":"toRgb"}}`},
+ wantStdout: "{\"id\":\"rgb\",\"result\":{\"a\":1,\"b\":0,\"g\":0,\"r\":255}}\n",
+ },
+ {
+ name: "bridge percentage RGB object",
+ args: []string{"bridge", `{"id":"prgb","operation":"output","input":"red","args":{"method":"toPercentageRgb"}}`},
+ wantStdout: "{\"id\":\"prgb\",\"result\":{\"a\":1,\"b\":\"0%\",\"g\":\"0%\",\"r\":\"100%\"}}\n",
+ },
+ {
+ name: "bridge HSL object",
+ args: []string{"bridge", `{"id":"hsl","operation":"output","input":"red","args":{"method":"toHsl"}}`},
+ wantStdout: "{\"id\":\"hsl\",\"result\":{\"a\":1,\"h\":0,\"l\":0.5,\"s\":1}}\n",
+ },
+ {
+ name: "bridge HSV object",
+ args: []string{"bridge", `{"id":"hsv","operation":"output","input":"red","args":{"method":"toHsv"}}`},
+ wantStdout: "{\"id\":\"hsv\",\"result\":{\"a\":1,\"h\":0,\"s\":1,\"v\":1}}\n",
+ },
+ {
+ name: "bridge compact hex",
+ args: []string{"bridge", `{"id":"compact","operation":"output","input":"red","args":{"method":"toHexString","compact":true}}`},
+ wantStdout: "{\"id\":\"compact\",\"result\":\"#f00\"}\n",
+ },
+ {
+ name: "bridge explicit hex4",
+ args: []string{"bridge", `{"id":"hex4","operation":"output","input":"rgba(255, 0, 0, 0.6)","args":{"method":"toString","format":"hex4"}}`},
+ wantStdout: "{\"id\":\"hex4\",\"result\":\"#f009\"}\n",
+ },
+ {
+ name: "bridge alpha mutation",
+ args: []string{"bridge", `{"id":"alpha","operation":"setAlpha","input":"red","args":{"value":0.5}}`},
+ wantStdout: "{\"id\":\"alpha\",\"result\":{\"alpha\":0.5,\"format\":\"name\",\"original\":\"red\",\"rgb\":{\"a\":0.5,\"b\":0,\"g\":0,\"r\":255},\"valid\":true,\"value\":\"rgba(255, 0, 0, 0.5)\"}}\n",
+ },
+ {
+ name: "bridge random snapshot",
+ args: []string{"bridge", `{"id":"random","operation":"random"}`},
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ if !strings.Contains(output, `"format":"prgb"`) || !strings.Contains(output, `"valid":true`) {
+ t.Fatalf("stdout = %q", output)
+ }
+ },
+ },
+ {
+ name: "bridge Go-owned names",
+ args: []string{"bridge", `{"id":"names","operation":"names"}`},
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ if !strings.Contains(output, `"red":"f00"`) || !strings.Contains(output, `"rebeccapurple":"663399"`) {
+ t.Fatalf("stdout = %q", output)
+ }
+ },
+ },
+ {name: "missing bridge request", args: []string{"bridge"}, wantStatus: 2},
+ {
+ name: "malformed bridge request",
+ args: []string{"bridge", "{bad"},
+ wantStatus: 1,
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ if !strings.Contains(output, "malformed JSON") {
+ t.Fatalf("stdout = %q", output)
+ }
+ },
+ },
+ {
+ name: "malformed JSONL request does not stop the stream",
+ stdin: "{bad json\n{\"id\":\"red\",\"operation\":\"inspect\",\"input\":\"red\"}\n",
+ verify: func(t *testing.T, output string) {
+ t.Helper()
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ if len(lines) != 2 || !strings.Contains(lines[0], "malformed JSON") || !strings.Contains(lines[1], "\"id\":\"red\"") {
+ t.Fatalf("stdout = %q", output)
+ }
+ },
+ },
+ {name: "unknown command", args: []string{"unknown"}, wantStatus: 2},
+ {name: "missing convert target", args: []string{"convert", "red"}, wantStatus: 2},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ if status := run(test.args, strings.NewReader(test.stdin), &stdout, &stderr); status != test.wantStatus {
+ t.Fatalf("status = %d, want %d", status, test.wantStatus)
+ }
+ if test.verify != nil {
+ test.verify(t, stdout.String())
+ } else if stdout.String() != test.wantStdout {
+ t.Fatalf("stdout = %q, want %q", stdout.String(), test.wantStdout)
+ }
+ if test.wantStatus == 2 && !strings.Contains(stderr.String(), "Usage: tinycolor ") {
+ t.Fatalf("stderr = %q, want tinycolor usage", stderr.String())
+ }
+ })
+ }
+}
diff --git a/src/go.mod b/src/go.mod
new file mode 100644
index 00000000..fe59f263
--- /dev/null
+++ b/src/go.mod
@@ -0,0 +1,3 @@
+module github.com/rajeet-04/tinycolor-go
+
+go 1.26
diff --git a/src/internal/color/bounds.go b/src/internal/color/bounds.go
new file mode 100644
index 00000000..a9e64d72
--- /dev/null
+++ b/src/internal/color/bounds.go
@@ -0,0 +1,77 @@
+package color
+
+import (
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var jsParseFloat = regexp.MustCompile(`^[\t\n\v\f\r ]*[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:[eE][+-]?\d+)?`)
+
+// ParseFloat mirrors the prefix parsing used by JavaScript parseFloat for the
+// numeric forms TinyColor accepts.
+func ParseFloat(value any) float64 {
+ switch number := value.(type) {
+ case float64:
+ return number
+ case float32:
+ return float64(number)
+ case int:
+ return float64(number)
+ case int64:
+ return float64(number)
+ case string:
+ match := jsParseFloat.FindString(number)
+ if match == "" {
+ return math.NaN()
+ }
+ parsed, err := strconv.ParseFloat(strings.TrimSpace(match), 64)
+ if err != nil {
+ return math.NaN()
+ }
+ return parsed
+ default:
+ return math.NaN()
+ }
+}
+
+func IsPercentage(value any) bool {
+ text, ok := value.(string)
+ return ok && strings.Contains(text, "%")
+}
+
+func IsOnePointZero(value any) bool {
+ text, ok := value.(string)
+ return ok && strings.Contains(text, ".") && ParseFloat(text) == 1
+}
+
+// Bound01 is TinyColor's bound01 helper, including percentage truncation and
+// its near-maximum floating-point correction.
+func Bound01(value any, max float64) float64 {
+ if IsOnePointZero(value) {
+ value = "100%"
+ }
+ percentage := IsPercentage(value)
+ number := ParseFloat(value)
+ if math.IsNaN(number) {
+ return math.NaN()
+ }
+ number = math.Min(max, math.Max(0, number))
+ if percentage {
+ number = math.Trunc(number*max) / 100
+ }
+ if math.Abs(number-max) < 0.000001 {
+ return 1
+ }
+ return math.Mod(number, max) / max
+}
+
+// BoundAlpha keeps TinyColor's intentionally forgiving alpha behavior.
+func BoundAlpha(value any) float64 {
+ alpha := ParseFloat(value)
+ if math.IsNaN(alpha) || alpha < 0 || alpha > 1 {
+ return 1
+ }
+ return alpha
+}
diff --git a/src/internal/color/model.go b/src/internal/color/model.go
new file mode 100644
index 00000000..85db3c17
--- /dev/null
+++ b/src/internal/color/model.go
@@ -0,0 +1,53 @@
+// Package color holds the normalized state shared by all parser paths.
+package color
+
+// Format is TinyColor's detected input format. An empty Format represents the
+// source's false format value for invalid input.
+type Format string
+
+const (
+ FormatUnknown Format = ""
+ FormatRGB Format = "rgb"
+ FormatPRGB Format = "prgb"
+ FormatHSL Format = "hsl"
+ FormatHSV Format = "hsv"
+ FormatHex Format = "hex"
+ FormatHex8 Format = "hex8"
+ FormatName Format = "name"
+)
+
+// Model is TinyColor's normalized constructor state. It is internal to this
+// module so the public facade cannot bypass source-compatible parsing.
+type Model struct {
+ R, G, B float64
+ A float64
+ Valid bool
+ Format Format
+ Original any
+}
+
+// Invalid returns the source invalid-color state: opaque black, no format,
+// and a retained original input.
+func Invalid(original any) Model {
+ return Model{A: 1, Original: original}
+}
+
+// OriginalInput applies TinyColor's constructor normalization to the JSON-safe
+// inputs used by the compatibility adapter.
+func OriginalInput(input any) any {
+ switch value := input.(type) {
+ case nil:
+ return ""
+ case string:
+ return value
+ case bool:
+ if !value {
+ return ""
+ }
+ case float64:
+ if value == 0 {
+ return ""
+ }
+ }
+ return input
+}
diff --git a/src/internal/color/model_test.go b/src/internal/color/model_test.go
new file mode 100644
index 00000000..a5304110
--- /dev/null
+++ b/src/internal/color/model_test.go
@@ -0,0 +1,63 @@
+package color
+
+import (
+ "math"
+ "reflect"
+ "testing"
+)
+
+func TestBound01MatchesTinyColorEdges(t *testing.T) {
+ tests := []struct {
+ input any
+ max float64
+ want float64
+ }{
+ {-1, 255, 0},
+ {300, 255, 1},
+ {"1", 255, 1.0 / 255},
+ {"1.0", 255, 1},
+ {"100%", 255, 1},
+ {"50%", 255, 0.5},
+ {"255.0000001", 255, 1},
+ }
+ for _, tt := range tests {
+ if got := Bound01(tt.input, tt.max); got != tt.want {
+ t.Errorf("Bound01(%#v, %v) = %v, want %v", tt.input, tt.max, got, tt.want)
+ }
+ }
+ if !math.IsNaN(Bound01("not a number", 255)) {
+ t.Fatal("invalid input should stay NaN until the parser rejects it")
+ }
+}
+
+func TestBoundAlphaMatchesTinyColor(t *testing.T) {
+ tests := []struct {
+ input any
+ want float64
+ }{
+ {-1, 1}, {0, 0}, {0.5, 0.5}, {1, 1}, {100, 1}, {"asdf", 1},
+ }
+ for _, tt := range tests {
+ if got := BoundAlpha(tt.input); got != tt.want {
+ t.Errorf("BoundAlpha(%#v) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestOriginalInputAndInvalidState(t *testing.T) {
+ object := map[string]any{"r": float64(255)}
+ for _, tt := range []struct {
+ input any
+ want any
+ }{
+ {nil, ""}, {"", ""}, {"Red", "Red"}, {false, ""}, {float64(0), ""}, {object, object},
+ } {
+ if got := OriginalInput(tt.input); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("OriginalInput(%#v) = %#v, want %#v", tt.input, got, tt.want)
+ }
+ }
+ invalid := Invalid("bad")
+ if invalid.Valid || invalid.R != 0 || invalid.G != 0 || invalid.B != 0 || invalid.A != 1 || invalid.Format != FormatUnknown {
+ t.Fatalf("invalid state = %#v", invalid)
+ }
+}
diff --git a/src/internal/compat/protocol.go b/src/internal/compat/protocol.go
new file mode 100644
index 00000000..c2a8d966
--- /dev/null
+++ b/src/internal/compat/protocol.go
@@ -0,0 +1,71 @@
+package compat
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+type Request struct {
+ ID string `json:"id"`
+ Operation string `json:"operation"`
+ Input any `json:"input"`
+ Args any `json:"args,omitempty"`
+}
+
+type Response struct {
+ ID string `json:"id"`
+ Result any `json:"-"`
+ Error string `json:"-"`
+ hasResult bool
+}
+
+func Success(id string, result any) (Response, error) {
+ response := Response{ID: id, Result: result, hasResult: true}
+ return response, response.Validate()
+}
+
+func Failure(id, message string) (Response, error) {
+ response := Response{ID: id, Error: message}
+ return response, response.Validate()
+}
+
+func (r Response) Validate() error {
+ if r.hasResult == (r.Error != "") {
+ return fmt.Errorf("response must contain exactly one of result or error")
+ }
+ return nil
+}
+
+func (r Response) MarshalJSON() ([]byte, error) {
+ if err := r.Validate(); err != nil {
+ return nil, err
+ }
+ if r.hasResult {
+ return json.Marshal(struct {
+ ID string `json:"id"`
+ Result any `json:"result"`
+ }{r.ID, r.Result})
+ }
+ return json.Marshal(struct {
+ ID string `json:"id"`
+ Error string `json:"error"`
+ }{r.ID, r.Error})
+}
+
+func Decode(line []byte) (Request, error) {
+ var request Request
+ if err := json.Unmarshal(line, &request); err != nil {
+ return Request{}, fmt.Errorf("malformed JSON")
+ }
+ if request.ID == "" || request.Operation == "" {
+ return Request{}, fmt.Errorf("id and operation are required")
+ }
+ return request, nil
+}
+
+func Encode(response Response) ([]byte, error) {
+ if err := response.Validate(); err != nil {
+ return nil, err
+ }
+ return json.Marshal(response)
+}
diff --git a/src/internal/compat/protocol_test.go b/src/internal/compat/protocol_test.go
new file mode 100644
index 00000000..bf1cf3e8
--- /dev/null
+++ b/src/internal/compat/protocol_test.go
@@ -0,0 +1,29 @@
+package compat
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestResponseHasExactlyOnePayload(t *testing.T) {
+ if _, err := Success("one", map[string]any{"ok": true}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Failure("one", "bad request"); err != nil {
+ t.Fatal(err)
+ }
+ if err := (Response{ID: "one"}).Validate(); err == nil {
+ t.Fatal("expected neither payload error")
+ }
+ if err := (Response{ID: "one", Error: "bad", hasResult: true}).Validate(); err == nil {
+ t.Fatal("expected both payloads error")
+ }
+}
+
+func TestFalseResultIsPresent(t *testing.T) {
+ response, _ := Success("no", false)
+ encoded, err := Encode(response)
+ if err != nil || !strings.Contains(string(encoded), `"result":false`) {
+ t.Fatalf("%s %v", encoded, err)
+ }
+}
diff --git a/src/internal/parser/hex.go b/src/internal/parser/hex.go
new file mode 100644
index 00000000..32e4441c
--- /dev/null
+++ b/src/internal/parser/hex.go
@@ -0,0 +1,41 @@
+package parser
+
+import (
+ "strconv"
+
+ "github.com/rajeet-04/tinycolor-go/internal/color"
+)
+
+func parseHex(text string, original any, format color.Format) (color.Model, bool) {
+ if len(text) > 0 && text[0] == '#' {
+ text = text[1:]
+ }
+ if len(text) != 3 && len(text) != 4 && len(text) != 6 && len(text) != 8 {
+ return color.Model{}, false
+ }
+ for _, rune := range text {
+ if !((rune >= '0' && rune <= '9') || (rune >= 'a' && rune <= 'f')) {
+ return color.Model{}, false
+ }
+ }
+ if len(text) == 3 || len(text) == 4 {
+ text = string([]byte{text[0], text[0], text[1], text[1], text[2], text[2]}) + func() string {
+ if len(text) == 4 {
+ return string([]byte{text[3], text[3]})
+ }
+ return ""
+ }()
+ }
+ channel := func(offset int) float64 {
+ value, _ := strconv.ParseInt(text[offset:offset+2], 16, 64)
+ return float64(value)
+ }
+ model := color.Model{R: channel(0), G: channel(2), B: channel(4), A: 1, Valid: true, Format: format, Original: original}
+ if len(text) == 8 {
+ model.A = channel(6) / 255
+ if format == color.FormatHex {
+ model.Format = color.FormatHex8
+ }
+ }
+ return model, true
+}
diff --git a/src/internal/parser/hsl.go b/src/internal/parser/hsl.go
new file mode 100644
index 00000000..dccc646d
--- /dev/null
+++ b/src/internal/parser/hsl.go
@@ -0,0 +1,13 @@
+package parser
+
+import "github.com/rajeet-04/tinycolor-go/internal/color"
+
+func parseHSL(text string, original any) (color.Model, bool) {
+ if match := hslaMatcher.FindStringSubmatch(text); match != nil {
+ return hslModel(match[1], match[2], match[3], match[4], true, original), true
+ }
+ if match := hslMatcher.FindStringSubmatch(text); match != nil {
+ return hslModel(match[1], match[2], match[3], nil, false, original), true
+ }
+ return color.Model{}, false
+}
diff --git a/src/internal/parser/hsv.go b/src/internal/parser/hsv.go
new file mode 100644
index 00000000..987f34eb
--- /dev/null
+++ b/src/internal/parser/hsv.go
@@ -0,0 +1,13 @@
+package parser
+
+import "github.com/rajeet-04/tinycolor-go/internal/color"
+
+func parseHSV(text string, original any) (color.Model, bool) {
+ if match := hsvaMatcher.FindStringSubmatch(text); match != nil {
+ return hsvModel(match[1], match[2], match[3], match[4], true, original), true
+ }
+ if match := hsvMatcher.FindStringSubmatch(text); match != nil {
+ return hsvModel(match[1], match[2], match[3], nil, false, original), true
+ }
+ return color.Model{}, false
+}
diff --git a/src/internal/parser/names.go b/src/internal/parser/names.go
new file mode 100644
index 00000000..45ff78a7
--- /dev/null
+++ b/src/internal/parser/names.go
@@ -0,0 +1,46 @@
+package parser
+
+import (
+ "fmt"
+ "maps"
+ "strings"
+)
+
+// names is copied from this checkout's mod.js names table.
+var (
+ names = func() map[string]string {
+ const data = "aliceblue:f0f8ff;antiquewhite:faebd7;aqua:0ff;aquamarine:7fffd4;azure:f0ffff;beige:f5f5dc;bisque:ffe4c4;black:000;blanchedalmond:ffebcd;blue:00f;blueviolet:8a2be2;brown:a52a2a;burlywood:deb887;burntsienna:ea7e5d;cadetblue:5f9ea0;chartreuse:7fff00;chocolate:d2691e;coral:ff7f50;cornflowerblue:6495ed;cornsilk:fff8dc;crimson:dc143c;cyan:0ff;darkblue:00008b;darkcyan:008b8b;darkgoldenrod:b8860b;darkgray:a9a9a9;darkgreen:006400;darkgrey:a9a9a9;darkkhaki:bdb76b;darkmagenta:8b008b;darkolivegreen:556b2f;darkorange:ff8c00;darkorchid:9932cc;darkred:8b0000;darksalmon:e9967a;darkseagreen:8fbc8f;darkslateblue:483d8b;darkslategray:2f4f4f;darkslategrey:2f4f4f;darkturquoise:00ced1;darkviolet:9400d3;deeppink:ff1493;deepskyblue:00bfff;dimgray:696969;dimgrey:696969;dodgerblue:1e90ff;firebrick:b22222;floralwhite:fffaf0;forestgreen:228b22;fuchsia:f0f;gainsboro:dcdcdc;ghostwhite:f8f8ff;gold:ffd700;goldenrod:daa520;gray:808080;green:008000;greenyellow:adff2f;grey:808080;honeydew:f0fff0;hotpink:ff69b4;indianred:cd5c5c;indigo:4b0082;ivory:fffff0;khaki:f0e68c;lavender:e6e6fa;lavenderblush:fff0f5;lawngreen:7cfc00;lemonchiffon:fffacd;lightblue:add8e6;lightcoral:f08080;lightcyan:e0ffff;lightgoldenrodyellow:fafad2;lightgray:d3d3d3;lightgreen:90ee90;lightgrey:d3d3d3;lightpink:ffb6c1;lightsalmon:ffa07a;lightseagreen:20b2aa;lightskyblue:87cefa;lightslategray:789;lightslategrey:789;lightsteelblue:b0c4de;lightyellow:ffffe0;lime:0f0;limegreen:32cd32;linen:faf0e6;magenta:f0f;maroon:800000;mediumaquamarine:66cdaa;mediumblue:0000cd;mediumorchid:ba55d3;mediumpurple:9370db;mediumseagreen:3cb371;mediumslateblue:7b68ee;mediumspringgreen:00fa9a;mediumturquoise:48d1cc;mediumvioletred:c71585;midnightblue:191970;mintcream:f5fffa;mistyrose:ffe4e1;moccasin:ffe4b5;navajowhite:ffdead;navy:000080;oldlace:fdf5e6;olive:808000;olivedrab:6b8e23;orange:ffa500;orangered:ff4500;orchid:da70d6;palegoldenrod:eee8aa;palegreen:98fb98;paleturquoise:afeeee;palevioletred:db7093;papayawhip:ffefd5;peachpuff:ffdab9;peru:cd853f;pink:ffc0cb;plum:dda0dd;powderblue:b0e0e6;purple:800080;rebeccapurple:663399;red:f00;rosybrown:bc8f8f;royalblue:4169e1;saddlebrown:8b4513;salmon:fa8072;sandybrown:f4a460;seagreen:2e8b57;seashell:fff5ee;sienna:a0522d;silver:c0c0c0;skyblue:87ceeb;slateblue:6a5acd;slategray:708090;slategrey:708090;snow:fffafa;springgreen:00ff7f;steelblue:4682b4;tan:d2b48c;teal:008080;thistle:d8bfd8;tomato:ff6347;turquoise:40e0d0;violet:ee82ee;wheat:f5deb3;white:fff;whitesmoke:f5f5f5;yellow:ff0;yellowgreen:9acd32"
+ result := make(map[string]string)
+ for _, entry := range strings.Split(data, ";") {
+ pair := strings.SplitN(entry, ":", 2)
+ result[pair[0]] = pair[1]
+ }
+ return result
+ }()
+ hexNames = func() map[string]string {
+ result := make(map[string]string)
+ for name, sourceHex := range names {
+ result[sourceHex] = name
+ }
+ result["0ff"] = "cyan"
+ result["f0f"] = "magenta"
+ result["808080"] = "grey"
+ result["a9a9a9"] = "darkgrey"
+ result["2f4f4f"] = "darkslategrey"
+ result["696969"] = "dimgrey"
+ result["d3d3d3"] = "lightgrey"
+ result["789"] = "lightslategrey"
+ result["708090"] = "slategrey"
+ return result
+ }()
+)
+
+func Names() map[string]string { return maps.Clone(names) }
+
+func NameForRGB(r, g, b int) string {
+ hex := fmt.Sprintf("%02x%02x%02x", r, g, b)
+ if hex[0] == hex[1] && hex[2] == hex[3] && hex[4] == hex[5] {
+ hex = string([]byte{hex[0], hex[2], hex[4]})
+ }
+ return hexNames[hex]
+}
diff --git a/src/internal/parser/object.go b/src/internal/parser/object.go
new file mode 100644
index 00000000..eac330cf
--- /dev/null
+++ b/src/internal/parser/object.go
@@ -0,0 +1,22 @@
+package parser
+
+import "github.com/rajeet-04/tinycolor-go/internal/color"
+
+func parseObjectFull(object map[string]any, original any) color.Model {
+ if model := parseObject(object, original); model.Valid {
+ return model
+ }
+ h, hasH := object["h"]
+ s, hasS := object["s"]
+ if !hasH || !hasS || !validCSSUnit(h) || !validCSSUnit(s) {
+ return color.Invalid(original)
+ }
+ alpha, hasAlpha := object["a"]
+ if v, ok := object["v"]; ok && validCSSUnit(v) {
+ return hsvModel(h, s, v, alpha, hasAlpha, original)
+ }
+ if l, ok := object["l"]; ok && validCSSUnit(l) {
+ return hslModel(h, s, l, alpha, hasAlpha, original)
+ }
+ return color.Invalid(original)
+}
diff --git a/src/internal/parser/parser.go b/src/internal/parser/parser.go
new file mode 100644
index 00000000..f5c79ede
--- /dev/null
+++ b/src/internal/parser/parser.go
@@ -0,0 +1,179 @@
+// Package parser converts JSON-safe compatibility inputs into normalized state.
+package parser
+
+import (
+ "math"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/rajeet-04/tinycolor-go/internal/color"
+)
+
+// Parse accepts the local TinyColor input forms implemented in this phase.
+func Parse(input any) color.Model {
+ original := color.OriginalInput(input)
+ switch value := original.(type) {
+ case string:
+ return parseString(value, original)
+ case map[string]any:
+ return parseObjectFull(value, original)
+ default:
+ return color.Invalid(original)
+ }
+}
+
+// ParseFromRatio applies TinyColor.fromRatio's object transformation before
+// parsing; the transformed object is also TinyColor's original input.
+func ParseFromRatio(input any) color.Model {
+ object, ok := input.(map[string]any)
+ if !ok {
+ return Parse(input)
+ }
+ transformed := make(map[string]any, len(object))
+ for key, value := range object {
+ if key == "a" {
+ transformed[key] = value
+ continue
+ }
+ if number := color.ParseFloat(value); !math.IsNaN(number) && number <= 1 {
+ transformed[key] = formatPercent(number*100) + "%"
+ } else {
+ transformed[key] = value
+ }
+ }
+ return parseObjectFull(transformed, transformed)
+}
+
+func formatPercent(number float64) string {
+ return strconv.FormatFloat(number, 'f', -1, 64)
+}
+
+var (
+ hslMatcher = regexp.MustCompile(`(?i)hsl[\s|(]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)\s*\)?`)
+ hsvMatcher = regexp.MustCompile(`(?i)hsv[\s|(]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)\s*\)?`)
+ hslaMatcher = regexp.MustCompile(`(?i)hsla[\s|(]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)\s*\)?`)
+ hsvaMatcher = regexp.MustCompile(`(?i)hsva[\s|(]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)\s*\)?`)
+)
+
+func parseString(value string, original any) color.Model {
+ text := strings.ToLower(strings.TrimSpace(value))
+ if text == "transparent" {
+ return color.Model{A: 0, Valid: true, Format: color.FormatName, Original: original}
+ }
+ if hex, named := names[text]; named {
+ model, _ := parseHex(hex, original, color.FormatName)
+ return model
+ }
+ if model, ok := parseRGBString(text, original); ok {
+ return model
+ }
+ if model, ok := parseHex(text, original, color.FormatHex); ok {
+ return model
+ }
+ // Phase 1 smoke must remain executable while Plan 02-02 moves these paths
+ // into dedicated files. This is generic grammar, not a fixed input switch.
+ if model, ok := parseHSL(text, original); ok {
+ return model
+ }
+ if model, ok := parseHSV(text, original); ok {
+ return model
+ }
+ return color.Invalid(original)
+}
+
+func parseObject(object map[string]any, original any) color.Model {
+ r, hasR := object["r"]
+ g, hasG := object["g"]
+ b, hasB := object["b"]
+ if !hasR || !hasG || !hasB || !validCSSUnit(r) || !validCSSUnit(g) || !validCSSUnit(b) {
+ return color.Invalid(original)
+ }
+ alpha, hasAlpha := object["a"]
+ format := formatForRGB(r)
+ if explicit, ok := object["format"].(string); ok && explicit != "" {
+ format = color.Format(explicit)
+ }
+ return rgbModel(r, g, b, alpha, hasAlpha, original, format)
+}
+
+func parseHSLHSVSmoke(text string, original any) (color.Model, bool) {
+ if match := hslaMatcher.FindStringSubmatch(text); match != nil {
+ return hslModel(match[1], match[2], match[3], match[4], true, original), true
+ }
+ if match := hslMatcher.FindStringSubmatch(text); match != nil {
+ return hslModel(match[1], match[2], match[3], nil, false, original), true
+ }
+ if match := hsvaMatcher.FindStringSubmatch(text); match != nil {
+ return hsvModel(match[1], match[2], match[3], match[4], true, original), true
+ }
+ if match := hsvMatcher.FindStringSubmatch(text); match != nil {
+ return hsvModel(match[1], match[2], match[3], nil, false, original), true
+ }
+ return color.Model{}, false
+}
+
+func hslModel(h, s, l, alpha any, hasAlpha bool, original any) color.Model {
+ hue := color.Bound01(h, 360)
+ saturation := ratio(s)
+ lightness := ratio(l)
+ var q float64
+ if lightness < 0.5 {
+ q = lightness * (1 + saturation)
+ } else {
+ q = lightness + saturation - lightness*saturation
+ }
+ p := 2*lightness - q
+ channel := func(t float64) float64 {
+ if t < 0 {
+ t++
+ }
+ if t > 1 {
+ t--
+ }
+ switch {
+ case t*6 < 1:
+ return p + (q-p)*6*t
+ case t*2 < 1:
+ return q
+ case t*3 < 2:
+ return p + (q-p)*(2.0/3.0-t)*6
+ default:
+ return p
+ }
+ }
+ if saturation == 0 {
+ p = lightness
+ q = lightness
+ }
+ model := color.Model{R: channel(hue+1.0/3.0) * 255, G: channel(hue) * 255, B: channel(hue-1.0/3.0) * 255, A: color.BoundAlpha(alpha), Valid: true, Format: color.FormatHSL, Original: original}
+ if !hasAlpha {
+ model.A = 1
+ }
+ return model
+}
+
+func hsvModel(h, s, v, alpha any, hasAlpha bool, original any) color.Model {
+ hue := color.Bound01(h, 360) * 6
+ saturation := ratio(s)
+ value := ratio(v)
+ i := int(math.Floor(hue))
+ f := hue - math.Floor(hue)
+ p := value * (1 - saturation)
+ q := value * (1 - f*saturation)
+ t := value * (1 - (1-f)*saturation)
+ channels := [][3]float64{{value, t, p}, {q, value, p}, {p, value, t}, {p, q, value}, {t, p, value}, {value, p, q}}
+ rgb := channels[i%6]
+ model := color.Model{R: rgb[0] * 255, G: rgb[1] * 255, B: rgb[2] * 255, A: color.BoundAlpha(alpha), Valid: true, Format: color.FormatHSV, Original: original}
+ if !hasAlpha {
+ model.A = 1
+ }
+ return model
+}
+
+func ratio(value any) float64 {
+ if number := color.ParseFloat(value); !color.IsPercentage(value) && !math.IsNaN(number) && number <= 1 {
+ value = formatPercent(number*100) + "%"
+ }
+ return color.Bound01(value, 100)
+}
diff --git a/src/internal/parser/parser_test.go b/src/internal/parser/parser_test.go
new file mode 100644
index 00000000..0b43da8e
--- /dev/null
+++ b/src/internal/parser/parser_test.go
@@ -0,0 +1,86 @@
+package parser
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/rajeet-04/tinycolor-go/internal/color"
+)
+
+func TestHexRGBNamesAndObjects(t *testing.T) {
+ tests := []struct {
+ input any
+ valid bool
+ format color.Format
+ r, g, b float64
+ alpha float64
+ }{
+ {"#f00", true, color.FormatHex, 255, 0, 0, 1},
+ {"ff000080", true, color.FormatHex8, 255, 0, 0, 128.0 / 255},
+ {" InDiAnReD ", true, color.FormatName, 205, 92, 92, 1},
+ {"transparent", true, color.FormatName, 0, 0, 0, 0},
+ {"rgb (100%, 0%, 0%)", true, color.FormatPRGB, 255, 0, 0, 1},
+ {"rgba 255 0 0 .5", true, color.FormatRGB, 255, 0, 0, .5},
+ {map[string]any{"r": "90%", "g": "45%", "b": "0%", "a": .4}, true, color.FormatPRGB, 229.5, 114.75, 0, .4},
+ {"##123456", false, color.FormatUnknown, 0, 0, 0, 1},
+ {map[string]any{"r": "invalid", "g": "invalid", "b": "invalid"}, false, color.FormatUnknown, 0, 0, 0, 1},
+ }
+ for _, tt := range tests {
+ model := Parse(tt.input)
+ if model.Valid != tt.valid || model.Format != tt.format || model.R != tt.r || model.G != tt.g || model.B != tt.b || model.A != tt.alpha {
+ t.Errorf("Parse(%#v) = %#v", tt.input, model)
+ }
+ }
+}
+
+func TestAllSourceNamesParse(t *testing.T) {
+ for name := range names {
+ model := Parse(name)
+ if !model.Valid || model.Format != color.FormatName {
+ t.Errorf("%s = %#v", name, model)
+ }
+ }
+}
+
+func TestNamesReturnsIndependentCopy(t *testing.T) {
+ first := Names()
+ if first["red"] != "f00" {
+ t.Fatalf("red = %q", first["red"])
+ }
+ first["red"] = "broken"
+ if second := Names(); second["red"] != "f00" {
+ t.Fatalf("mutated red = %q", second["red"])
+ }
+}
+
+func TestOriginalAndFromRatio(t *testing.T) {
+ object := map[string]any{"r": float64(1), "g": float64(0), "b": float64(0), "a": float64(.5)}
+ ratio := ParseFromRatio(object)
+ if !ratio.Valid || ratio.Format != color.FormatPRGB || ratio.A != .5 {
+ t.Fatalf("ratio = %#v", ratio)
+ }
+ want := map[string]any{"r": "100%", "g": "0%", "b": "0%", "a": float64(.5)}
+ if !reflect.DeepEqual(ratio.Original, want) {
+ t.Fatalf("ratio original = %#v, want %#v", ratio.Original, want)
+ }
+ if original := Parse(nil).Original; original != "" {
+ t.Fatalf("null original = %#v", original)
+ }
+}
+
+func TestHSLHSVObjectsAndWrappedHue(t *testing.T) {
+ for _, input := range []any{
+ "hsl(251, 100%, 38%)",
+ "hsva 251.1 .887 .918 .5",
+ map[string]any{"h": float64(251), "s": float64(100), "l": float64(.38)},
+ map[string]any{"h": float64(720), "s": float64(100), "v": float64(100)},
+ } {
+ model := Parse(input)
+ if !model.Valid || (model.Format != color.FormatHSL && model.Format != color.FormatHSV) {
+ t.Errorf("Parse(%#v) = %#v", input, model)
+ }
+ }
+ if model := Parse(map[string]any{"h": "invalid", "s": "invalid", "v": "invalid"}); model.Valid {
+ t.Fatalf("invalid HSV object = %#v", model)
+ }
+}
diff --git a/src/internal/parser/rgb.go b/src/internal/parser/rgb.go
new file mode 100644
index 00000000..eaf6b8db
--- /dev/null
+++ b/src/internal/parser/rgb.go
@@ -0,0 +1,56 @@
+package parser
+
+import (
+ "regexp"
+
+ "github.com/rajeet-04/tinycolor-go/internal/color"
+)
+
+const cssUnit = `(?:[-+]?\d*\.\d+%?)|(?:[-+]?\d+%?)`
+
+var (
+ rgbMatcher = regexp.MustCompile(`(?i)rgb[\s|(]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)\s*\)?`)
+ rgbaMatcher = regexp.MustCompile(`(?i)rgba[\s|(]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)[,|\s]+(` + cssUnit + `)\s*\)?`)
+ cssMatcher = regexp.MustCompile(cssUnit)
+)
+
+func validCSSUnit(value any) bool {
+ switch value := value.(type) {
+ case string:
+ return cssMatcher.FindStringIndex(value) != nil
+ case float64:
+ return true
+ default:
+ return false
+ }
+}
+
+func rgbModel(r, g, b, alpha any, hasAlpha bool, original any, format color.Format) color.Model {
+ model := color.Model{
+ R: color.Bound01(r, 255) * 255,
+ G: color.Bound01(g, 255) * 255,
+ B: color.Bound01(b, 255) * 255,
+ A: color.BoundAlpha(alpha), Valid: true, Format: format, Original: original,
+ }
+ if !hasAlpha {
+ model.A = 1
+ }
+ return model
+}
+
+func parseRGBString(text string, original any) (color.Model, bool) {
+ if match := rgbaMatcher.FindStringSubmatch(text); match != nil {
+ return rgbModel(match[1], match[2], match[3], match[4], true, original, formatForRGB(match[1])), true
+ }
+ if match := rgbMatcher.FindStringSubmatch(text); match != nil {
+ return rgbModel(match[1], match[2], match[3], nil, false, original, formatForRGB(match[1])), true
+ }
+ return color.Model{}, false
+}
+
+func formatForRGB(red any) color.Format {
+ if color.IsPercentage(red) {
+ return color.FormatPRGB
+ }
+ return color.FormatRGB
+}
diff --git a/src/tinycolor/color.go b/src/tinycolor/color.go
new file mode 100644
index 00000000..507f413c
--- /dev/null
+++ b/src/tinycolor/color.go
@@ -0,0 +1,536 @@
+// Package tinycolor exposes the compatibility facade over normalized parsing.
+package tinycolor
+
+import (
+ "fmt"
+ "math"
+ "math/rand/v2"
+ "strconv"
+ "strings"
+
+ "github.com/rajeet-04/tinycolor-go/internal/color"
+ "github.com/rajeet-04/tinycolor-go/internal/parser"
+)
+
+type Color struct {
+ model color.Model
+ gradientType bool
+}
+type CompatOptions struct {
+ Format string
+ GradientType bool
+}
+type RGB struct {
+ R, G, B int
+ A float64
+}
+type HSL struct{ H, S, L, A float64 }
+type HSV struct{ H, S, V, A float64 }
+type WCAG2Options struct {
+ Level string
+ Size string
+ IncludeFallbackColors bool
+}
+
+func FromCompat(input any, fromRatio bool) (Color, error) {
+ var model color.Model
+ if fromRatio {
+ model = parser.ParseFromRatio(input)
+ } else {
+ model = parser.Parse(input)
+ }
+ if model.R < 1 {
+ model.R = math.Floor(model.R + .5)
+ }
+ if model.G < 1 {
+ model.G = math.Floor(model.G + .5)
+ }
+ if model.B < 1 {
+ model.B = math.Floor(model.B + .5)
+ }
+ return Color{model: model}, nil
+}
+func FromCompatWithOptions(input any, fromRatio bool, options CompatOptions) (Color, error) {
+ c, e := FromCompat(input, fromRatio)
+ if options.Format != "" {
+ c.model.Format = color.Format(options.Format)
+ }
+ c.gradientType = options.GradientType
+ return c, e
+}
+
+func (c Color) Valid() bool { return c.model.Valid }
+func (c Color) Format() string { return string(c.model.Format) }
+func (c Color) Alpha() float64 { return c.model.A }
+func (c Color) Original() any { return c.model.Original }
+func (c *Color) SetAlpha(value any) *Color {
+ c.model.A = color.BoundAlpha(value)
+ return c
+}
+func (c Color) RGB() map[string]any {
+ return map[string]any{"r": math.Round(c.model.R), "g": math.Round(c.model.G), "b": math.Round(c.model.B), "a": c.model.A}
+}
+func (c Color) ToRGB() RGB {
+ return RGB{int(math.Round(c.model.R)), int(math.Round(c.model.G)), int(math.Round(c.model.B)), c.model.A}
+}
+func (c Color) ToHSL() HSL {
+ h, s, l := rgbToHSL(c.model.R, c.model.G, c.model.B)
+ return HSL{h * 360, s, l, c.model.A}
+}
+func (c Color) ToHSV() HSV {
+ h, s, v := rgbToHSV(c.model.R, c.model.G, c.model.B)
+ return HSV{h * 360, s, v, c.model.A}
+}
+func (c Color) ToRGBString() string {
+ x := c.ToRGB()
+ if x.A == 1 {
+ return fmt.Sprintf("rgb(%d, %d, %d)", x.R, x.G, x.B)
+ }
+ return fmt.Sprintf("rgba(%d, %d, %d, %s)", x.R, x.G, x.B, roundedAlpha(x.A))
+}
+func (c Color) ToPercentageRGB() RGB {
+ return RGB{percent(c.model.R), percent(c.model.G), percent(c.model.B), c.model.A}
+}
+func (c Color) ToPercentageRGBString() string {
+ x := c.ToPercentageRGB()
+ if x.A == 1 {
+ return fmt.Sprintf("rgb(%d%%, %d%%, %d%%)", x.R, x.G, x.B)
+ }
+ return fmt.Sprintf("rgba(%d%%, %d%%, %d%%, %s)", x.R, x.G, x.B, roundedAlpha(x.A))
+}
+func (c Color) ToHSLString() string {
+ x := c.ToHSL()
+ p := "hsl"
+ if x.A < 1 {
+ p = "hsla"
+ }
+ s := fmt.Sprintf("%s(%d, %d%%, %d%%", p, mathRound(x.H), mathRound(x.S*100), mathRound(x.L*100))
+ if x.A < 1 {
+ s += ", " + roundedAlpha(x.A)
+ }
+ return s + ")"
+}
+func (c Color) ToHSVString() string {
+ x := c.ToHSV()
+ p := "hsv"
+ if x.A < 1 {
+ p = "hsva"
+ }
+ s := fmt.Sprintf("%s(%d, %d%%, %d%%", p, mathRound(x.H), mathRound(x.S*100), mathRound(x.V*100))
+ if x.A < 1 {
+ s += ", " + roundedAlpha(x.A)
+ }
+ return s + ")"
+}
+func (c Color) ToHex() string {
+ return fmt.Sprintf("%02x%02x%02x", mathRound(c.model.R), mathRound(c.model.G), mathRound(c.model.B))
+}
+func (c Color) ToHexString() string { return "#" + c.ToHex() }
+func (c Color) ToHex8() string { return c.ToHex() + fmt.Sprintf("%02x", mathRound(c.model.A*255)) }
+func (c Color) ToHex8String() string { return "#" + c.ToHex8() }
+func (c Color) ToString(format string) string { return c.toString(format, format != "") }
+func (c Color) toString(format string, explicit bool) string {
+ if format == "" {
+ format = string(c.model.Format)
+ }
+ if !explicit && c.model.A < 1 && (format == "hex" || format == "hex6" || format == "hex3" || format == "hex4" || format == "hex8" || format == "name") {
+ if format == "name" && c.model.A == 0 {
+ return "transparent"
+ }
+ return c.ToRGBString()
+ }
+ switch format {
+ case "rgb":
+ return c.ToRGBString()
+ case "prgb":
+ return c.ToPercentageRGBString()
+ case "hsl":
+ return c.ToHSLString()
+ case "hsv":
+ return c.ToHSVString()
+ case "hex", "hex6":
+ return c.ToHexString()
+ case "hex8":
+ return c.ToHex8String()
+ case "name":
+ if n, ok := c.ToName(); ok {
+ return n
+ }
+ return c.ToHexString()
+ }
+ return c.ToHexString()
+}
+func (c Color) ToName() (string, bool) {
+ if c.model.A == 0 {
+ return "transparent", true
+ }
+ if c.model.A < 1 {
+ return "", false
+ }
+ n := parser.NameForRGB(mathRound(c.model.R), mathRound(c.model.G), mathRound(c.model.B))
+ return n, n != ""
+}
+func (c Color) ToFilter(second *Color, gradient bool) string {
+ start := "#" + fmt.Sprintf("%02x", mathRound(c.model.A*255)) + c.ToHex()
+ end := start
+ if second != nil {
+ end = "#" + fmt.Sprintf("%02x", mathRound(second.model.A*255)) + second.ToHex()
+ }
+ prefix := ""
+ if gradient || c.gradientType {
+ prefix = "GradientType = 1, "
+ }
+ return "progid:DXImageTransform.Microsoft.gradient(" + prefix + "startColorstr=" + start + ",endColorstr=" + end + ")"
+}
+func (c Color) Brightness() float64 { x := c.ToRGB(); return float64(x.R*299+x.G*587+x.B*114) / 1000 }
+func (c Color) Luminance() float64 {
+ x := c.ToRGB()
+ return .2126*luminanceChannel(x.R) + .7152*luminanceChannel(x.G) + .0722*luminanceChannel(x.B)
+}
+func Readability(first, second Color) float64 {
+ firstLuminance, secondLuminance := first.Luminance(), second.Luminance()
+ return (math.Max(firstLuminance, secondLuminance) + .05) / (math.Min(firstLuminance, secondLuminance) + .05)
+}
+func IsReadable(first, second Color, options WCAG2Options) bool {
+ return isReadableRatio(Readability(first, second), options)
+}
+func MostReadable(base Color, candidates []Color, options WCAG2Options) (Color, bool) {
+ bestColor, hasBest := Color{}, len(candidates) != 0
+ if hasBest {
+ bestColor = candidates[0]
+ } else {
+ bestColor, _ = FromCompat(nil, false)
+ }
+ bestScore := Readability(base, bestColor)
+ for _, candidate := range candidates {
+ if score := Readability(base, candidate); score > bestScore {
+ bestScore, bestColor = score, candidate
+ }
+ }
+ if IsReadable(base, bestColor, options) || !options.IncludeFallbackColors {
+ return bestColor, hasBest
+ }
+ white, _ := FromCompat("#fff", false)
+ black, _ := FromCompat("#000", false)
+ return MostReadable(base, []Color{white, black}, WCAG2Options{Level: options.Level, Size: options.Size})
+}
+func isReadableRatio(ratio float64, options WCAG2Options) bool {
+ options = normalizeWCAG2Options(options)
+ switch options.Level + options.Size {
+ case "AAsmall", "AAAlarge":
+ return ratio >= 4.5
+ case "AAlarge":
+ return ratio >= 3
+ case "AAAsmall":
+ return ratio >= 7
+ }
+ return false
+}
+func normalizeWCAG2Options(options WCAG2Options) WCAG2Options {
+ options.Level = strings.ToUpper(options.Level)
+ options.Size = strings.ToLower(options.Size)
+ if options.Level != "AA" && options.Level != "AAA" {
+ options.Level = "AA"
+ }
+ if options.Size != "small" && options.Size != "large" {
+ options.Size = "small"
+ }
+ return options
+}
+func (c Color) IsDark() bool { return c.Brightness() < 128 }
+func (c Color) IsLight() bool { return !c.IsDark() }
+func (c Color) Clone() Color { n, _ := FromCompat(c.String(), false); return n }
+func Equals(a, b any) bool {
+ if !jsTruthy(a) || !jsTruthy(b) {
+ return false
+ }
+ x, _ := FromCompat(a, false)
+ y, _ := FromCompat(b, false)
+ return x.ToRGBString() == y.ToRGBString()
+}
+func jsTruthy(value any) bool {
+ switch value := value.(type) {
+ case nil:
+ return false
+ case bool:
+ return value
+ case string:
+ return value != ""
+ default:
+ number := color.ParseFloat(value)
+ return math.IsNaN(number) || number != 0
+ }
+}
+func Random() Color {
+ return Color{model: color.Model{R: rand.Float64() * 255, G: rand.Float64() * 255, B: rand.Float64() * 255, A: 1, Valid: true, Format: color.FormatPRGB}}
+}
+func mathRound(v float64) int {
+ floor := math.Floor(v)
+ if v-floor >= .5 {
+ floor++
+ }
+ return int(floor)
+}
+
+// String supplies the minimal source-compatible string snapshot used by the
+// JSONL oracle. Full public output APIs remain Phase 3 work.
+func (c Color) String() string {
+ return c.toString("", false)
+ /*
+ r, g, b := int(math.Round(c.model.R)), int(math.Round(c.model.G)), int(math.Round(c.model.B))
+ if c.model.Format == color.FormatName {
+ if c.model.A == 0 {
+ return "transparent"
+ }
+ if name := parser.NameForRGB(r, g, b); name != "" {
+ return name
+ }
+ }
+ if c.model.Format == color.FormatPRGB {
+ if c.model.A < 1 {
+ return fmt.Sprintf("rgba(%d%%, %d%%, %d%%, %s)", percent(r), percent(g), percent(b), roundedAlpha(c.model.A))
+ }
+ return fmt.Sprintf("rgb(%d%%, %d%%, %d%%)", percent(r), percent(g), percent(b))
+ }
+ if c.model.Format == color.FormatHSL {
+ h, s, l := rgbToHSL(c.model.R, c.model.G, c.model.B)
+ if c.model.A < 1 {
+ return fmt.Sprintf("hsla(%d, %d%%, %d%%, %s)", int(math.Round(h*360)), int(math.Round(s*100)), int(math.Round(l*100)), roundedAlpha(c.model.A))
+ }
+ return fmt.Sprintf("hsl(%d, %d%%, %d%%)", int(math.Round(h*360)), int(math.Round(s*100)), int(math.Round(l*100)))
+ }
+ if c.model.Format == color.FormatHSV {
+ h, s, v := rgbToHSV(c.model.R, c.model.G, c.model.B)
+ if c.model.A < 1 {
+ return fmt.Sprintf("hsva(%d, %d%%, %d%%, %s)", int(math.Round(h*360)), int(math.Round(s*100)), int(math.Round(v*100)), roundedAlpha(c.model.A))
+ }
+ return fmt.Sprintf("hsv(%d, %d%%, %d%%)", int(math.Round(h*360)), int(math.Round(s*100)), int(math.Round(v*100)))
+ }
+ if c.model.Format == color.FormatRGB || c.model.A < 1 {
+ if c.model.A == 1 {
+ return fmt.Sprintf("rgb(%d, %d, %d)", r, g, b)
+ }
+ return fmt.Sprintf("rgba(%d, %d, %d, %s)", r, g, b, roundedAlpha(c.model.A))
+ }
+ return fmt.Sprintf("#%02x%02x%02x", r, g, b)
+ */
+}
+
+func (c Color) Inspect() map[string]any {
+ format := any(c.Format())
+ if c.Format() == "" {
+ format = false
+ }
+ return map[string]any{"valid": c.Valid(), "format": format, "alpha": c.Alpha(), "rgb": c.RGB(), "value": c.String(), "original": c.Original()}
+}
+
+func percent(channel float64) int { return mathRound(color.Bound01(channel, 255) * 100) }
+func roundedAlpha(alpha float64) string {
+ return strconv.FormatFloat(math.Round(alpha*100)/100, 'f', -1, 64)
+}
+
+func rgbToHSL(r, g, b float64) (float64, float64, float64) {
+ r, g, b = color.Bound01(r, 255), color.Bound01(g, 255), color.Bound01(b, 255)
+ max, min := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
+ l := (max + min) / 2
+ if max == min {
+ return 0, 0, l
+ }
+ d := max - min
+ s := d / (2 - max - min)
+ if l <= 0.5 {
+ s = d / (max + min)
+ }
+ var h float64
+ switch max {
+ case r:
+ h = (g-b)/d + map[bool]float64{true: 6, false: 0}[g < b]
+ case g:
+ h = (b-r)/d + 2
+ default:
+ h = (r-g)/d + 4
+ }
+ return h / 6, s, l
+}
+
+func rgbToHSV(r, g, b float64) (float64, float64, float64) {
+ r, g, b = color.Bound01(r, 255), color.Bound01(g, 255), color.Bound01(b, 255)
+ max, min := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
+ d := max - min
+ if max == 0 {
+ return 0, 0, 0
+ }
+ if d == 0 {
+ return 0, 0, max
+ }
+ var h float64
+ switch max {
+ case r:
+ h = (g-b)/d + map[bool]float64{true: 6, false: 0}[g < b]
+ case g:
+ h = (b-r)/d + 2
+ default:
+ h = (r-g)/d + 4
+ }
+ return h / 6, d / max, max
+}
+
+func (c *Color) Lighten(amount float64) *Color {
+ h, s, l := rgbToHSL(c.model.R, c.model.G, c.model.B)
+ c.setHSL(h, s, clamp01(l+amount/100))
+ return c
+}
+
+func (c *Color) Darken(amount float64) *Color {
+ h, s, l := rgbToHSL(c.model.R, c.model.G, c.model.B)
+ c.setHSL(h, s, clamp01(l-amount/100))
+ return c
+}
+
+func (c *Color) Saturate(amount float64) *Color {
+ h, s, l := rgbToHSL(c.model.R, c.model.G, c.model.B)
+ c.setHSL(h, clamp01(s+amount/100), l)
+ return c
+}
+
+func (c *Color) Desaturate(amount float64) *Color {
+ h, s, l := rgbToHSL(c.model.R, c.model.G, c.model.B)
+ c.setHSL(h, clamp01(s-amount/100), l)
+ return c
+}
+
+func (c *Color) Greyscale() *Color { return c.Desaturate(100) }
+
+func (c *Color) Brighten(amount float64) *Color {
+ delta := float64(-mathRound(-255 * amount / 100))
+ rgb := c.ToRGB()
+ c.model.R = clampChannel(float64(rgb.R) + delta)
+ c.model.G = clampChannel(float64(rgb.G) + delta)
+ c.model.B = clampChannel(float64(rgb.B) + delta)
+ return c
+}
+
+func (c *Color) Spin(amount float64) *Color {
+ if math.IsNaN(amount) {
+ c.model.R, c.model.G, c.model.B = 0, 0, 0
+ return c
+ }
+ h, s, l := rgbToHSL(c.model.R, c.model.G, c.model.B)
+ h = math.Mod(h*360+amount, 360)
+ if h < 0 {
+ h += 360
+ }
+ c.setHSL(h/360, s, l)
+ return c
+}
+
+func Mix(first, second Color, amount float64) Color {
+ p := amount / 100
+ firstRGB, secondRGB := first.ToRGB(), second.ToRGB()
+ raw := map[string]float64{
+ "r": (float64(secondRGB.R)-float64(firstRGB.R))*p + float64(firstRGB.R),
+ "g": (float64(secondRGB.G)-float64(firstRGB.G))*p + float64(firstRGB.G),
+ "b": (float64(secondRGB.B)-float64(firstRGB.B))*p + float64(firstRGB.B),
+ "a": (second.model.A-first.model.A)*p + first.model.A,
+ }
+ return Color{model: color.Model{
+ R: clampChannel(raw["r"]),
+ G: clampChannel(raw["g"]),
+ B: clampChannel(raw["b"]),
+ A: clampAlpha(raw["a"]),
+ Valid: true,
+ Format: color.FormatRGB,
+ Original: raw,
+ }}
+}
+
+func (c Color) Complement() Color {
+ hsl := c.ToHSL()
+ return hslColor(math.Mod(hsl.H+180, 360), hsl.S, hsl.L, hsl.A, true)
+}
+
+func (c Color) SplitComplement() []Color {
+ hsl := c.ToHSL()
+ return []Color{
+ c,
+ hslColor(math.Mod(hsl.H+72, 360), hsl.S, hsl.L, 1, false),
+ hslColor(math.Mod(hsl.H+216, 360), hsl.S, hsl.L, 1, false),
+ }
+}
+
+func (c Color) Triad() []Color { return c.polyad(3) }
+func (c Color) Tetrad() []Color { return c.polyad(4) }
+
+func (c Color) Analogous(results, slices int) []Color {
+ if results <= 0 || slices <= 0 {
+ return []Color{}
+ }
+ hsl := c.ToHSL()
+ part := 360 / float64(slices)
+ palette := []Color{c}
+ hue := math.Mod(hsl.H-float64(int(part*float64(results))>>1)+720, 360)
+ input := map[string]any{"h": hue, "s": hsl.S, "l": hsl.L, "a": hsl.A}
+ for remaining := results - 1; remaining > 0; remaining-- {
+ hue = math.Mod(hue+part, 360)
+ input["h"] = hue
+ color, _ := FromCompat(input, false)
+ palette = append(palette, color)
+ }
+ return palette
+}
+
+func (c Color) Monochromatic(results int) []Color {
+ if results <= 0 {
+ return []Color{}
+ }
+ hsv := c.ToHSV()
+ palette := make([]Color, 0, results)
+ value := hsv.V
+ modification := 1 / float64(results)
+ for remaining := results; remaining > 0; remaining-- {
+ palette = append(palette, hsvColor(hsv.H, hsv.S, value))
+ value = math.Mod(value+modification, 1)
+ }
+ return palette
+}
+
+func (c Color) polyad(number int) []Color {
+ if number <= 0 {
+ return []Color{}
+ }
+ hsl := c.ToHSL()
+ palette := []Color{c}
+ step := 360 / float64(number)
+ for index := 1; index < number; index++ {
+ palette = append(palette, hslColor(math.Mod(hsl.H+float64(index)*step, 360), hsl.S, hsl.L, 1, false))
+ }
+ return palette
+}
+
+func hslColor(hue, saturation, lightness, alpha float64, includeAlpha bool) Color {
+ input := map[string]any{"h": hue, "s": saturation, "l": lightness}
+ if includeAlpha {
+ input["a"] = alpha
+ }
+ color, _ := FromCompat(input, false)
+ return color
+}
+
+func hsvColor(hue, saturation, value float64) Color {
+ color, _ := FromCompat(map[string]any{"h": hue, "s": saturation, "v": value}, false)
+ return color
+}
+
+func (c *Color) setHSL(h, s, l float64) {
+ converted := hslColor(h*360, s, l, c.model.A, true)
+ c.model.R, c.model.G, c.model.B = converted.model.R, converted.model.G, converted.model.B
+}
+
+func clamp01(value float64) float64 { return math.Min(1, math.Max(0, value)) }
+func clampChannel(value float64) float64 { return math.Min(255, math.Max(0, value)) }
+func clampAlpha(value float64) float64 {
+ if value < 0 || value > 1 || math.IsNaN(value) {
+ return 1
+ }
+ return value
+}
diff --git a/src/tinycolor/color_test.go b/src/tinycolor/color_test.go
new file mode 100644
index 00000000..31d4424a
--- /dev/null
+++ b/src/tinycolor/color_test.go
@@ -0,0 +1,139 @@
+package tinycolor
+
+import "testing"
+
+func TestOutputsAndAnalysis(t *testing.T) {
+ c, _ := FromCompat("rgba(255, 0, 0, .5)", false)
+ if c.ToHex8String() != "#ff000080" || c.ToRGBString() != "rgba(255, 0, 0, 0.5)" {
+ t.Fatal(c.ToHex8String(), c.ToRGBString())
+ }
+ if c.ToFilter(nil, false) != "progid:DXImageTransform.Microsoft.gradient(startColorstr=#80ff0000,endColorstr=#80ff0000)" {
+ t.Fatal(c.ToFilter(nil, false))
+ }
+ black, _ := FromCompat("#000", false)
+ white, _ := FromCompat("#fff", false)
+ if black.Brightness() != 0 || white.Luminance() != 1 || !black.IsDark() || !white.IsLight() {
+ t.Fatal("analysis")
+ }
+ if !Equals("#ff000066", "rgba(255, 0, 0, .4)") {
+ t.Fatal("equals")
+ }
+ if random := Random(); !random.Valid() || random.Format() != "prgb" {
+ t.Fatalf("random = valid %t, format %q", random.Valid(), random.Format())
+ }
+}
+
+func TestSetAlphaMatchesTinyColorBounds(t *testing.T) {
+ color, _ := FromCompat("red", false)
+ for _, test := range []struct {
+ value any
+ want float64
+ }{
+ {0.9, 0.9},
+ {-1.0, 1},
+ {2.0, 1},
+ {nil, 1},
+ {"test", 1},
+ } {
+ if returned := color.SetAlpha(test.value); returned != &color || color.Alpha() != test.want {
+ t.Fatalf("SetAlpha(%#v) returned %p, alpha %v", test.value, returned, color.Alpha())
+ }
+ }
+}
+
+func TestStringHexFilterAndConversion(t *testing.T) {
+ c, _ := FromCompat("rgba(255, 0, 0, .5)", false)
+ if c.ToPercentageRGBString() != "rgba(100%, 0%, 0%, 0.5)" || c.ToHSLString() != "hsla(0, 100%, 50%, 0.5)" || c.ToHSVString() != "hsva(0, 100%, 100%, 0.5)" {
+ t.Fatal("strings")
+ }
+ if c.ToString("hex8") != "#ff000080" || c.ToString("name") != "#ff0000" {
+ t.Fatal(c.ToString("hex8"), c.ToString("name"))
+ }
+ with, _ := FromCompatWithOptions("red", false, CompatOptions{GradientType: true})
+ if with.ToFilter(nil, false) != "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#ffff0000,endColorstr=#ffff0000)" {
+ t.Fatal(with.ToFilter(nil, false))
+ }
+}
+
+func TestPercentageRGBUsesUnroundedChannels(t *testing.T) {
+ c, _ := FromCompat(map[string]any{"h": float64(266), "s": float64(255), "v": float64(342), "a": float64(2.63)}, false)
+ if got := c.ToPercentageRGBString(); got != "rgb(43%, 0%, 100%)" {
+ t.Fatalf("ToPercentageRGBString() = %q", got)
+ }
+}
+
+func TestOnePercentRGBMatchesJavaScriptHSLRounding(t *testing.T) {
+ color, _ := FromCompat(map[string]any{"r": "1%", "g": "0%", "b": "0%", "a": float64(1)}, false)
+ if got := color.ToHSLString(); got != "hsl(0, 100%, 0%)" {
+ t.Fatalf("ToHSLString() = %q", got)
+ }
+}
+
+func TestEqualsRejectsFalsyInput(t *testing.T) {
+ if Equals("", map[string]any{"h": float64(529), "s": float64(15), "l": float64(-77), "a": float64(-1.132)}) {
+ t.Fatal("empty input must not equal normalized black")
+ }
+}
+
+func TestImplicitAlphaHexFallsBackToRGBA(t *testing.T) {
+ for _, input := range []string{"#f008", "#ff000080"} {
+ c, _ := FromCompat(input, false)
+ if c.String() != c.ToRGBString() {
+ t.Fatalf("%s: %s", input, c.String())
+ }
+ if c.ToString("hex8") != c.ToHex8String() {
+ t.Fatal("explicit hex8")
+ }
+ }
+}
+
+func TestPhaseOneInputsMatchTinyColor(t *testing.T) {
+ tests := []struct {
+ name string
+ input any
+ ratio bool
+ valid bool
+ format string
+ alpha float64
+ value string
+ }{
+ {"name", "red", false, true, "name", 1, "red"},
+ {"hex", "#000", false, true, "hex", 1, "#000000"},
+ {"invalid", "not a color", false, false, "", 1, "#000000"},
+ {"transparent", "transparent", false, true, "name", 0, "transparent"},
+ {"rgba", "rgba(255, 0, 0, .5)", false, true, "rgb", .5, "rgba(255, 0, 0, 0.5)"},
+ {"hsl", "hsl(0, 100%, 50%)", false, true, "hsl", 1, "hsl(0, 100%, 50%)"},
+ {"hsv", "hsv(0, 100%, 100%)", false, true, "hsv", 1, "hsv(0, 100%, 100%)"},
+ {"rgb object", map[string]any{"r": float64(255), "g": float64(0), "b": float64(0)}, false, true, "rgb", 1, "rgb(255, 0, 0)"},
+ {"from ratio", map[string]any{"r": float64(1), "g": float64(0), "b": float64(0)}, true, true, "prgb", 1, "rgb(100%, 0%, 0%)"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ color, err := FromCompat(tt.input, tt.ratio)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if color.Valid() != tt.valid || color.Format() != tt.format || color.Alpha() != tt.alpha || color.String() != tt.value {
+ t.Fatalf("got valid=%v format=%q alpha=%v string=%q", color.Valid(), color.Format(), color.Alpha(), color.String())
+ }
+ })
+ }
+}
+
+func TestInvalidInputIsAColorState(t *testing.T) {
+ color, err := FromCompat("this is not a color", false)
+ if err != nil || color.Valid() || color.String() != "#000000" {
+ t.Fatalf("invalid input = %#v, %v", color, err)
+ }
+}
+
+func TestCompressibleSixDigitNameFallsBackToHex(t *testing.T) {
+ color, _ := FromCompat("rebeccapurple", false)
+ if name, ok := color.ToName(); ok || name != "" {
+ t.Fatalf("ToName() = %q, %t", name, ok)
+ }
+ if color.String() != "#663399" || color.Clone().String() != "#663399" {
+ t.Fatalf("color = %q, clone = %q", color.String(), color.Clone().String())
+ }
+}
diff --git a/src/tinycolor/luminance.go b/src/tinycolor/luminance.go
new file mode 100644
index 00000000..5dfa5707
--- /dev/null
+++ b/src/tinycolor/luminance.go
@@ -0,0 +1,45 @@
+package tinycolor
+
+import "math"
+
+// TinyColor rounds channels to 8-bit values before applying Math.pow. These
+// V8-derived results preserve exact JavaScript behavior where Go math.Pow
+// rounds differently.
+var luminanceBits = [...]uint64{
+ 0x0000000000000000, 0x3f33e45677c176f7, 0x3f43e45677c176f7, 0x3f4dd681b3a23272, 0x3f53e45677c176f7, 0x3f58dd6c15b1d4b4, 0x3f5dd681b3a23272, 0x3f6167cba8c94818,
+ 0x3f63e45677c176f7, 0x3f6660e146b9a5d5, 0x3f68dd6c15b1d4b4, 0x3f6b6a31b5259c99, 0x3f6e1e31d70c99dd, 0x3f707c38bf8583a9, 0x3f71fcc2beed6421, 0x3f7390ffaf95e279,
+ 0x3f753936cc7bc928, 0x3f76f5addb50c915, 0x3f78c6a94031b561, 0x3f7aac6c0fb97351, 0x3f7ca7381f9f602b, 0x3f7eb74e160978d0, 0x3f806e76bbda92b8, 0x3f818c2a5a8a8044,
+ 0x3f82b4e09b3f0ae3, 0x3f83e8b7b3bde965, 0x3f8527cd60af8b85, 0x3f86723eea8d3709, 0x3f87c8292a3db6b3, 0x3f8929a88d67b521, 0x3f8a96d91a8016bd, 0x3f8c0fd67499fab6,
+ 0x3f8d94bbdefd740e, 0x3f8f25a44089883f, 0x3f9061551372c694, 0x3f9135f3e4c2cce2, 0x3f9210bb8642b172, 0x3f92f1b8c1ae46bd, 0x3f93d8f839b79c0b, 0x3f94c6866b3e9fa4,
+ 0x3f95ba6fae794313, 0x3f96b4c0380d2def, 0x3f97b5841a1bf3ac, 0x3f98bcc74542addb, 0x3f99ca95898dc8b5, 0x3f9adefa9761c020, 0x3f9bfa0200597bd9, 0x3f9d1bb7381aec1f,
+ 0x3f9e442595227bca, 0x3f9f73585185e1b5, 0x3fa054ad45d76878, 0x3fa0f31ba386ff26, 0x3fa194fcb663747b, 0x3fa23a55e62a662a, 0x3fa2e32c8e148d11, 0x3fa38f85fd21eacf,
+ 0x3fa43f67766310ff, 0x3fa4f2d6313fa8d0, 0x3fa5a9d759ba5ed0, 0x3fa6647010b254ee, 0x3fa722a56c2239ee, 0x3fa7e47c775d2427, 0x3fa8a9fa33494b07, 0x3fa973239698b9cc,
+ 0x3faa3ffd8e001389, 0x3fab108cfc6b7fbc, 0x3fabe4d6bb31d522, 0x3facbcdf9a4616f2, 0x3fad98ac60675833, 0x3fae7841cb4f16df, 0x3faf5ba48fde2048, 0x3fb0216cad240765,
+ 0x3fb096f2671eb815, 0x3fb10e65c38a5192, 0x3fb187c90bf8bce2, 0x3fb2031e85f5d6da, 0x3fb28068731a1952, 0x3fb2ffa9111cb94b, 0x3fb380e299e53f92, 0x3fb40417439ca10f,
+ 0x3fb4894940bddbfb, 0x3fb5107ac0261e59, 0x3fb599aded247aac, 0x3fb624e4ef892ed4, 0x3fb6b221ebb4817e, 0x3fb7416702a539d1, 0x3fb7d2b65206b527, 0x3fb86611f43e9e6a,
+ 0x3fb8fb7c007a4a70, 0x3fb992f68abbbc89, 0x3fba2c83a3e6566d, 0x3fbac82559cb3644, 0x3fbb65ddb7354604, 0x3fbc05aec3f4fe5e, 0x3fbca79a84ebe030, 0x3fbd4ba2fc17a6a5,
+ 0x3fbdf1ca289d34b8, 0x3fbe9a1206d34003, 0x3fbf447c904cbb4e, 0x3fbff10bbbe302c2, 0x3fc04fe0bedfe5f1, 0x3fc0a84fe3b36d8f, 0x3fc101d443dfc06f, 0x3fc15c6ed58eefdf,
+ 0x3fc1b8208da5fef0, 0x3fc214ea5fc9514a, 0x3fc272cd3e610123, 0x3fc2d1ca1a9d1cfb, 0x3fc331e1e479cdf5, 0x3fc393158ac3674e, 0x3fc3f565fb1a5fd5, 0x3fc458d421f735df,
+ 0x3fc4bd60eaae3e73, 0x3fc5230d3f736034, 0x3fc589da095dbaa1, 0x3fc5f1c8306b3a3c, 0x3fc65ad89b841a2b, 0x3fc6c50c307e53bf, 0x3fc73063d420fc80, 0x3fc79ce06a279303,
+ 0x3fc80a82d5453b5d, 0x3fc8794bf727eb3f, 0x3fc8e93cb07b8679, 0x3fc95a55e0ecec0b, 0x3fc9cc98672cf47e, 0x3fca400520f3619c, 0x3fcab49ceb01c003, 0x3fcb2a60a1263b0a,
+ 0x3fcba1511e3e632d, 0x3fcc196f3c39e76f, 0x3fcc92bbd41d41fe, 0x3fcd0d37be045851, 0x3fcd88e3d1250f68, 0x3fce05c0e3d1d3e0, 0x3fce83cfcb7c16f0, 0x3fcf03115cb6bfd3,
+ 0x3fcf83866b38924d, 0x3fd00297e4ef4553, 0x3fd044072557177a, 0x3fd086115f6beb3a, 0x3fd0c8b6fb5c735e, 0x3fd10bf860ef039a, 0x3fd14fd5f782a5a6, 0x3fd1945026102997,
+ 0x3fd1d967532b31b1, 0x3fd21f1be50339e7, 0x3fd2656e41649ae3, 0x3fd2ac5ecdb988f8, 0x3fd2f3edef0b0ed8, 0x3fd33c1c0a020438, 0x3fd384e982e800b1, 0x3fd3ce56bda84a81,
+ 0x3fd418641dd0c1bc, 0x3fd463120692c7af, 0x3fd4ae60dac4229d, 0x3fd4fa50fcdfde15, 0x3fd546e2cf0727a9, 0x3fd59416b3022858, 0x3fd5e1ed0a40daab, 0x3fd6306635dbdd7b,
+ 0x3fd67f82969543a2, 0x3fd6cf428cd96079, 0x3fd71fa678bf915d, 0x3fd770aeba0b042a, 0x3fd7c25bb02b7ac5, 0x3fd814adba3e0bd9, 0x3fd867a5370de0b1, 0x3fd8bb428514f067,
+ 0x3fd90f86027cb84e, 0x3fd964700d1ef1b1, 0x3fd9ba0102864521, 0x3fda10393feefafd, 0x3fda67192247a9be, 0x3fdabea10631e195, 0x3fdb16d14802d5ca, 0x3fdb6faa43c403bb,
+ 0x3fdbc92c5533d785, 0x3fdc2357d7c64e5d, 0x3fdc7e2d26a596de, 0x3fdcd9ac9cb2aef2, 0x3fdd35d69485ffc5, 0x3fdd92ab686ff782, 0x3fddf02b7279a10d, 0x3fde4e570c6539c5,
+ 0x3fdead2e8faec526, 0x3fdf0cb2558c9ea4, 0x3fdf6ce2b6f00983, 0x3fdfcdc00c85bec2, 0x3fe017a5575b3cb2, 0x3fe048c17ad3c04b, 0x3fe07a349c9d9837, 0x3fe0abfee888c050,
+ 0x3fe0de208a4444c8, 0x3fe11099ad5e83eb, 0x3fe1436a7d456eef, 0x3fe176932546ca12, 0x3fe1aa13d0906bda, 0x3fe1ddecaa307b85, 0x3fe2121ddd15aece, 0x3fe246a7940f86d1,
+ 0x3fe27b89f9ce8c4b, 0x3fe2b0c538e48b07, 0x3fe2e6597bc4cca0, 0x3fe31c46ecc4528d, 0x3fe3528db61a0f73, 0x3fe3892e01df1fcc, 0x3fe3c027fa0f01eb, 0x3fe3f77bc887cd3b,
+ 0x3fe42f29970a68f8, 0x3fe467318f3ac22d, 0x3fe49f93daa00113, 0x3fe4d850a2a4bde1, 0x3fe51168109734e5, 0x3fe54ada4da97a1b, 0x3fe584a782f1ac23, 0x3fe5becfd96a2698,
+ 0x3fe5f95379f1b3ed, 0x3fe634328d4bbe97, 0x3fe66f6d3c2081cf, 0x3fe6ab03aefd39aa, 0x3fe6e6f60e5452b1, 0x3fe72344827d98f6, 0x3fe75fef33b6669b, 0x3fe79cf64a21d1e2,
+ 0x3fe7da59edc8dab0, 0x3fe8181a469a9787, 0x3fe856377c6c6224, 0x3fe894b1b6fa0377, 0x3fe8d3891de5df49, 0x3fe912bdd8b91f45, 0x3fe952500ee3dda5, 0x3fe9923fe7bd4f67,
+ 0x3fe9d28d8a83edfc, 0x3fea13391e5da09f, 0x3fea5442ca57e52e, 0x3fea95aab567f88f, 0x3fead771066afec2, 0x3feb1995e4262a69, 0x3feb5c197546e3f8, 0x3feb9efbe062f086,
+ 0x3febe23d4bf8981b, 0x3fec25ddde6ecbbb, 0x3fec69ddbe154af1, 0x3fecae3d1124c90b, 0x3fecf2fbfdbf11f1, 0x3fed381aa9ef2e82, 0x3fed7d993ba988d4, 0x3fedc377d8cc0fd5,
+ 0x3fee09b6a71e5aa6, 0x3fee5055cc51cbb4, 0x3fee97556e01b351, 0x3feedeb5b1b37216, 0x3fef2676bcd69ade, 0x3fef6e98b4c51466, 0x3fefb71bbec33ab2, 0x3ff0000000000000,
+}
+
+func luminanceChannel(channel int) float64 {
+ return math.Float64frombits(luminanceBits[channel])
+}
diff --git a/src/tinycolor/operations_test.go b/src/tinycolor/operations_test.go
new file mode 100644
index 00000000..b5436d2c
--- /dev/null
+++ b/src/tinycolor/operations_test.go
@@ -0,0 +1,310 @@
+package tinycolor
+
+import (
+ "math"
+ "strings"
+ "testing"
+)
+
+func TestModifiers(t *testing.T) {
+ red, _ := FromCompat("red", false)
+ if returned := red.Lighten(10); returned != &red {
+ t.Fatal("Lighten must return its receiver")
+ }
+ if got := red.ToHexString(); got != "#ff3333" {
+ t.Fatalf("Lighten(10) = %s", got)
+ }
+
+ for _, modifier := range []struct {
+ name string
+ apply func(*Color) *Color
+ }{
+ {"lighten", func(c *Color) *Color { return c.Lighten(0) }},
+ {"darken", func(c *Color) *Color { return c.Darken(0) }},
+ {"saturate", func(c *Color) *Color { return c.Saturate(0) }},
+ {"desaturate", func(c *Color) *Color { return c.Desaturate(0) }},
+ {"brighten", func(c *Color) *Color { return c.Brighten(0) }},
+ {"spin", func(c *Color) *Color { return c.Spin(0) }},
+ } {
+ t.Run(modifier.name+" zero", func(t *testing.T) {
+ color, _ := FromCompat("#336699", false)
+ if modifier.apply(&color) != &color || color.ToHexString() != "#336699" {
+ t.Fatalf("%s(0) changed %#v", modifier.name, color)
+ }
+ })
+ }
+
+ white, _ := FromCompat("#fff", false)
+ white.Lighten(100)
+ if got := white.ToHexString(); got != "#ffffff" {
+ t.Fatalf("Lighten clamp = %s", got)
+ }
+ black, _ := FromCompat("#000", false)
+ black.Darken(100)
+ if got := black.ToHexString(); got != "#000000" {
+ t.Fatalf("Darken clamp = %s", got)
+ }
+ red, _ = FromCompat("red", false)
+ red.Desaturate(200)
+ if got := red.ToHexString(); got != "#808080" {
+ t.Fatalf("Desaturate clamp = %s", got)
+ }
+
+ black, _ = FromCompat("#000", false)
+ black.Brighten(.2)
+ if got := black.ToHexString(); got != "#010101" {
+ t.Fatalf("Brighten pre-clamp rounding = %s", got)
+ }
+ halfAmount := 50.0 / 255
+ black, _ = FromCompat("#000", false)
+ if got := black.Brighten(halfAmount).ToHexString(); got != "#000000" {
+ t.Fatalf("Brighten positive half tie = %s", got)
+ }
+ one, _ := FromCompat("#010101", false)
+ if got := one.Brighten(-halfAmount).ToHexString(); got != "#000000" {
+ t.Fatalf("Brighten negative half tie = %s", got)
+ }
+
+ red, _ = FromCompat("red", false)
+ red.Spin(-120)
+ if got := red.ToHexString(); got != "#0000ff" {
+ t.Fatalf("Spin(-120) = %s", got)
+ }
+ red, _ = FromCompat("red", false)
+ red.Spin(480)
+ if got := red.ToHexString(); got != "#00ff00" {
+ t.Fatalf("Spin(480) = %s", got)
+ }
+
+ color, _ := FromCompatWithOptions("rgba(255, 0, 0, .4)", false, CompatOptions{GradientType: true})
+ original, format, alpha := color.Original(), color.Format(), color.Alpha()
+ if color.Spin(math.NaN()) != &color {
+ t.Fatal("Spin must return its receiver")
+ }
+ if color.ToHexString() != "#000000" || !color.Valid() || color.Format() != format || color.Original() != original || color.Alpha() != alpha {
+ t.Fatalf("Spin(NaN) did not retain metadata: %#v", color)
+ }
+ if got := color.ToFilter(nil, false); got != "progid:DXImageTransform.Microsoft.gradient(GradientType = 1, startColorstr=#66000000,endColorstr=#66000000)" {
+ t.Fatalf("Spin(NaN) gradient metadata = %s", got)
+ }
+
+ alphaColor, _ := FromCompat("rgba(255, 0, 0, .4)", false)
+ alphaColor.Greyscale()
+ if alphaColor.ToHexString() != "#808080" || alphaColor.Alpha() != .4 {
+ t.Fatalf("Greyscale() = %#v", alphaColor)
+ }
+}
+
+func TestSaturateMatchesObjectConversionRounding(t *testing.T) {
+ color, _ := FromCompat("#400140", false)
+ if got := color.Saturate(-100).ToHexString(); got != "#202020" {
+ t.Fatalf("Saturate(-100) = %s", got)
+ }
+}
+
+func TestBrightenUsesRoundedRGBSnapshot(t *testing.T) {
+ color, _ := FromCompat("rgb(423.5294117647059%, 78.03921568627452%, -0.3921568627450981%)", false)
+ if got := color.Brighten(12.7).ToPercentageRGBString(); got != "rgb(67%, 91%, 13%)" {
+ t.Fatalf("Brighten(12.7) = %s", got)
+ }
+}
+
+func TestMix(t *testing.T) {
+ black, _ := FromCompat("#000", false)
+ white, _ := FromCompat("#fff", false)
+ mixedHalf := Mix(black, white, 50)
+ if got := mixedHalf.ToHexString(); got != "#808080" {
+ t.Fatalf("Mix(50) = %s", got)
+ }
+ if got, ok := mixedHalf.Original().(map[string]float64); !ok || got["r"] != 127.5 || got["g"] != 127.5 || got["b"] != 127.5 || got["a"] != 1 {
+ t.Fatalf("Mix(50) original = %#v", mixedHalf.Original())
+ }
+ if got := Mix(black, white, 0).ToHexString(); got != "#000000" {
+ t.Fatalf("Mix(0) = %s", got)
+ }
+ if got := Mix(white, black, 90).ToHexString(); got != "#1a1a1a" {
+ t.Fatalf("Mix(90) = %s", got)
+ }
+
+ transparent, _ := FromCompat("transparent", false)
+ mixed := Mix(transparent, black, 25)
+ if mixed.ToHexString() != "#000000" || mixed.Alpha() != .25 {
+ t.Fatalf("transparent Mix = %#v", mixed)
+ }
+ if transparent.Alpha() != 0 || black.ToHexString() != "#000000" {
+ t.Fatal("Mix mutated an input")
+ }
+
+ if got := Mix(black, white, 200).ToHexString(); got != "#ffffff" {
+ t.Fatalf("out-of-range Mix = %s", got)
+ }
+}
+
+func TestReadability(t *testing.T) {
+ black, _ := FromCompat("#000", false)
+ white, _ := FromCompat("#fff", false)
+ if got := Readability(black, black); got != 1 {
+ t.Fatalf("same-color readability = %v", got)
+ }
+ if got := Readability(black, white); got != 21 {
+ t.Fatalf("black-on-white readability = %v", got)
+ }
+}
+
+func TestReadabilityMatchesJavaScriptPrecision(t *testing.T) {
+ first, _ := FromCompat(map[string]any{"r": 127.0, "g": 64.0, "b": 15.0, "a": 0.0}, false)
+ second, _ := FromCompat("#80007f", false)
+ if got := Readability(first, second); got != 1.188086751976723 {
+ t.Fatalf("Readability precision = %.17g", got)
+ }
+}
+
+func TestIsReadable(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ ratio float64
+ options WCAG2Options
+ expected bool
+ }{
+ {"AA small threshold", 4.5, WCAG2Options{Level: "AA", Size: "small"}, true},
+ {"AA large threshold", 3, WCAG2Options{Level: "AA", Size: "large"}, true},
+ {"AAA small threshold", 7, WCAG2Options{Level: "AAA", Size: "small"}, true},
+ {"AAA large threshold", 4.5, WCAG2Options{Level: "AAA", Size: "large"}, true},
+ {"below threshold", 4.499999999999999, WCAG2Options{Level: "AA", Size: "small"}, false},
+ {"mixed case", 7, WCAG2Options{Level: "aaa", Size: "LARGE"}, true},
+ {"invalid values default", 4.5, WCAG2Options{Level: "invalid", Size: "invalid"}, true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if got := isReadableRatio(test.ratio, test.options); got != test.expected {
+ t.Fatalf("isReadableRatio(%v, %#v) = %t", test.ratio, test.options, got)
+ }
+ })
+ }
+}
+
+func TestMostReadable(t *testing.T) {
+ base, _ := FromCompat("#000", false)
+ first, _ := FromCompat("white", false)
+ second, _ := FromCompat("#fff", false)
+ if got, ok := MostReadable(base, []Color{first, second}, WCAG2Options{}); !ok || got.Original() != "white" {
+ t.Fatalf("first tied candidate = %#v, %t", got, ok)
+ }
+
+ gray, _ := FromCompat("#777", false)
+ matchingGray, _ := FromCompat("#777", false)
+ if got, ok := MostReadable(gray, []Color{matchingGray}, WCAG2Options{IncludeFallbackColors: true}); !ok || got.ToHexString() != "#000000" {
+ t.Fatalf("fallback candidate = %#v, %t", got, ok)
+ }
+ if got, ok := MostReadable(gray, []Color{matchingGray}, WCAG2Options{}); !ok || got.ToHexString() != "#777777" {
+ t.Fatalf("fallback-disabled candidate = %#v, %t", got, ok)
+ }
+ if _, ok := MostReadable(base, nil, WCAG2Options{}); ok {
+ t.Fatal("empty candidates without fallback must have no result")
+ }
+ whiteBase, _ := FromCompat("#fff", false)
+ if _, ok := MostReadable(whiteBase, nil, WCAG2Options{IncludeFallbackColors: true}); ok {
+ t.Fatal("readable null candidate must remain no result")
+ }
+ darkBase, _ := FromCompat("#123", false)
+ if got, ok := MostReadable(darkBase, nil, WCAG2Options{IncludeFallbackColors: true}); !ok || got.ToHexString() != "#ffffff" {
+ t.Fatalf("empty candidates fallback = %#v, %t", got, ok)
+ }
+}
+
+func TestComplement(t *testing.T) {
+ red, _ := FromCompat("red", false)
+ if got := red.Complement().ToHex(); got != "00ffff" {
+ t.Fatalf("Complement() = %s", got)
+ }
+ if got := red.ToHex(); got != "ff0000" {
+ t.Fatalf("Complement() mutated input to %s", got)
+ }
+ transparent, _ := FromCompat("rgba(255, 0, 0, .5)", false)
+ if got := transparent.Complement().Alpha(); got != .5 {
+ t.Fatalf("Complement alpha = %v", got)
+ }
+}
+
+func TestPaletteOrders(t *testing.T) {
+ red, _ := FromCompat("red", false)
+ for _, test := range []struct {
+ name string
+ palette []Color
+ expected string
+ }{
+ {"split complement", red.SplitComplement(), "ff0000,ccff00,0066ff"},
+ {"triad", red.Triad(), "ff0000,00ff00,0000ff"},
+ {"tetrad", red.Tetrad(), "ff0000,80ff00,00ffff,7f00ff"},
+ {"analogous", red.Analogous(6, 30), "ff0000,ff0066,ff0033,ff0000,ff3300,ff6600"},
+ {"monochromatic", red.Monochromatic(6), "ff0000,2a0000,550000,800000,aa0000,d40000"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if got := paletteHex(test.palette); got != test.expected {
+ t.Fatalf("%s = %s", test.name, got)
+ }
+ })
+ }
+}
+
+func TestPaletteCustomizationsAndIndependence(t *testing.T) {
+ blue, _ := FromCompat("#336699", false)
+ if got := paletteHex(blue.Analogous(4, 12)); got != "336699,339999,336699,333399" {
+ t.Fatalf("custom analogous = %s", got)
+ }
+ wrapped, _ := FromCompat("#ff0066", false)
+ if got := paletteHex(wrapped.SplitComplement()); got != "ff0066,ffcc00,00ccff" {
+ t.Fatalf("wrapped split complement = %s", got)
+ }
+ if got := blue.Analogous(0, 12); len(got) != 0 {
+ t.Fatalf("typed analogous zero length = %d", len(got))
+ }
+ if got := blue.Monochromatic(0); len(got) != 0 {
+ t.Fatalf("typed monochromatic zero length = %d", len(got))
+ }
+
+ transparent, _ := FromCompat("rgba(255, 0, 0, .5)", false)
+ analogous := transparent.Analogous(3, 30)
+ for index, color := range analogous {
+ if color.Alpha() != .5 {
+ t.Fatalf("analogous alpha at %d = %v", index, color.Alpha())
+ }
+ }
+ for index, color := range analogous[1:] {
+ original, ok := color.Original().(map[string]any)
+ if !ok || original["h"] != float64(6) {
+ t.Fatalf("analogous original at %d = %#v", index+1, color.Original())
+ }
+ }
+ triad := transparent.Triad()
+ if triad[0].Alpha() != .5 || triad[1].Alpha() != 1 || triad[2].Alpha() != 1 {
+ t.Fatalf("triad alpha = %#v", triad)
+ }
+ triad[1].Spin(30)
+ if transparent.ToHexString() != "#ff0000" || triad[2].ToHexString() != "#0000ff" {
+ t.Fatal("palette result mutation changed the source or another result")
+ }
+}
+
+func TestPaletteConversionUsesTinyColorChannelBounds(t *testing.T) {
+ color, _ := FromCompat("rgb(127, 0, 255)", false)
+ converted := color.Tetrad()[1]
+ if got := converted.ToHSLString(); got != "hsl(0, 100%, 50%)" {
+ t.Fatalf("tetrad wrapped hue = %s", got)
+ }
+}
+
+func TestPaletteHSLMetadataMatchesJavaScriptPrecision(t *testing.T) {
+ color, _ := FromCompat("#967a", false)
+ original, ok := color.Complement().Original().(map[string]any)
+ if !ok || original["s"] != 0.19999999999999996 {
+ t.Fatalf("complement original = %#v", original)
+ }
+}
+
+func paletteHex(colors []Color) string {
+ values := make([]string, len(colors))
+ for index, color := range colors {
+ values[index] = color.ToHex()
+ }
+ return strings.Join(values, ",")
+}
diff --git a/tests/original-go/facade.test.mjs b/tests/original-go/facade.test.mjs
new file mode 100644
index 00000000..1aafce68
--- /dev/null
+++ b/tests/original-go/facade.test.mjs
@@ -0,0 +1,14 @@
+import assert from "node:assert/strict";
+
+import tinycolor from "./mod.js";
+
+Deno.test("facade serves TinyColor behavior from Go", () => {
+ assert.equal(tinycolor("red").toHexString(), "#ff0000");
+ const color = tinycolor("red");
+ assert.equal(color.lighten(10), color);
+ assert.equal(color.toHexString(), "#ff3333");
+ assert.deepEqual(tinycolor.fromRatio({ r: 1, g: 0, b: 0 }).toRgb(), { r: 255, g: 0, b: 0, a: 1 });
+ assert.equal(tinycolor("hsl 100 20 10").toHslString(), "hsl(100, 20%, 10%)");
+ assert.equal(tinycolor.mix("#000", "#fff").toHsl().l, 0.5);
+ assert.equal(tinycolor("red").analogous().map((entry) => entry.toHex()).join(","), "ff0000,ff0066,ff0033,ff0000,ff3300,ff6600");
+});
diff --git a/tests/original-go/mod.js b/tests/original-go/mod.js
new file mode 100644
index 00000000..e1e14119
--- /dev/null
+++ b/tests/original-go/mod.js
@@ -0,0 +1,144 @@
+const binary = Deno.env.get("TINYCOLOR_GO_BINARY");
+if (!binary) throw new Error("TINYCOLOR_GO_BINARY is required");
+
+const decoder = new TextDecoder();
+let nextID = 0;
+
+function invoke(operation, input, args = {}) {
+ const id = `suite-${nextID++}`;
+ const request = { id, operation, input: unwrap(input), args: unwrap(args) };
+ const output = new Deno.Command(binary, {
+ args: ["bridge", JSON.stringify(request)],
+ stdout: "piped",
+ stderr: "piped",
+ }).outputSync();
+ const stderr = decoder.decode(output.stderr).trim();
+ if (!output.success) throw new Error(stderr || `Go bridge exited ${output.code}`);
+ const response = JSON.parse(decoder.decode(output.stdout));
+ if (response.id !== id) throw new Error(`Go bridge response ID mismatch: ${response.id}`);
+ if (Object.hasOwn(response, "error")) throw new Error(response.error);
+ return response.result;
+}
+
+function unwrap(value) {
+ if (value instanceof TinyColorFacade) return value._input;
+ if (Array.isArray(value)) return value.map(unwrap);
+ if (value && typeof value === "object") {
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, unwrap(entry)]));
+ }
+ return value;
+}
+
+class TinyColorFacade {
+ constructor(input, options = {}, ratio = false) {
+ this._original = input || "";
+ this._options = options;
+ const inspection = invoke(ratio ? "fromRatio" : "inspect", input, ratio ? options : {});
+ this._apply(inspection, inspection.original ?? inspection.rgb);
+ if (options.format) this._format = options.format;
+ }
+
+ static fromInspection(inspection, original = inspection.original, options = {}, input = inspection.original ?? inspection.rgb) {
+ const color = Object.create(TinyColorFacade.prototype);
+ color._original = original;
+ color._options = options;
+ return color._apply(inspection, input);
+ }
+
+ _apply(inspection, input = inspection.rgb) {
+ this._input = input;
+ this._rgba = inspection.rgb;
+ this._format = inspection.format || "";
+ this._valid = inspection.valid;
+ return this;
+ }
+
+ _args(args = {}) {
+ return { ...args, options: { format: this._format, gradientType: !!this._options.gradientType } };
+ }
+
+ _output(method, args = {}) {
+ return invoke("output", this._input, this._args({ method, ...args }));
+ }
+
+ _modify(method, amount) {
+ return this._apply(invoke("modify", this._input, this._args({ method, amount })).after);
+ }
+
+ _palette(method, args = {}) {
+ return invoke("palette", this._input, this._args({ method, ...args }))
+ .map((inspection) => TinyColorFacade.fromInspection(inspection, inspection.original, {}, inspection.rgb));
+ }
+
+ getOriginalInput() { return this._original; }
+ getFormat() { return this._format || false; }
+ getAlpha() { return this._rgba.a; }
+ isValid() { return this._valid; }
+
+ setAlpha(value) {
+ return this._apply(invoke("setAlpha", this._input, this._args({ value })));
+ }
+
+ clone() {
+ return TinyColorFacade.fromInspection(invoke("clone", this._input, this._args()), this.toString());
+ }
+
+ toRgb() { return this._output("toRgb"); }
+ toPercentageRgb() { return this._output("toPercentageRgb"); }
+ toHsl() { return this._output("toHsl"); }
+ toHsv() { return this._output("toHsv"); }
+ toRgbString() { return this._output("toRgbString"); }
+ toPercentageRgbString() { return this._output("toPercentageRgbString"); }
+ toHslString() { return this._output("toHslString"); }
+ toHsvString() { return this._output("toHsvString"); }
+ toHex(compact = false) { return this._output("toHex", { compact }); }
+
+ toHexString(compact = false) {
+ return this._output("toHexString", { compact });
+ }
+
+ toHex8(compact = false) { return this._output("toHex8", { compact }); }
+ toHex8String(compact = false) { return this._output("toHex8String", { compact }); }
+ toName() { return this._output("toName"); }
+ toFilter(secondColor) { return this._output("toFilter", { secondColor }); }
+ toString(format) { return this._output("toString", { format }); }
+
+ getBrightness() { return invoke("analysis", this._input, { method: "brightness" }); }
+ getLuminance() { return invoke("analysis", this._input, { method: "luminance" }); }
+ isDark() { return invoke("analysis", this._input, { method: "isDark" }); }
+ isLight() { return invoke("analysis", this._input, { method: "isLight" }); }
+
+ lighten(amount) { return this._modify("lighten", amount); }
+ brighten(amount) { return this._modify("brighten", amount); }
+ darken(amount) { return this._modify("darken", amount); }
+ saturate(amount) { return this._modify("saturate", amount); }
+ desaturate(amount) { return this._modify("desaturate", amount); }
+ greyscale() { return this._modify("greyscale"); }
+ spin(amount) { return this._modify("spin", amount); }
+
+ complement() { return this._palette("complement")[0]; }
+ analogous(results, slices) { return this._palette("analogous", { results, slices }); }
+ monochromatic(results) { return this._palette("monochromatic", { results }); }
+ splitcomplement() { return this._palette("splitcomplement"); }
+ triad() { return this._palette("triad"); }
+ tetrad() { return this._palette("tetrad"); }
+}
+
+function tinycolor(input, options = {}) {
+ if (input instanceof TinyColorFacade) return input;
+ return new TinyColorFacade(input, options);
+}
+
+tinycolor.fromRatio = (input, options = {}) => new TinyColorFacade(input, options, true);
+tinycolor.random = () => TinyColorFacade.fromInspection(invoke("random"));
+tinycolor.equals = (first, second) => invoke("equals", first, { other: second });
+tinycolor.mix = (first, second, amount) => TinyColorFacade.fromInspection(invoke("mix", first, { other: second, amount }));
+tinycolor.readability = (first, second) => invoke("readability", first, { other: second });
+tinycolor.isReadable = (first, second, options) => invoke("isReadable", first, { other: second, options });
+tinycolor.mostReadable = (base, candidates, options) => {
+ const inspection = invoke("mostReadable", base, { candidates, options });
+ return inspection ? TinyColorFacade.fromInspection(inspection) : null;
+};
+tinycolor.names = invoke("names");
+
+export default tinycolor;
diff --git a/tests/original-go/run.mjs b/tests/original-go/run.mjs
new file mode 100644
index 00000000..93af7cc2
--- /dev/null
+++ b/tests/original-go/run.mjs
@@ -0,0 +1,66 @@
+import { execFileSync, spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+
+import { verifyManifest } from "../original/verify.mjs";
+
+const root = resolve(import.meta.dirname, "../..");
+
+export function prepareOverlay() {
+ const failures = verifyManifest(resolve(root, "tests/original/manifest.sha256"), root);
+ if (failures.length) throw new Error(failures.join("\n"));
+ const directory = mkdtempSync(resolve(tmpdir(), "tinycolor-original-go-"));
+ const testFile = resolve(directory, "test.js");
+ copyFileSync(resolve(root, "test.js"), testFile);
+ copyFileSync(resolve(import.meta.dirname, "mod.js"), resolve(directory, "mod.js"));
+ if (!readFileSync(testFile).equals(readFileSync(resolve(root, "test.js")))) {
+ rmSync(directory, { recursive: true, force: true });
+ throw new Error("copied test.js differs from kickoff test.js");
+ }
+ return {
+ directory,
+ testFile,
+ cleanup: () => rmSync(directory, { recursive: true, force: true }),
+ };
+}
+
+function buildBinary() {
+ mkdirSync(resolve(root, "bin"), { recursive: true });
+ const suffix = execFileSync("go", ["env", "GOEXE"], { cwd: root, encoding: "utf8" }).trim();
+ const binary = resolve(root, "bin", `tinycolor${suffix}`);
+ execFileSync("go", ["-C", "src", "build", "-o", `../bin/tinycolor${suffix}`, "./cmd/tinycolor-compat"], {
+ cwd: root,
+ env: { ...process.env, GOCACHE: process.env.GOCACHE ?? resolve(root, ".cache/go-build") },
+ stdio: "inherit",
+ });
+ return binary;
+}
+
+function main() {
+ const binary = buildBinary();
+ const overlay = prepareOverlay();
+ try {
+ const result = spawnSync("deno", [
+ "test",
+ `--allow-env=TINYCOLOR_GO_BINARY`,
+ `--allow-run=${binary}`,
+ overlay.testFile,
+ ], {
+ cwd: root,
+ env: {
+ ...process.env,
+ DENO_DIR: process.env.DENO_DIR ?? resolve(root, ".cache/deno"),
+ TINYCOLOR_GO_BINARY: binary,
+ },
+ stdio: "inherit",
+ });
+ if (result.error) throw result.error;
+ process.exitCode = result.status ?? 1;
+ } finally {
+ overlay.cleanup();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main();
diff --git a/tests/original-go/run.test.mjs b/tests/original-go/run.test.mjs
new file mode 100644
index 00000000..7e323c7f
--- /dev/null
+++ b/tests/original-go/run.test.mjs
@@ -0,0 +1,18 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import { prepareOverlay } from "./run.mjs";
+
+const digest = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
+
+test("overlay preserves the original test bytes", () => {
+ const overlay = prepareOverlay();
+ try {
+ assert.equal(digest(overlay.testFile), digest("test.js"));
+ assert.equal(readFileSync(overlay.testFile).equals(readFileSync("test.js")), true);
+ } finally {
+ overlay.cleanup();
+ }
+});
diff --git a/tests/original/README.md b/tests/original/README.md
new file mode 100644
index 00000000..4465405d
--- /dev/null
+++ b/tests/original/README.md
@@ -0,0 +1,5 @@
+# Pinned JavaScript oracle
+
+The canonical, unmodified upstream files remain at the repository root so the
+Node adapter can import them directly. `manifest.sha256` records their kickoff
+hashes from commit `b49018c9f2dbca313d80d7a4dad25e26143cfe01`.
diff --git a/tests/original/manifest.sha256 b/tests/original/manifest.sha256
new file mode 100644
index 00000000..0297cdc9
--- /dev/null
+++ b/tests/original/manifest.sha256
@@ -0,0 +1,3 @@
+a4c3efa67a123efbb9e8082a6c1296459cd9151c0cdd00ad699b1a43fe02fb77 mod.js
+cdddded304f3f2910d43e12538246e79931b7e1638a186a632920b307c67dfb3 test.js
+239e168433c66d15388d18034944203380c93171f44b029ce33456116c855314 tinycolor.js
diff --git a/tests/original/verify.mjs b/tests/original/verify.mjs
new file mode 100644
index 00000000..a9bb8c3a
--- /dev/null
+++ b/tests/original/verify.mjs
@@ -0,0 +1,32 @@
+import { createHash } from "node:crypto";
+import { readFileSync } from "node:fs";
+import { resolve, sep } from "node:path";
+
+const digest = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
+
+export function verifyManifest(manifestPath, root) {
+ const resolvedRoot = resolve(root);
+ return readFileSync(manifestPath, "utf8").split(/\r?\n/).filter(Boolean).flatMap((line) => {
+ const match = line.match(/^(\S+)\s{2}(.+)$/);
+ if (!match) return [`invalid: ${line}`];
+ const [, expected, file] = match;
+ const path = resolve(resolvedRoot, file);
+ if (path !== resolvedRoot && !path.startsWith(resolvedRoot + sep)) return [`invalid: ${file}`];
+ try {
+ return digest(path) === expected ? [] : [`mismatch: ${file}`];
+ } catch {
+ return [`missing: ${file}`];
+ }
+ });
+}
+
+if (import.meta.main) {
+ const manifest = resolve("tests/original/manifest.sha256");
+ const failures = verifyManifest(manifest, process.cwd());
+ if (failures.length) {
+ console.error(failures.join("\n"));
+ process.exitCode = 1;
+ } else {
+ console.log(`verified: ${readFileSync(manifest, "utf8").split(/\r?\n/).filter(Boolean).length}`);
+ }
+}
diff --git a/tests/original/verify.test.mjs b/tests/original/verify.test.mjs
new file mode 100644
index 00000000..3816faf7
--- /dev/null
+++ b/tests/original/verify.test.mjs
@@ -0,0 +1,67 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { verifyManifest } from "./verify.mjs";
+
+const sha256 = (value) => createHash("sha256").update(value).digest("hex");
+
+test("oracle files are checked out with CRLF line endings", () => {
+ const files = ["mod.js", "test.js", "tinycolor.js"];
+ const attributes = execFileSync("git", ["check-attr", "eol", "--", ...files], {
+ encoding: "utf8",
+ });
+
+ assert.deepEqual(attributes.trim().split(/\r?\n/), files.map((file) => `${file}: eol: crlf`));
+});
+
+test("verifyManifest accepts matching files and reports a changed path", () => {
+ const root = mkdtempSync(join(tmpdir(), "tinycolor-verify-"));
+ try {
+ writeFileSync(join(root, "first.txt"), "first");
+ writeFileSync(join(root, "second.txt"), "second");
+ const manifest = join(root, "manifest.sha256");
+ writeFileSync(manifest, `${sha256("first")} first.txt\n${sha256("second")} second.txt\n`);
+
+ assert.deepEqual(verifyManifest(manifest, root), []);
+
+ writeFileSync(join(root, "second.txt"), "changed");
+ assert.deepEqual(verifyManifest(manifest, root), ["mismatch: second.txt"]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("verifyManifest reports a malformed manifest line", () => {
+ const root = mkdtempSync(join(tmpdir(), "tinycolor-verify-"));
+ try {
+ const manifest = join(root, "manifest.sha256");
+ writeFileSync(manifest, "not a manifest entry\n");
+
+ assert.deepEqual(verifyManifest(manifest, root), ["invalid: not a manifest entry"]);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("verifyManifest rejects entries outside the manifest root", () => {
+ const parent = mkdtempSync(join(tmpdir(), "tinycolor-verify-"));
+ const root = join(parent, "root");
+ const outside = join(parent, "outside.txt");
+ try {
+ mkdirSync(root);
+ writeFileSync(outside, "outside");
+ const manifest = join(root, "manifest.sha256");
+ writeFileSync(manifest, `${sha256("outside")} ${outside}\n${sha256("outside")} ../outside.txt\n`);
+
+ assert.deepEqual(verifyManifest(manifest, root), [
+ `invalid: ${outside}`,
+ "invalid: ../outside.txt",
+ ]);
+ } finally {
+ rmSync(parent, { recursive: true, force: true });
+ }
+});
diff --git a/tests/port/README.md b/tests/port/README.md
new file mode 100644
index 00000000..eb54381d
--- /dev/null
+++ b/tests/port/README.md
@@ -0,0 +1,4 @@
+# Port-owned tests
+
+`adapter.test.mjs` checks the JSON-lines contract used for JavaScript-versus-Go
+comparisons. Go unit tests are kept beside the code in `src/`.
diff --git a/tests/port/adapter.test.mjs b/tests/port/adapter.test.mjs
new file mode 100644
index 00000000..86fa42cf
--- /dev/null
+++ b/tests/port/adapter.test.mjs
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+
+const run = (input) => {
+ const result = spawnSync(process.execPath, ["compat/js-runner.mjs"], {
+ cwd: process.cwd(),
+ input,
+ encoding: "utf8",
+ });
+ return result.stdout.trim().split("\n").filter(Boolean).map(JSON.parse);
+};
+
+const [success] = run('{"id":"red","operation":"inspect","input":"red"}\n');
+assert.deepEqual(success, {
+ id: "red",
+ result: {
+ valid: true,
+ format: "name",
+ alpha: 1,
+ rgb: { r: 255, g: 0, b: 0, a: 1 },
+ value: "red",
+ original: "red",
+ },
+});
+
+const [malformed, unknown] = run('{bad json\n{"id":"unknown","operation":"unknown"}\n');
+for (const response of [malformed, unknown]) {
+ assert.notEqual(Object.hasOwn(response, "result"), Object.hasOwn(response, "error"));
+ assert.equal(typeof response.error, "string");
+}
+
+const [lightenDefault, lightenZero, spinOmitted, spinNull, mixed, badModifier] = run([
+ '{"id":"lighten-default","operation":"modify","input":"red","args":{"method":"lighten"}}',
+ '{"id":"lighten-zero","operation":"modify","input":"red","args":{"method":"lighten","amount":0}}',
+ '{"id":"spin-omitted","operation":"modify","input":"red","args":{"method":"spin"}}',
+ '{"id":"spin-null","operation":"modify","input":"red","args":{"method":"spin","amount":null}}',
+ '{"id":"mix-default","operation":"mix","input":"red","args":{"other":"#000"}}',
+ '{"id":"bad-modifier","operation":"modify","input":"red","args":{"method":"unknown"}}',
+].join("\n"));
+
+assert.deepEqual(lightenDefault, {
+ id: "lighten-default",
+ result: {
+ before: success.result,
+ after: {
+ ...success.result,
+ rgb: { r: 255, g: 51, b: 51, a: 1 },
+ value: "#ff3333",
+ },
+ sameReceiver: true,
+ },
+});
+assert.deepEqual(lightenZero.result, {
+ before: success.result,
+ after: success.result,
+ sameReceiver: true,
+});
+assert.equal(spinOmitted.result.after.value, "black");
+assert.equal(spinOmitted.result.after.original, "red");
+assert.deepEqual(spinNull.result, lightenZero.result);
+assert.deepEqual(mixed, {
+ id: "mix-default",
+ result: {
+ valid: true,
+ format: "rgb",
+ alpha: 1,
+ rgb: { r: 128, g: 0, b: 0, a: 1 },
+ value: "rgb(128, 0, 0)",
+ original: { r: 127.5, g: 0, b: 0, a: 1 },
+ },
+});
+assert.deepEqual(badModifier, { id: "bad-modifier", error: "unsupported method" });
+
+const [readability, readableDefault, readableMixedCase, readableInvalid, fallback, fallbackDisabled, emptyCandidates] = run([
+ '{"id":"readability","operation":"readability","input":"#000","args":{"other":"#fff"}}',
+ '{"id":"readable-default","operation":"isReadable","input":"#777","args":{"other":"#000","options":{}}}',
+ '{"id":"readable-mixed","operation":"isReadable","input":"#000","args":{"other":"#fff","options":{"level":"aaa","size":"LARGE"}}}',
+ '{"id":"readable-invalid","operation":"isReadable","input":"#777","args":{"other":"#000","options":{"level":false,"size":0}}}',
+ '{"id":"fallback","operation":"mostReadable","input":"#777","args":{"candidates":["#777"],"options":{"includeFallbackColors":true}}}',
+ '{"id":"fallback-disabled","operation":"mostReadable","input":"#777","args":{"candidates":["#777"],"options":{"includeFallbackColors":false}}}',
+ '{"id":"empty-candidates","operation":"mostReadable","input":"#fff","args":{"candidates":[],"options":{"includeFallbackColors":true}}}',
+].join("\n"));
+
+assert.equal(readability.result, 21);
+assert.equal(readableDefault.result, true);
+assert.equal(readableMixedCase.result, true);
+assert.equal(readableInvalid.result, true);
+assert.equal(fallback.result.value, "#000000");
+assert.equal(fallbackDisabled.result.value, "#777777");
+assert.equal(emptyCandidates.result, null);
+
+const [complement, splitComplement, triad, tetrad, analogous, analogousZero, monochromatic, monochromaticZero, badPalette] = run([
+ '{"id":"complement","operation":"palette","input":"red","args":{"method":"complement"}}',
+ '{"id":"split-complement","operation":"palette","input":"red","args":{"method":"splitcomplement"}}',
+ '{"id":"triad","operation":"palette","input":"red","args":{"method":"triad"}}',
+ '{"id":"tetrad","operation":"palette","input":"red","args":{"method":"tetrad"}}',
+ '{"id":"analogous","operation":"palette","input":"red","args":{"method":"analogous"}}',
+ '{"id":"analogous-zero","operation":"palette","input":"red","args":{"method":"analogous","results":0,"slices":0}}',
+ '{"id":"monochromatic","operation":"palette","input":"red","args":{"method":"monochromatic"}}',
+ '{"id":"monochromatic-zero","operation":"palette","input":"red","args":{"method":"monochromatic","results":0}}',
+ '{"id":"bad-palette","operation":"palette","input":"red","args":{"method":"unknown"}}',
+].join("\n"));
+
+const paletteValues = (response) => response.result.map((color) => color.value);
+assert.deepEqual(paletteValues(complement), ["hsl(180, 100%, 50%)"]);
+assert.deepEqual(paletteValues(splitComplement), ["red", "hsl(72, 100%, 50%)", "hsl(216, 100%, 50%)"]);
+assert.deepEqual(paletteValues(triad), ["red", "hsl(120, 100%, 50%)", "hsl(240, 100%, 50%)"]);
+assert.deepEqual(paletteValues(tetrad), ["red", "hsl(90, 100%, 50%)", "hsl(180, 100%, 50%)", "hsl(270, 100%, 50%)"]);
+assert.deepEqual(paletteValues(analogous), ["red", "hsl(336, 100%, 50%)", "hsl(348, 100%, 50%)", "hsl(0, 100%, 50%)", "hsl(12, 100%, 50%)", "hsl(24, 100%, 50%)"]);
+assert.deepEqual(paletteValues(analogousZero), paletteValues(analogous));
+assert.deepEqual(paletteValues(monochromatic), ["hsv(0, 100%, 100%)", "hsv(0, 100%, 17%)", "hsv(0, 100%, 33%)", "hsv(0, 100%, 50%)", "hsv(0, 100%, 67%)", "hsv(0, 100%, 83%)"]);
+assert.deepEqual(paletteValues(monochromaticZero), paletteValues(monochromatic));
+assert.deepEqual(badPalette, { id: "bad-palette", error: "unsupported method" });