Tweakit
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.
npm install tweakitInstall 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 add tweakityarn add tweakitbun add tweakitconst 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);
});
});