# Color & gradient

> The wide-gamut OKLCH color picker, the gradient editor, the shadow editor, and the image picker, live.

The page: https://ryankiley.github.io/tweakit/color-and-gradient.html. Every page as Markdown: https://ryankiley.github.io/tweakit/llms.txt

The color engine is the deepest part of the kit: a wide-gamut **OKLCH** picker with CSS Color 4 gamut mapping, shared by the color control and the gradient editor. All four controls on this page are lazy: on the code-split build their modules load the first time a schema asks for them.

## Color

Any hex or CSS color-function string is recognized as a shorthand. Hex in, but the picker works in OKLCH and can emit hex, `rgb()`, `hsl()`, `oklch()` and the other wide-gamut notations (switch the format inside the picker). P3-only colors survive instead of clipping. The param is always a CSS-ready string.

Everything in the picker takes the keyboard: Tab to the plane and arrow it (↑/↓ lightness, ←/→ chroma, ⇧ for ×10, Page Up / Page Down for ten lightness steps), the hue and alpha strips are sliders (the arrow keys, Home/End), and the channel fields type or arrow-step.

```js
const panel = tweaks("Color", {
  tint: "#7C5CFF",   // or { type: "color", value: "oklch(0.65 0.24 295)" }
});
mount.append(panel.el);

const stage = mount.parentElement;   // the box behind the panel
const apply = (p) => { stage.style.background = p.tint; };
panel.on(apply);
panel.ready.then(() => apply(panel.params));
```

`mount` is the panel's slot inside the stage. In your own page you'd just `document.body.append(panel.el)`.

## Gradient

A stop editor: drag stops along the bar (or Tab to one and nudge it with the arrow keys), double-click the bar (or the + button) to add, select a stop to recolor it with the full picker, Delete (or Backspace) to remove it, down to two. The value is `{ stops: [{ color, pos }], interpolation, easing }`; hand it to `gradientCss(value, angle?)` for the CSS. Stops can be authored in `oklch()` for wide-gamut ramps.

The ramp blends in whichever color space you pick in the stop editor: switch the mode to RGB and the blend goes through sRGB (muddier; that's what `rgb()` blends look like); OKLCH stays perceptually even. That chosen space rides along as `interpolation`, so dropping it into `linear-gradient(in …)` makes your CSS match the preview exactly.

Beside the mode, **easing**. A gradient blends in a straight line between stops, and the eye reads where a straight blend starts and stops as an edge, most visibly in a fade to transparent. Pick `ease-in-out` (or any CSS easing) and the ramp eases between each pair of stops instead; it rides along as `easing`. No browser eases a gradient natively, so `gradientCss()` samples the curve into 16 stops per segment, each a `color-mix()` in the ramp's own blend space. `gradientStops(value)` is the stop list alone, for a conic or radial gradient.

```js
const panel = tweaks("Gradient", {
  ramp: { type: "gradient", value: { stops: [
    { color: "oklch(0.72 0.19 25)", pos: 0 },
    { color: "oklch(0.86 0.17 95)", pos: 0.5 },
    { color: "oklch(0.72 0.16 280)", pos: 1 },
  ] } },
  angle: [90, 0, 360, 1],
});
mount.append(panel.el);

// gradientCss honors the editor's blend space and expands its easing, so the box
// behind the panel is the picker's preview at the chosen angle.
const stage = mount.parentElement;
const apply = (p) => { stage.style.background = gradientCss(p.ramp, p.angle); };
panel.on(apply);
panel.ready.then(() => apply(panel.params));
```

## Shadow

`{ type: "shadow" }` edits a `box-shadow`: offset, blur, spread, inside or outside, and the color from the same picker, in one popover. The param is the fields plus `css`, the shadow as CSS, so `el.style.boxShadow = p.lift.css` is the whole wiring. Author it as fields or as a `box-shadow` string.

The pane's preview draws the shadow on the element it's for, scaled down to fit, so it reads there as it will on the page. Say how big that element is with `size`, its width in px or `[width, height]` (in markup, `data-size="440,440"`): a shadow made for a 440px plate then shows as it falls on the plate, where the preview's own 64px card would lose a large negative spread altogether. Without `size`, the card grows from 64px only as far as the shadow needs to stay in view, sized as the pane opens and held while you edit.

```js
const card = target.querySelector(".sh-card");
const panel = tweaks("Shadow", {
  lift: {
    type: "shadow", y: 12, blur: 32, spread: -4, color: "rgb(124 92 255 / 0.35)",
    // or the line above as value: "0 12px 32px -4px #7c5cff59"
    size: [150, 100],   // the card's, so the pane previews the shadow on it
  },
});
mount.append(panel.el);

const apply = (p) => { card.style.boxShadow = p.lift.css; };
panel.on(apply);
panel.ready.then(() => apply(panel.params));
```

## Image

`{ type: "image" }` is a drop zone and file picker in one row; the param is a data URL. Give it `options` (URLs, or `{ value, label }`) and it becomes a gallery: the row names the chosen image and opens a pane of tiles to pick from, the upload below them, and Clear, which clears the param to `""`. An upload stays in the browser and is held to 10 MB (`maxSize`, in bytes). A picture longer than 2560px on its long side is drawn down to it first (`fit`, in px, or `false` to keep uploads as they are), and one over 1.5 MB is redrawn at its size, so a phone photo can't fill storage. A JPEG stays a JPEG at `quality` 0.85; anything else stays a PNG when that comes to 1.5 MB or less, as a screenshot does, so its text isn't smudged, and past that is a JPEG, or a WebP when it has transparency. The redraw keeps Display P3 where the browser can. A GIF, an SVG, or an animated PNG or WebP is kept as it is, and a JPEG or PNG kept as it is loses its Exif and XMP, GPS included, so a photo's location doesn't ride into a save or a paste. An Ultra HDR photo keeps the XMP that holds only its gain map, so it stays HDR. `upload: false` leaves only the tiles, `clear: false` leaves out Clear for a choice that must keep a value, and `cols` sets the tiles per row, up to 6.

```js
const tile = target.querySelector(".img-tile");
const panel = tweaks("Image", {
  texture: {
    type: "image",
    value: "./textures/dusk.svg",
    options: ["./textures/dusk.svg", "./textures/waves.svg", "./textures/grid.svg", "./textures/dots.svg"],
  },
});
mount.append(panel.el);

const apply = (p) => {
  tile.style.backgroundImage = p.texture ? `url(${p.texture})` : "none";
  tile.querySelector("span").style.opacity = p.texture ? 0 : 1;
};
panel.on(apply);
panel.ready.then(() => apply(panel.params));
```
