const TABS = ["Layout", "Size", "Colors", "Look"];
const listOf = (...values) => values.map((value) => [value, value]);
const SHADOWS = [["none", "none"], ["small", "0 1px 3px rgb(0 0 0 / 20%)"],
["medium", "0 4px 12px rgb(0 0 0 / 20%)"], ["large", "0 12px 32px rgb(0 0 0 / 25%)"]];
const CORNERS = ["top-left", "top-right", "bottom-right", "bottom-left"].map((corner) => `border-${corner}-radius`);
const KNOBS = [
{ tab: "Layout", label: "Display", props: ["display"], options: listOf("block", "flex", "grid", "inline-block") },
{ tab: "Layout", label: "Direction", props: ["flex-direction"], options: listOf("row", "column"), flex: true },
{ tab: "Layout", label: "Align items", props: ["align-items"],
options: listOf("stretch", "flex-start", "center", "flex-end"), box: true },
{ tab: "Layout", label: "Justify content", props: ["justify-content"],
options: listOf("flex-start", "center", "flex-end", "space-between"), box: true },
{ tab: "Layout", label: "Gap", props: ["row-gap", "column-gap"], min: 0, max: 64, box: true },
{ tab: "Size", label: "Width", props: ["width"], min: 0, max: 800 },
{ tab: "Size", label: "Height", props: ["height"], min: 0, max: 400 },
{ tab: "Size", label: "Padding top and bottom", props: ["padding-top", "padding-bottom"], min: 0, max: 64 },
{ tab: "Size", label: "Padding left and right", props: ["padding-left", "padding-right"], min: 0, max: 64 },
{ tab: "Size", label: "Margin top and bottom", props: ["margin-top", "margin-bottom"], min: 0, max: 128 },
{ tab: "Size", label: "Margin left and right", props: ["margin-left", "margin-right"], min: 0, max: 128 },
{ tab: "Size", label: "Text size", props: ["font-size"], min: 8, max: 96 },
{ tab: "Colors", label: "Text color", props: ["color"], color: true },
{ tab: "Colors", label: "Background", props: ["background-color"], color: true },
{ tab: "Look", label: "Corner radius", props: CORNERS, min: 0, max: 64 },
{ tab: "Look", label: "Opacity", props: ["opacity"], min: 0, max: 1, step: 0.05, unit: "" },
{ tab: "Look", label: "Shadow", props: ["box-shadow"], options: SHADOWS },
];
const KNOB_PROPS = KNOBS.flatMap((knob) => knob.props);
const inPx = (knob) => !knob.color && !knob.options && knob.unit === undefined;
const swatch = document.createElement("canvas").getContext("2d");
const hexOf = (color) => {
swatch.fillStyle = "#000";
swatch.fillStyle = color;
const value = swatch.fillStyle;
return /,\s*0\)$/.test(value) ? "transparent" : value;
};
const opaque = (color) => {
if (color.startsWith("#")) return color;
const rgb = color.match(/[\d.]+/g)?.slice(0, 3) ?? ["255", "255", "255"];
return `#${rgb.map((n) => Math.round(+n).toString(16).padStart(2, "0")).join("")}`;
};
const numOf = (value) => Math.round(parseFloat(value) * 100) / 100 || 0;
const tabList = $(".tabs");
const tabs = TABS.map((name) => {
const tab = Object.assign(document.createElement("button"), { type: "button", id: `tab-${name}`, textContent: name });
const pane = Object.assign(document.createElement("div"), { className: "knobs", id: `pane-${name}` });
tab.setAttribute("role", "tab");
tab.setAttribute("aria-controls", pane.id);
pane.setAttribute("role", "tabpanel");
pane.setAttribute("aria-labelledby", tab.id);
tab.addEventListener("click", () => showTab(name));
tabList.append(tab);
$(".panes").append(pane);
return { name, tab, pane };
});
let shownTab = "Size";
function showTab(name, focus = false) {
shownTab = name;
for (const { name: each, tab, pane } of tabs) {
tab.setAttribute("aria-selected", String(each === name));
tab.tabIndex = each === name ? 0 : -1;
pane.hidden = each !== name;
if (each === name && focus) tab.focus();
}
}
tabList.addEventListener("keydown", (event) => {
const by = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
if (!by || event.altKey) return;
event.preventDefault();
showTab(TABS[(TABS.indexOf(shownTab) + by + TABS.length) % TABS.length], true);
});
showTab(shownTab);
const knobs = KNOBS.map((knob) => {
const row = document.createElement("div");
row.className = "knob";
row.innerHTML = `<span aria-hidden="true"></span><output aria-hidden="true"></output>
${knob.options ? "<select></select>" : `<input type="${knob.color ? "color" : "range"}">`}`;
const [name, out, input] = ["span", "output", "input, select"].map((tag) => row.querySelector(tag));
name.textContent = knob.label;
input.setAttribute("aria-label", knob.label);
if (knob.step) input.step = String(knob.step);
tabs.find((tab) => tab.name === knob.tab).pane.append(row);
const state = { ...knob, out, input, before: [], after: null };
input.addEventListener("input", () => turnKnob(state));
return state;
});
function showKnob(knob) {
if (knob.options) return;
const value = knob.after ?? knob.before[0];
const text = inPx(knob) ? `${value} px` : String(value);
knob.out.textContent = text;
if (!knob.color) knob.input.setAttribute("aria-valuetext", text);
}
function fitKnobs(node) {
const display = getComputedStyle(node).display;
for (const knob of knobs) {
if (knob.flex || knob.box) knob.input.disabled = !(knob.flex ? display.includes("flex") : /flex|grid/.test(display));
}
}
function loadKnobs(node, saved) {
const style = getComputedStyle(node);
knobs.forEach((knob, index) => {
const read = (prop) => style.getPropertyValue(prop);
knob.before = saved?.[index].before ?? knob.props.map((prop) =>
(knob.color ? hexOf(read(prop)) : knob.options ? read(prop) : numOf(read(prop))));
knob.after = saved?.[index].after ?? null;
const now = knob.after ?? knob.before[0];
if (knob.options) {
const known = knob.options.some(([, value]) => value === knob.before[0]);
const options = known ? knob.options : [...knob.options, [knob.before[0], knob.before[0]]];
knob.input.replaceChildren(...options.map(([text, value]) => new Option(text, value)));
knob.input.value = now;
} else if (knob.color) {
knob.input.value = opaque(now);
} else {
knob.input.min = String(Math.floor(Math.min(knob.min, ...knob.before)));
knob.input.max = String(inPx(knob) ? Math.ceil(Math.max(knob.max, ...knob.before.map((value) => value * 2))) : knob.max);
knob.input.value = String(now);
}
showKnob(knob);
});
fitKnobs(node);
}
const knobState = () => knobs.map(({ before, after }) => ({ before, after }));
function turnKnob(knob) {
const raw = knob.input.value;
const value = inPx(knob) ? `${raw}px` : raw;
for (const prop of knob.props) editing.node.style.setProperty(prop, value, "important");
knob.after = knob.color || knob.options ? raw : Number(raw);
showKnob(knob);
if (knob.label === "Display") fitKnobs(editing.node);
}
const knobChanges = () => knobs
.filter((knob) => knob.after !== null && knob.before.some((value) => value !== knob.after))
.map((knob) => `${knob.label} ${[...new Set(knob.before)].join(" and ")} -> ${knob.after}${inPx(knob) ? " px" : ""}`);