Tweakit v0.7.2

Color & gradient

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.

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.

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.

Card
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.

Pick a texture, or drop one on the control →
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));