Loading files
"use client";
// Color ramp for the focused code layer — chained to the code, so every code
// layer keeps its own ramp. New in the layered lab: per-stop ALPHA, drawn over
// a checkerboard, which is what lets a value field fade out and reveal the
// layers underneath.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
RAMP_MODES,
buildRampLUTRGBA,
oklabToSrgb,
sampleRampRGBA,
srgbToOklab,
type RampMode,
} from "@/lib/pattern/harness";
import { codeUsesValueField } from "@/lib/pattern/ramp";
import { rampStateToHarness } from "@/lib/lab/engine";
import { DEFAULT_RAMP_STATE, type CodeLayer } from "@/lib/lab/types";
import { useFocusCodeLayer, useLabStore } from "@/lib/lab/store";
import { rgbToHex } from "@/lib/pattern/color";
import { randomRampColors } from "@/lib/lab/rampPalettes";
import { useRampHistory } from "./ramp/useRampHistory";
import local from "./RampPanel.module.css";
import dock from "../LabPanels.module.css";
/** "hsvShort" → "hsv short" — camelCase mode ids read as plain words. */
function modeLabel(mode: string): string {
return mode.replace(/([A-Z])/g, " $1").toLowerCase();
}
type LumaTrend = "up" | "down" | "flat" | "mixed";
const LUMA_TREND_LABEL: Record<LumaTrend, string> = {
up: "monotonic ↑",
down: "monotonic ↓",
flat: "flat",
mixed: "non-monotonic",
};
export default function RampPanel() {
const layer = useFocusCodeLayer();
const addCodeLayer = useLabStore((state) => state.addCodeLayer);
if (!layer) {
return (
<div className={dock.panel}>
<div className={dock.panelHint}>
The color ramp chains to a code layer — none is in the stack yet.
<button type="button" onClick={addCodeLayer}>+ Add code layer</button>
</div>
</div>
);
}
// Remounting on focus changes isolates both an in-flight drag and history.
return <RampEditor key={layer.id} layer={layer} />;
}
function RampEditor({ layer }: { layer: CodeLayer }) {
const setLayerRampMode = useLabStore((state) => state.setLayerRampMode);
const setLayerRampWrap = useLabStore((state) => state.setLayerRampWrap);
const setLayerRecolor = useLabStore((state) => state.setLayerRecolor);
const updateLayerRampStop = useLabStore((state) => state.updateLayerRampStop);
const addLayerRampStop = useLabStore((state) => state.addLayerRampStop);
const deleteLayerRampStop = useLabStore((state) => state.deleteLayerRampStop);
const setLayerRampStops = useLabStore((state) => state.setLayerRampStops);
const selectedStopIndex = useLabStore((state) => state.rampSelection[layer.id] ?? 0);
const setRampSelection = useLabStore((state) => state.setRampSelection);
const { edit, beginGesture, endGesture, undo, redo, canUndo, canRedo } = useRampHistory(layer.id);
const [keepStops, setKeepStops] = useState(false);
const rampBarRef = useRef<HTMLCanvasElement | null>(null);
const lumaBarRef = useRef<HTMLCanvasElement | null>(null);
const rampTrackRef = useRef<HTMLDivElement | null>(null);
const stopDragRef = useRef<{ index: number } | null>(null);
const layerId = layer.id;
const ramp = layer.ramp;
// LUT + perceptual-lightness read of the ramp, shared by both preview strips.
// OKLab L of the RGB only — alpha is layering, not color — with the trend
// (the "squint test": does the palette keep its structure in grayscale?).
const rampAnalysis = useMemo(() => {
if (!ramp) return null;
const lut = buildRampLUTRGBA(rampStateToHarness(ramp));
const luma = new Float32Array(256);
for (let i = 0; i < 256; i++) {
luma[i] = srgbToOklab(lut[i * 4], lut[i * 4 + 1], lut[i * 4 + 2])[0];
}
// Cumulative drawup/drawdown, not per-step deltas: a gentle full-range ramp
// moves ~0.002 L per LUT step, far below any noise floor a step test could
// use, while byte rounding wiggles adjacent entries. Track how far L climbs
// above its running minimum and falls below its running maximum instead.
const EPS = 0.02;
let runningMin = luma[0];
let runningMax = luma[0];
let drawup = 0;
let drawdown = 0;
for (let i = 1; i < 256; i++) {
runningMin = Math.min(runningMin, luma[i]);
runningMax = Math.max(runningMax, luma[i]);
drawup = Math.max(drawup, luma[i] - runningMin);
drawdown = Math.max(drawdown, runningMax - luma[i]);
}
const trend: LumaTrend =
drawup > EPS && drawdown > EPS
? "mixed"
: drawup > EPS
? "up"
: drawdown > EPS
? "down"
: "flat";
return { lut, luma, trend };
}, [ramp]);
// Paint the gradient bar: checkerboard first, then the RGBA LUT over it, so
// transparent stops are visibly transparent.
useEffect(() => {
const canvas = rampBarRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context || !rampAnalysis) return;
const lut = rampAnalysis.lut;
const width = 256;
const height = 14;
context.clearRect(0, 0, width, height);
const cell = 4;
for (let y = 0; y < height; y += cell) {
for (let x = 0; x < width; x += cell) {
const even = ((x / cell) | 0) % 2 === ((y / cell) | 0) % 2;
context.fillStyle = even ? "#c9c2b1" : "#b5ad9a";
context.fillRect(x, y, cell, cell);
}
}
const imageData = context.getImageData(0, 0, width, height);
const data = imageData.data;
for (let x = 0; x < width; x++) {
const li = x * 4;
const a = lut[li + 3] / 255;
for (let y = 0; y < height; y++) {
const index = (y * width + x) * 4;
data[index] = lut[li] * a + data[index] * (1 - a);
data[index + 1] = lut[li + 1] * a + data[index + 1] * (1 - a);
data[index + 2] = lut[li + 2] * a + data[index + 2] * (1 - a);
data[index + 3] = 255;
}
}
context.putImageData(imageData, 0, 0);
// Luminance strip: each column is the perceptual gray of that LUT entry.
const lumaCanvas = lumaBarRef.current;
const lumaContext = lumaCanvas?.getContext("2d");
if (!lumaCanvas || !lumaContext) return;
const lumaHeight = 8;
const lumaImage = lumaContext.createImageData(width, lumaHeight);
const lumaData = lumaImage.data;
for (let x = 0; x < width; x++) {
const gray = Math.round(oklabToSrgb(rampAnalysis.luma[x], 0, 0)[0]);
for (let y = 0; y < lumaHeight; y++) {
const index = (y * width + x) * 4;
lumaData[index] = gray;
lumaData[index + 1] = gray;
lumaData[index + 2] = gray;
lumaData[index + 3] = 255;
}
}
lumaContext.putImageData(lumaImage, 0, 0);
}, [rampAnalysis]);
const rampPositionFromClientX = useCallback((clientX: number) => {
const track = rampTrackRef.current;
if (!track) return 0;
const rect = track.getBoundingClientRect();
if (rect.width <= 0) return 0;
const raw = (clientX - rect.left) / rect.width;
return Math.round(Math.max(0, Math.min(1, raw)) * 1000) / 1000;
}, []);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const drag = stopDragRef.current;
if (!drag) return;
event.preventDefault();
edit(() => updateLayerRampStop(layerId, drag.index, { position: rampPositionFromClientX(event.clientX) }));
};
const endDrag = () => {
stopDragRef.current = null;
endGesture();
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", endDrag);
window.addEventListener("pointercancel", endDrag);
window.addEventListener("blur", endDrag);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", endDrag);
window.removeEventListener("pointercancel", endDrag);
window.removeEventListener("blur", endDrag);
};
}, [edit, endGesture, layerId, rampPositionFromClientX, updateLayerRampStop]);
const activeStopIndex = Math.min(selectedStopIndex, ramp.stops.length - 1);
const activeStop = ramp.stops[activeStopIndex];
const usesValueField = codeUsesValueField(layer.code);
const addStopAt = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
if (ramp.stops.length >= 64) return;
event.preventDefault();
const position = rampPositionFromClientX(event.clientX);
// New stop inherits the ramp's current color (and alpha) at that spot, so
// adding a stop never visibly changes the gradient.
const [r, g, b, a] = sampleRampRGBA(rampStateToHarness(ramp), position);
const newIndex = ramp.stops.length;
beginGesture();
edit(() => addLayerRampStop(layer.id, { position, color: rgbToHex(r, g, b), alpha: a }));
setRampSelection(layer.id, newIndex);
stopDragRef.current = { index: newIndex };
};
const startStopDrag = (event: React.PointerEvent<HTMLButtonElement>, index: number) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
beginGesture();
setRampSelection(layer.id, index);
stopDragRef.current = { index };
};
const deleteStop = (index: number) => {
edit(() => deleteLayerRampStop(layer.id, index));
setRampSelection(layer.id, index === activeStopIndex ? 0 : Math.max(0, activeStopIndex - 1));
};
return (
<div className={dock.panel}>
<div className={dock.panelBar}>
<label title="Ramp is chained to this code layer">{layer.name}</label>
<span style={{ flex: 1 }} />
<select
value={ramp.mode}
aria-label="Ramp interpolation mode"
title="How colors blend between stops"
onChange={(event) => edit(() => setLayerRampMode(layer.id, event.target.value as RampMode))}
>
{RAMP_MODES.map((mode) => (
<option key={mode} value={mode}>
{modeLabel(mode)}
</option>
))}
</select>
<label
className={local.rampToggle}
title="Cyclic ramp — blends past the last stop back into the first"
>
<input
type="checkbox"
checked={ramp.wrap}
onChange={(event) => edit(() => setLayerRampWrap(layer.id, event.target.checked))}
/>
wrap
</label>
<label
className={local.rampToggle}
title="Recolor this layer: map each drawn pixel's luminance through the ramp (works on RGB patterns too)"
>
<input
type="checkbox"
checked={layer.recolor}
onChange={(event) => edit(() => setLayerRecolor(layer.id, event.target.checked))}
/>
recolor
</label>
</div>
<div className={`${dock.panelBody} ${dock.rampDock}`}>
<div className={local.paletteActions}>
<button
type="button"
className={local.paletteRandom}
onClick={() => edit(() => {
setLayerRampStops(layer.id, randomRampColors(ramp, keepStops));
if (!keepStops) setRampSelection(layer.id, 0);
})}
title={keepStops ? "Randomize each stop’s hue, saturation and brightness" : "Generate 2–12 stops with new positions and independent random colors"}
>
{keepStops ? "Random colors" : "Random ramp"}
</button>
<label className={local.rampToggle} title="Keep the current stop count, positions and opacity; randomize only the colors">
<input type="checkbox" checked={keepStops} onChange={(event) => setKeepStops(event.target.checked)} />
keep stops
</label>
<div className={local.paletteHistory}>
<button type="button" onClick={undo} disabled={!canUndo} aria-label="Undo ramp change" title="Undo this layer's ramp edit (while this editor stays open)">Undo</button>
<button type="button" onClick={redo} disabled={!canRedo} aria-label="Redo ramp change" title="Redo this layer's ramp edit">Redo</button>
</div>
</div>
<div
ref={rampTrackRef}
className={local.rampTrack}
title="Click to add a stop · drag a line to move it"
onPointerDown={addStopAt}
>
<canvas
ref={rampBarRef}
className={local.rampBar}
width={256}
height={14}
aria-label="Ramp gradient preview"
/>
{ramp.stops.map((stop, index) => (
<button
key={index}
type="button"
className={`${local.rampHandle}${index === activeStopIndex ? ` ${local.rampHandleActive}` : ""}`}
style={{ left: `${stop.position * 100}%` }}
aria-label={`Ramp stop at ${stop.position.toFixed(2)}`}
title={`${stop.color} @ ${stop.position.toFixed(2)}${stop.alpha < 1 ? ` · ${Math.round(stop.alpha * 100)}% alpha` : ""} — drag to move, Delete to remove`}
onPointerDown={(event) => startStopDrag(event, index)}
onKeyDown={(event) => {
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
deleteStop(index);
}
}}
>
<span style={{ background: stop.color, opacity: 0.35 + stop.alpha * 0.65 }} />
</button>
))}
</div>
<canvas
ref={lumaBarRef}
className={local.rampLuma}
width={256}
height={8}
aria-label="Ramp luminance preview"
title="Perceptual lightness (OKLab L) of the ramp — the squint test: structure should survive in grayscale"
/>
{rampAnalysis && (
<div className={local.rampLumaRow}>
<span>luminance</span>
<span
className={`${local.rampLumaBadge}${rampAnalysis.trend === "mixed" && !ramp.wrap ? ` ${local.rampLumaMixed}` : ""}`}
title="Lightness trend across the ramp. Monotonic reads as ordered values on the panel; non-monotonic is a stylistic choice, not an error."
>
{ramp.wrap ? "cyclic" : LUMA_TREND_LABEL[rampAnalysis.trend]}
</span>
</div>
)}
{activeStop && (
<>
<div className={local.rampStopEdit}>
<input
type="color"
value={activeStop.color}
aria-label="Selected stop color"
onPointerDown={beginGesture}
onBlur={endGesture}
onChange={(event) =>
edit(() => updateLayerRampStop(layer.id, activeStopIndex, { color: event.target.value }))
}
/>
<span className={local.rampStopPos}>@ {activeStop.position.toFixed(2)}</span>
<button
type="button"
className={local.rampStopDelete}
disabled={ramp.stops.length <= 1}
title={
ramp.stops.length <= 1 ? "The ramp needs at least one stop" : "Remove this stop"
}
onClick={() => deleteStop(activeStopIndex)}
>
Delete
</button>
<button
type="button"
className={local.rampResetBtn}
title="Reset to the default ramp: 0 = black, 1 = white"
onClick={() =>
edit(() => setLayerRampStops(
layer.id,
DEFAULT_RAMP_STATE.stops.map((stop) => ({ ...stop })),
))
}
>
Reset
</button>
<button
type="button"
className={local.rampResetBtn}
title="Make the low end of the ramp transparent — the classic setup for layering value fields"
onClick={() => {
const lowest = ramp.stops.reduce(
(best, stop, index) =>
stop.position < ramp.stops[best].position ? index : best,
0,
);
edit(() => updateLayerRampStop(layer.id, lowest, { alpha: 0 }));
}}
>
Fade low
</button>
</div>
<div className={dock.rampAlphaRow}>
<span>Alpha</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={activeStop.alpha}
aria-label="Selected stop alpha"
title="Opacity of this stop — transparent stops let lower layers show through"
onPointerDown={beginGesture}
onKeyDown={beginGesture}
onKeyUp={endGesture}
onBlur={endGesture}
onChange={(event) =>
edit(() => updateLayerRampStop(layer.id, activeStopIndex, {
alpha: Number(event.target.value),
}))
}
/>
<span className={dock.rampAlphaValue}>{Math.round(activeStop.alpha * 100)}%</span>
</div>
</>
)}
{!usesValueField && !layer.recolor && (
<p className={local.rampHint}>
This layer draws RGB directly — the ramp applies when the code uses display.setValue,
or when recolor is on.
</p>
)}
</div>
</div>
);
}
98b423c28This file is pinned to the selected release. Download the original to open it in your design tool.