# Tweakit: a dependency-free, real-time parameter panel

> A dependency-free, code-split, real-time parameter panel. Hand it a plain schema and it builds a live control for each value.

The page: https://ryankiley.github.io/tweakit/index.html. Every page as Markdown: https://ryankiley.github.io/tweakit/llms.txt

A dependency-free, code-split, real-time parameter panel. Hand it a plain schema; it builds a live control for each value: sliders, color, curves, springs, and more.

- No framework
- ~25 KB gzip code-split
- TypeScript types included

## Install

npm:

```sh
npm install tweakit
```

agent:

```text
Install tweakit in this repo. Read https://ryankiley.github.io/tweakit/AGENTS.md first. Install it with this project's package manager, import "tweakit/css", and add one panel with a few controls to an existing component so I can tune it live. Bind the values to its styles. Verify the build.
```

pnpm:

```sh
pnpm add tweakit
```

yarn:

```sh
yarn add tweakit
```

bun:

```sh
bun add tweakit
```

## The schema behind this pane

```js
const text = target.querySelector(".sp-text");
const when = (style) => (get) => get("style") === style;   // show a row for one fill style
const start = Math.max(48, Math.round(target.clientWidth / 3.4)); // most of the column
const pageColor = () => getComputedStyle(target).color;          // the page's text color, per theme
text.style.fontSize = `${start}px`;                               // the CSS size below, pinned
const panel = tweaks("Tweakit", {
  text: {
    size: { type: "slider", label: "Font size", value: start, min: 16, max: 480, step: 1, unit: "px" },
    weight: [300, 100, 900, 10],
    tracking: { type: "slider", value: -0.04, min: -0.1, max: 0.25, step: 0.005, unit: "em" },
  },
  fill: {
    style: { type: "segmented", options: ["solid", "gradient"], value: "solid" },
    color: { type: "color", value: pageColor(), render: when("solid") },
    gradient: { type: "gradient", stops: [["oklch(0.78 0.19 30)", 0], ["oklch(0.68 0.22 295)", 1]], render: when("gradient") },
    angle: { type: "slider", value: 90, min: 0, max: 360, step: 1, unit: "°", render: when("gradient") },
  },
  effects: {
    shadow: { type: "shadow", y: 6, blur: 20, color: "rgb(0 0 0 / 0.14)" },
    bounce: { type: "motion", visualDuration: 0.6, bounce: 0.5 },   // edit it and the word hops on it
  },
  actions: {
    play: { type: "button", label: "Bounce", action: () => hop() },
  },
});
mount.append(panel.el);

const apply = ({ text: { size, weight, tracking }, fill, effects: { shadow } }) => {
  const s = text.style, gradient = fill.style === "gradient";
  s.fontSize = `${size}px`;
  s.fontWeight = weight;
  s.letterSpacing = `${tracking}em`;
  // a word never breaks: one too wide for its column shrinks to fit (a few passes, since
  // glyph widths don't scale exactly with size)
  for (let i = 0; i < 4 && text.scrollWidth > text.clientWidth; i++) {
    s.fontSize = `${parseFloat(s.fontSize) * text.clientWidth / text.scrollWidth}px`;
  }
  s.backgroundImage = gradient && fill.gradient ? gradientCss(fill.gradient, fill.angle) : "none";
  s.color = gradient ? "transparent" : fromPage.has(fill.color) ? "" : fill.color; // a color from the page follows its theme
  // drop-shadow, not text-shadow: a text-shadow paints over the clipped gradient
  s.filter = shadow && !shadow.inset && (shadow.x || shadow.y || shadow.blur)   // a drop-shadow can't be inset
    ? `drop-shadow(${shadow.x}px ${shadow.y}px ${shadow.blur / 2}px ${shadow.color})`
    : "none";
};
let hopping = false, again = false;                  // an edit mid-hop replays it after
async function hop() {
  if (!text.animate) return;
  if (matchMedia("(prefers-reduced-motion: reduce)").matches) {        // a fade, not a hop
    text.animate([{ opacity: 1 }, { opacity: 0.35 }, { opacity: 1 }], { duration: 500, easing: "ease-in-out" });
    return;
  }
  if (hopping) { again = true; return; }
  hopping = true;
  try {
    const { duration, easing } = panel.params.effects.bounce;  // a spring resolves to linear(…)
    // when the spring first reaches the ground: its first stop at or past 1 (the stops are evenly spaced)
    const stops = easing.startsWith("linear(") ? easing.slice(7, -1).split(",").map(Number) : [];
    const at = stops.findIndex((v) => v >= 1), land = duration * (at > 0 ? at / (stops.length - 1) : 1);
    // hop about half the word's height, but never up under the phone's sticky top bar (when
    // it shows): the room runs from the cap line (the box is padded above it) to 9px under
    // the bar, less the 10% stretch
    const cs = getComputedStyle(text), box = text.getBoundingClientRect();
    const bar = document.querySelector(".topbar"), barBottom = bar && bar.offsetHeight ? bar.getBoundingClientRect().bottom : -Infinity;
    const room = box.top + parseFloat(cs.paddingTop) - (barBottom + 9) - box.height * 0.1;
    const lift = Math.min(0.45 * parseFloat(cs.fontSize), Math.max(8, room));
    const up = `0 -${lift}px`;
    await text.animate([                             // crouch, then spring up stretched
      { translate: "0 0", scale: "1 1" },
      { translate: "0 0", scale: "1.1 0.88", offset: 0.35 },
      { translate: up, scale: "0.92 1.1" },
    ], { duration: 320, easing: "ease-out" }).finished;
    const fall = text.animate([{ translate: up }, { translate: "0 0" }], { duration, easing });
    const squash = land + 320;                       // squash at impact, then 320 ms to recover
    await Promise.all([fall.finished, text.animate([
      { scale: "0.92 1.1" },
      { scale: "1 1", offset: (land * 0.8) / squash },
      { scale: "1.14 0.84", offset: land / squash, easing: "cubic-bezier(0.3, 1.6, 0.5, 1)" },
      { scale: "1 1" },
    ], { duration: squash }).finished]);
  } catch {} finally {                               // a canceled run still frees the next one
    hopping = false;
    if (again) { again = false; hop(); }
  }
}
panel.on((p, changed) => { apply(p); if (changed === "effects.bounce") hop(); });
// The pane at the slot's middle, on its fullest height: measured only while it sits open in
// the slot (not collapsed, not floated out), so a closed folder or a drag can't shift it.
let fullHeight = 0;
const center = () => {
  if (mount.closest(".is-open")) return;                     // the schema is open and the slot shorter for now: stay put
  const inSlot = panel.el.parentElement === mount, open = panel.el.querySelector(".twk-header-toggle")?.getAttribute("aria-expanded") !== "false";
  if (inSlot && open) fullHeight = Math.max(fullHeight, panel.el.offsetHeight);
  mount.style.paddingTop = "0px";
  mount.style.paddingTop = `${Math.max(0, (mount.clientHeight - fullHeight) / 2)}px`;
};
const fromPage = new Set();                                     // the page colors the panel has held
panel.ready.then(() => {                                        // the values exist from here
  fromPage.add(panel.params.fill.color);
  apply(panel.params); center(); hop();
  setTimeout(center, 400);                                     // again once the folders finish opening
  window.addEventListener("resize", () => { apply(panel.params); center(); });
  // the device theme flips: a color still taken from the page moves to the new one;
  // a color you picked stays
  matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
    if (!fromPage.has(panel.params.fill.color)) return;
    panel.set("fill.color", pageColor());
    fromPage.add(panel.params.fill.color);
    apply(panel.params);
  });
});
```
