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. + + + +After completion, create `.planning/phases/03-conversion-and-representation/03-01-SUMMARY.md`. + 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. + + + +After completion, create `.planning/phases/03-conversion-and-representation/03-02-SUMMARY.md`. + 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. + + + +After completion, create `.planning/phases/04-operations-and-palettes/04-01-SUMMARY.md`. + \ 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. + +After completion, create `.planning/phases/04-operations-and-palettes/04-02-SUMMARY.md`. \ 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. +After completion, create `.planning/phases/04-operations-and-palettes/04-03-SUMMARY.md`. \ 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. +After completion, create `.planning/phases/04-operations-and-palettes/04-04-SUMMARY.md`. \ 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. +After completion, create `.planning/phases/04-operations-and-palettes/04-05-SUMMARY.md`. \ 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. +After completion, create `.planning/phases/04-operations-and-palettes/04-06-SUMMARY.md`. \ 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. +After completion, create `.planning/phases/05-delivery-evidence/05-01-SUMMARY.md`. 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. +After completion, create `.planning/phases/05-delivery-evidence/05-02-SUMMARY.md`. 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. +Create `05-03-SUMMARY.md` and `05-VERIFICATION.md`, then mark the phase complete only when their evidence is green. 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 +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](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" });