tweakit 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +31 -29
- package/README.md +55 -29
- package/THIRD-PARTY-NOTICES.md +0 -1
- package/dist/tweaks/auto.js +2 -0
- package/dist/tweaks/auto.js.map +6 -0
- package/dist/tweaks/bezier-JFEZMCLP.js +2 -0
- package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
- package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
- package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
- package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
- package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
- package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
- package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
- package/dist/tweaks/clip-WW7JYO4X.js +2 -0
- package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
- package/dist/tweaks/color-YMMFURP6.js +2 -0
- package/dist/tweaks/color-YMMFURP6.js.map +6 -0
- package/dist/tweaks/core.js +2 -1
- package/dist/tweaks/core.js.map +6 -0
- package/dist/tweaks/{fold-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
- package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
- package/dist/tweaks/gradient-JER7PW4D.js +2 -0
- package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
- package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
- package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
- package/dist/tweaks/image-H6QSLGO3.js +4 -0
- package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
- package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
- package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
- package/dist/tweaks/interval-TEUHQGZN.js +2 -0
- package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
- package/dist/tweaks/markup-NIBJD6E2.js +2 -0
- package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
- package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
- package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
- package/dist/tweaks/motion-FQPUBKKM.js +2 -0
- package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
- package/dist/tweaks/plot-2HBNBZSG.js +3 -0
- package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
- package/dist/tweaks/point-VV6XV3I6.js +2 -0
- package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
- package/dist/tweaks/shadow-5E4E55I2.js +2 -0
- package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
- package/dist/tweaks/sides-P3STEVYY.js +2 -0
- package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
- package/dist/tweaks/spring-HUVSXNVN.js +2 -0
- package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
- package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
- package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +6 -3
- package/dist/tweaks.js.map +6 -0
- package/dist/types/tweaks/easing.d.ts +8 -11
- package/dist/types/tweaks/panel.d.ts +6 -4
- package/dist/types/tweaks/schema.d.ts +4 -8
- package/dist/types/tweaks/standalone.d.ts +3 -3
- package/dist/types/tweaks/types.d.ts +278 -101
- package/package.json +27 -5
- package/src/tweaks/auto.ts +12 -0
- package/src/tweaks/clip.ts +94 -0
- package/src/tweaks/controls/basic.ts +403 -0
- package/src/tweaks/controls/bezier.ts +99 -0
- package/src/tweaks/controls/color.ts +412 -0
- package/src/tweaks/controls/gradient.ts +240 -0
- package/src/tweaks/controls/image.ts +299 -0
- package/src/tweaks/controls/interval.ts +104 -0
- package/src/tweaks/controls/monitor.ts +105 -0
- package/src/tweaks/controls/motion.ts +141 -0
- package/src/tweaks/controls/plot.ts +220 -0
- package/src/tweaks/controls/point.ts +119 -0
- package/src/tweaks/controls/shadow.ts +83 -0
- package/src/tweaks/controls/sides.ts +86 -0
- package/src/tweaks/controls/spring.ts +148 -0
- package/src/tweaks/controls/tabs.ts +47 -0
- package/src/tweaks/core.ts +9 -0
- package/src/tweaks/easing.ts +93 -0
- package/src/tweaks/enhance.ts +20 -0
- package/src/tweaks/feedback.ts +119 -0
- package/src/tweaks/fold.ts +21 -0
- package/src/tweaks/heavy.ts +302 -0
- package/src/tweaks/icons.ts +19 -0
- package/src/tweaks/image-store.ts +101 -0
- package/src/tweaks/lazy.ts +46 -0
- package/src/tweaks/markup.ts +154 -0
- package/src/tweaks/panel.ts +869 -0
- package/src/tweaks/schema.ts +189 -0
- package/src/tweaks/shared.ts +525 -0
- package/src/tweaks/single.ts +22 -0
- package/src/tweaks/standalone.ts +89 -0
- package/src/tweaks/types.ts +526 -0
- package/src/tweaks.css +959 -0
- package/src/wide-gamut.ts +310 -0
- package/dist/tweaks/bezier-2RABC6LD.js +0 -1
- package/dist/tweaks/chunk-4ATI34PB.js +0 -1
- package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
- package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
- package/dist/tweaks/clip-ECGCECEC.js +0 -1
- package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
- package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
- package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
- package/dist/tweaks/image-7MCLBOZ3.js +0 -1
- package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
- package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
- package/dist/tweaks/monitor-IEK742PG.js +0 -2
- package/dist/tweaks/motion-N5UNVR55.js +0 -1
- package/dist/tweaks/plot-VVTOY3RA.js +0 -2
- package/dist/tweaks/point-YMMHSLO7.js +0 -1
- package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
- package/dist/tweaks/sides-FH227Z54.js +0 -1
- package/dist/tweaks/spring-3PKAFIH7.js +0 -1
- package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ── Point — 2D/3D/4D vector. Lazy.
|
|
2
|
+
import { el, clamp, stepPrecision, defaultRange, popover, registerControl } from "../shared.js";
|
|
3
|
+
import { numField, svgEl, grabSurface, boxFrac, arrowStep, sayWhenKeysStop, triggerRow, decimalStep } from "../heavy.js";
|
|
4
|
+
import type { OnChange, Built } from "../shared.js";
|
|
5
|
+
import type { Meta, PointComponent } from "../schema.js";
|
|
6
|
+
import type { Control } from "../types.js";
|
|
7
|
+
|
|
8
|
+
// A trigger row opens the 2D pad over the component fields in a popover. The fields hold the values; readout, thumb
|
|
9
|
+
// and emitted map read off them. `pad: false` opts out.
|
|
10
|
+
function createPoint(meta: Meta, onChange: OnChange): Control {
|
|
11
|
+
const comps = meta.components; // [{ key, label, value, step, min, max }]
|
|
12
|
+
|
|
13
|
+
// ── Trigger row — label + value + a mini pad preview. ──
|
|
14
|
+
const { root, trigger, right } = triggerRow("tw-point", meta.label ?? "Point"); // ??: an explicit "" label renders none
|
|
15
|
+
const valueEl = el("span", "tw-trigger-value");
|
|
16
|
+
const preview = el("div", "tw-trigger-chip tw-point-preview");
|
|
17
|
+
const previewDot = el("div", "tw-point-preview-dot");
|
|
18
|
+
preview.append(previewDot);
|
|
19
|
+
right.append(preview, valueEl); // chip first, as every chip row
|
|
20
|
+
|
|
21
|
+
// ── Popover — the 2D pad over the component fields. ──
|
|
22
|
+
const pop = el("div", "tw-color-pop tw-point-pop");
|
|
23
|
+
const body = el("div", "tw-point-body");
|
|
24
|
+
pop.append(body);
|
|
25
|
+
|
|
26
|
+
const hasPad = meta.pad !== false && comps.length >= 2;
|
|
27
|
+
// An absent bound is the unit range widened to hold the value, mirrored about the origin (5 spans ±15); an absent
|
|
28
|
+
// step is the decade at or below range/200, so the pad is continuous, and finer when the default or min sits between
|
|
29
|
+
// its steps (337 over ±1011 stepped by 10 to 340), as the slider's step fits its default.
|
|
30
|
+
const rangeOf = (c: PointComponent) => {
|
|
31
|
+
const half = defaultRange(Math.abs(Number.isFinite(+c.value) ? +c.value : 0))[1];
|
|
32
|
+
let lo = c.min ?? -half, hi = c.max ?? half;
|
|
33
|
+
// A lone bound at or past the other end spans from itself instead, as the slider's does (min 5 over 0 spans 5–35, not 5–1, swapped).
|
|
34
|
+
if ((c.min == null) !== (c.max == null) && !(hi > lo)) { const w = 2 * defaultRange(Math.abs(+(c.min ?? c.max)!))[1]; if (c.min == null) lo = hi - w; else hi = lo + w; }
|
|
35
|
+
const fine = 10 ** Math.floor(Math.log10((hi - lo) / 200)), nudge = c.step ?? (fine > 0 && Number.isFinite(fine) ? fine : 1); // a degenerate range (hi ≤ lo, NaN) keeps a step of 1
|
|
36
|
+
return { lo, hi, nudge, step: c.step ?? Math.min(nudge, decimalStep(c.value), decimalStep(c.min)) }; // the pad's arrows keep the decade: 3.14159's 0.00001 would crawl
|
|
37
|
+
};
|
|
38
|
+
const ranges = comps.map(rangeOf);
|
|
39
|
+
const rx = ranges[0], ry = ranges[1];
|
|
40
|
+
const minX = rx?.lo ?? -1, maxX = rx?.hi ?? 1, minY = ry?.lo ?? -1, maxY = ry?.hi ?? 1;
|
|
41
|
+
const frac = (v: number, lo: number, hi: number) => (hi > lo ? clamp((v - lo) / (hi - lo), 0, 1) : 0.5);
|
|
42
|
+
|
|
43
|
+
let padHost: HTMLDivElement | null = null, padThumb: HTMLDivElement | null = null, padLine: Built<SVGLineElement> | null = null;
|
|
44
|
+
if (hasPad) {
|
|
45
|
+
padHost = el("div", "tw-pad");
|
|
46
|
+
padThumb = el("div", "tw-pad-thumb");
|
|
47
|
+
// A tether from the origin to the thumb, so the point reads as a vector; the pad is square so the angle is true.
|
|
48
|
+
const padSvg = svgEl("svg", "tw-pad-line"); padSvg.setAttribute("viewBox", "0 0 100 100"); padSvg.setAttribute("preserveAspectRatio", "none");
|
|
49
|
+
padLine = svgEl("line"); padSvg.append(padLine);
|
|
50
|
+
// The origin is fixed for the control's life: set x1/y1 once.
|
|
51
|
+
const oy0 = frac(0, minY, maxY); padLine.setAttribute("x1", frac(0, minX, maxX) * 100); padLine.setAttribute("y1", (meta.invertY ? oy0 : 1 - oy0) * 100);
|
|
52
|
+
padHost.append(el("div", "tw-pad-axis tw-pad-axis-x"), el("div", "tw-pad-axis tw-pad-axis-y"), padSvg, padThumb);
|
|
53
|
+
body.append(padHost);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fields = el("div", "tw-fields");
|
|
57
|
+
body.append(fields);
|
|
58
|
+
const sync = () => { positionPad(); paintValue(); };
|
|
59
|
+
const emit = () => onChange(read());
|
|
60
|
+
const flds = comps.map((c, k) => {
|
|
61
|
+
const fld = numField({ label: c.label, value: c.value ?? 0, step: ranges[k].step, min: c.min, max: c.max }, () => { sync(); emit(); });
|
|
62
|
+
fields.append(fld.el); return fld;
|
|
63
|
+
});
|
|
64
|
+
const read = () => Object.fromEntries(comps.map((c, k) => [c.key, flds[k].get()])); // the emitted map, straight off the fields
|
|
65
|
+
|
|
66
|
+
// Readout: the components at each step's precision, so a value reads "−1.00, −0.46" (steady columns), not "−1, −0.46".
|
|
67
|
+
const fmt = (v: number, step: number) => (+v).toFixed(stepPrecision(step));
|
|
68
|
+
const paintValue = () => { valueEl.textContent = flds.map((f, k) => fmt(f.get(), ranges[k].step)).join(", "); };
|
|
69
|
+
let positionPad = () => { previewDot.style.left = "50%"; previewDot.style.top = "50%"; };
|
|
70
|
+
|
|
71
|
+
if (hasPad) {
|
|
72
|
+
// The mini preview is clipped, so inset the 5px dot: its center travels from +2.5px at 0 to −2.5px at 1.
|
|
73
|
+
const DOT = 5;
|
|
74
|
+
const inset = (f: number) => `calc(${(f * 100).toFixed(2)}% + ${((0.5 - f) * DOT).toFixed(2)}px)`;
|
|
75
|
+
positionPad = () => {
|
|
76
|
+
const fx = frac(flds[0].get(), minX, maxX), fyv = frac(flds[1].get(), minY, maxY);
|
|
77
|
+
const tyFrac = meta.invertY ? fyv : 1 - fyv;
|
|
78
|
+
const tx = fx * 100, ty = tyFrac * 100;
|
|
79
|
+
padThumb.style.left = tx + "%"; padThumb.style.top = ty + "%";
|
|
80
|
+
previewDot.style.left = inset(fx); previewDot.style.top = inset(tyFrac); // mini pad: dot stays in bounds
|
|
81
|
+
padLine.setAttribute("x2", tx); padLine.setAttribute("y2", ty);
|
|
82
|
+
padHost.setAttribute("aria-description", `${comps[0].label ?? comps[0].key} ${fmt(flds[0].get(), rx.step)}, ${comps[1].label ?? comps[1].key} ${fmt(flds[1].get(), ry.step)}`); // the pad's live readout for assistive tech
|
|
83
|
+
};
|
|
84
|
+
const padSet = (e: PointerEvent) => {
|
|
85
|
+
const [fx, fy] = boxFrac(e, padHost); const yFrac = meta.invertY ? fy : 1 - fy;
|
|
86
|
+
flds[0].set(minX + fx * (maxX - minX)); flds[1].set(minY + yFrac * (maxY - minY));
|
|
87
|
+
sync(); emit();
|
|
88
|
+
};
|
|
89
|
+
grabSurface(padHost, padSet);
|
|
90
|
+
// Keyboard: a focusable group (no ARIA role fits a 2D point; the readout rides on aria-description). Arrows move a
|
|
91
|
+
// pad step (⇧ = ten), ↑ always up on screen; Home restores the default. Escape is the popover's — a blur here would close it first.
|
|
92
|
+
padHost.tabIndex = 0; padHost.setAttribute("role", "group");
|
|
93
|
+
padHost.setAttribute("aria-label", `${meta.label ?? "Point"} pad: ${comps[0].label ?? comps[0].key} and ${comps[1].label ?? comps[1].key}`);
|
|
94
|
+
const say = sayWhenKeysStop(padHost);
|
|
95
|
+
padHost.addEventListener("keydown", (e) => {
|
|
96
|
+
if (e.key === "Home") { e.preventDefault(); comps.forEach((c, k) => flds[k].set(c.value ?? 0)); sync(); emit(); say(); return; }
|
|
97
|
+
const [dx, dy] = arrowStep(e);
|
|
98
|
+
if (!dx && !dy) return;
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
const k = e.shiftKey ? 10 : 1;
|
|
101
|
+
if (dx) flds[0].set(clamp(flds[0].get() + dx * rx.nudge * k, minX, maxX));
|
|
102
|
+
if (dy) flds[1].set(clamp(flds[1].get() + (meta.invertY ? -dy : dy) * ry.nudge * k, minY, maxY));
|
|
103
|
+
sync(); emit(); say();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
root.append(pop);
|
|
108
|
+
popover(root, trigger, pop, { fallbackH: 240, onOpen: sync });
|
|
109
|
+
sync();
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
el: root,
|
|
113
|
+
set: (v: Record<string, number | string>) => { if (v) comps.forEach((c, k) => { if (v[c.key] != null) flds[k].set(v[c.key]); }); sync(); },
|
|
114
|
+
get: read,
|
|
115
|
+
range: Object.fromEntries(comps.map((c, k) => [c.key, flds[k].range])),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
registerControl("point", createPoint);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ── Shadow — a box-shadow editor with the kit's own picker mounted inline (popover() is single-open). The value carries
|
|
2
|
+
// the CSS as `css`, built only from the numbers and a color the picker accepted as one color token (isColor) — a host
|
|
3
|
+
// string reaching a `style` sink could splice in a url(). Lazy. ──
|
|
4
|
+
import { el, txt, popover, createSegmented, registerControl, btn, icon, isObj } from "../shared.js";
|
|
5
|
+
import { numField, triggerRow } from "../heavy.js";
|
|
6
|
+
import { createPickerBody, isColor } from "./color.js";
|
|
7
|
+
import type { OnChange, NumField } from "../shared.js";
|
|
8
|
+
import type { Meta } from "../schema.js";
|
|
9
|
+
import type { Control, ShadowValue, ShadowInput } from "../types.js";
|
|
10
|
+
|
|
11
|
+
const DEF = { x: 0, y: 8, blur: 24, spread: 0, color: "rgb(0 0 0 / 0.18)" };
|
|
12
|
+
const num = (v: unknown, d: number) => (v != null && v !== "" && Number.isFinite(+v) ? +v : d);
|
|
13
|
+
const fmt = (n: number) => String(+n.toFixed(1));
|
|
14
|
+
// One color function, no nesting, so a stray `)` can't extend the match.
|
|
15
|
+
const COLOR_RE = /#[0-9a-f]{3,8}\b|(?:oklch|oklab|rgba?|hsla?|hwb|lab|lch|color)\([^()]*\)/i;
|
|
16
|
+
const LEN_RE = /^(-?\d*\.?\d+)(px)?$/;
|
|
17
|
+
// Strict: optional `inset`, two to four px lengths, one color, one layer; anything else is null.
|
|
18
|
+
const parseShadow = (s: string): ShadowInput | null => {
|
|
19
|
+
const m = COLOR_RE.exec(s); if (!m || !isColor(m[0])) return null;
|
|
20
|
+
const rest = (s.slice(0, m.index) + " " + s.slice(m.index + m[0].length)).trim().split(/\s+/).filter(Boolean);
|
|
21
|
+
const inset = rest.includes("inset"), lens = rest.filter((t) => t !== "inset");
|
|
22
|
+
if (lens.length < 2 || lens.length > 4 || !lens.every((t) => LEN_RE.test(t))) return null;
|
|
23
|
+
const n = lens.map((t) => +t.replace(/px$/, ""));
|
|
24
|
+
return { inset, x: n[0], y: n[1], blur: n[2] ?? 0, spread: n[3] ?? 0, color: m[0] };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const CHEVRON = icon('<path d="M18 9s-4.4 6-6 6-6-6-6-6"/>', "tw-shadow-chevron", 2.5); // the kit's chevron; importing icons.ts splits a 4th chunk
|
|
28
|
+
function createShadow(meta: Meta, onChange: OnChange): Control {
|
|
29
|
+
const read = (v: unknown): ShadowInput | null => (typeof v === "string" ? parseShadow(v) : isObj(v) ? v : null);
|
|
30
|
+
const init = read(meta.value ?? meta) || {}; // the fields may sit on the meta itself (verbose form)
|
|
31
|
+
let inset = !!init.inset;
|
|
32
|
+
|
|
33
|
+
// ── Row — label, chip, summary. ──
|
|
34
|
+
const { root, trigger, right } = triggerRow("tw-shadow", meta.label ?? "Shadow"); // ??: an explicit "" label renders none
|
|
35
|
+
const chip = el("span", "tw-trigger-chip tw-shadow-chip"), chipTile = el("span", "tw-shadow-chip-tile"); chip.append(chipTile);
|
|
36
|
+
const valueEl = el("span", "tw-trigger-value");
|
|
37
|
+
right.append(chip, valueEl);
|
|
38
|
+
|
|
39
|
+
// ── Popover — a preview card, the position switch, the four lengths, the picker. ──
|
|
40
|
+
const pop = el("div", "tw-color-pop tw-shadow-pop");
|
|
41
|
+
const stage = el("div", "tw-shadow-stage"), card = el("div", "tw-shadow-card"); stage.append(card);
|
|
42
|
+
const insetSeg = createSegmented([{ value: "outer", label: "Outer" }, { value: "inner", label: "Inner" }], inset ? "inner" : "outer", (m: unknown) => { if ((m === "inner") !== inset) { inset = m === "inner"; update(); emit(); } }, "Shadow position");
|
|
43
|
+
const insetRow = el("div", "tw-row"); insetRow.append(txt("span", "tw-row-label", "Position"), insetSeg.el);
|
|
44
|
+
const fields = el("div", "tw-fields tw-shadow-fields");
|
|
45
|
+
// The four lengths live in their fields, read as the fields fit them: an authored 2.5 shows "3" and emits 3px, and the
|
|
46
|
+
// blur's min clamps a negative one to 0.
|
|
47
|
+
const field = (label: string, value: number, min?: number): NumField => { const f = numField({ label, value, step: 1, min, unit: "px" }, () => { update(); emit(); }); fields.append(f.el); return f; };
|
|
48
|
+
const fx = field("X", num(init.x, DEF.x)), fy = field("Y", num(init.y, DEF.y)), fb = field("Blur", num(init.blur, DEF.blur), 0), fs = field("Spread", num(init.spread, DEF.spread));
|
|
49
|
+
const lengths = () => [fx, fy, fb, fs].map((f) => f.get());
|
|
50
|
+
// The color row discloses the picker, so the pop opens compact.
|
|
51
|
+
const colorRow = btn("tw-trigger tw-shadow-color"); colorRow.setAttribute("aria-expanded", "false");
|
|
52
|
+
const cSwatch = el("span", "tw-trigger-chip tw-color-swatch"), cText = el("span", "tw-trigger-value"), cRight = el("span", "tw-trigger-right");
|
|
53
|
+
cRight.append(cSwatch, cText); cRight.insertAdjacentHTML("beforeend", CHEVRON); colorRow.append(txt("span", "tw-trigger-label", "Color"), cRight);
|
|
54
|
+
const body = createPickerBody({ value: typeof init.color === "string" && isColor(init.color) ? init.color : DEF.color, onMode: () => update() }, () => { update(); emit(); });
|
|
55
|
+
body.el.hidden = true;
|
|
56
|
+
colorRow.addEventListener("click", () => { const show = body.el.hidden; body.el.hidden = !show; colorRow.setAttribute("aria-expanded", String(show)); pop_.reflow(); }); // re-place for the new height; the picker lays out now
|
|
57
|
+
pop.append(stage, insetRow, fields, colorRow, body.el);
|
|
58
|
+
root.append(pop);
|
|
59
|
+
|
|
60
|
+
const cssAt = (k: number) => `${inset ? "inset " : ""}${lengths().map((n) => `${fmt(n * k)}px`).join(" ")} ${body.get()}`;
|
|
61
|
+
const value = (): ShadowValue => { const [x, y, blur, spread] = lengths().map((n) => +fmt(n)); return { inset, x, y, blur, spread, color: body.get(), css: cssAt(1) }; };
|
|
62
|
+
const emit = () => onChange(value());
|
|
63
|
+
const update = () => {
|
|
64
|
+
card.style.boxShadow = cssAt(1); chipTile.style.boxShadow = cssAt(0.3);
|
|
65
|
+
const [x, y, blur, spread] = lengths();
|
|
66
|
+
valueEl.textContent = `${inset ? "in " : ""}${fmt(x)} ${fmt(y)} ${fmt(blur)}${spread ? " " + fmt(spread) : ""}`;
|
|
67
|
+
cSwatch.style.background = body.swatchCss(); cText.textContent = body.valueText();
|
|
68
|
+
};
|
|
69
|
+
const pop_ = popover(root, trigger, pop, { fallbackH: 300, onOpen: body.reflow, onReflow: body.reflow });
|
|
70
|
+
update();
|
|
71
|
+
|
|
72
|
+
// Set/restore: the object form or a strict box-shadow string; `css` is ignored, and the color goes through the picker.
|
|
73
|
+
const set = (v: unknown) => {
|
|
74
|
+
const inp = read(v); if (!inp) return;
|
|
75
|
+
if (inp.inset != null) { inset = !!inp.inset; insetSeg.set(inset ? "inner" : "outer"); }
|
|
76
|
+
([["x", fx], ["y", fy], ["blur", fb], ["spread", fs]] as const).forEach(([k, f]) => { if (inp[k] != null) f.set(num(inp[k], f.get())); });
|
|
77
|
+
if (typeof inp.color === "string" && isColor(inp.color)) body.set(inp.color);
|
|
78
|
+
update();
|
|
79
|
+
};
|
|
80
|
+
return { el: root, set, get: value };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
registerControl("shadow", createShadow);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ── Sides — one row for a four-sided length (padding, margin, border width, or with `corners` a radius): one field
|
|
2
|
+
// linked; unlinked, the row opens in place and four fields sit in a line under it, in CSS order, each under its glyph.
|
|
3
|
+
// The value is the four numbers in CSS order plus the shorthand `css`, built only from the numbers and the unit —
|
|
4
|
+
// never a host string. Lazy. ──
|
|
5
|
+
import { el, txt, btn, icon, registerControl, stepPrecision } from "../shared.js";
|
|
6
|
+
import { numField, decimalStep } from "../heavy.js";
|
|
7
|
+
import type { OnChange, NumField } from "../shared.js";
|
|
8
|
+
import type { Meta } from "../schema.js";
|
|
9
|
+
import type { Control, SidesValue } from "../types.js";
|
|
10
|
+
|
|
11
|
+
const ICON_SPLIT = icon('<path d="M3 9V7a4 4 0 0 1 4-4h2M15 3h2a4 4 0 0 1 4 4v2M21 15v2a4 4 0 0 1-4 4h-2M9 21H7a4 4 0 0 1-4-4v-2"/>'); // original: the four corner arcs of a rounded box, apart
|
|
12
|
+
const ICON_CORNER = icon('<path d="M3 13V8a5 5 0 0 1 5-5h5"/>', "", 1.5, 16); // original: the top-left quarter of a rounded box, turned per corner in CSS
|
|
13
|
+
const ICON_EDGE = icon('<path d="M3 10V8a5 5 0 0 1 5-5a5 5 0 0 1 5 5v2"/>', "", 1.5, 16); // original: the top of that box, two of its corners joined, turned per side
|
|
14
|
+
const LEN_RE = /^(-?\d*\.?\d+)([a-z%]*)$/i;
|
|
15
|
+
const num = (v: unknown) => (v != null && v !== "" && Number.isFinite(+v) ? +v : null);
|
|
16
|
+
const expand = (n: number[]) => (n.length === 1 ? [n[0], n[0], n[0], n[0]] : n.length === 2 ? [n[0], n[1], n[0], n[1]] : n.length === 3 ? [n[0], n[1], n[2], n[1]] : n.slice(0, 4));
|
|
17
|
+
// A shorthand string as one to four [number, unit] pairs, or null for anything else: calc(), var(), five lengths, junk.
|
|
18
|
+
const lengths = (s: string) => { const m = s.trim().split(/\s+/).map((t) => LEN_RE.exec(t)); return m.length <= 4 && m.every(Boolean) ? (m as RegExpExecArray[]).map((x) => [+x[1], x[2].toLowerCase()] as const) : null; };
|
|
19
|
+
// Any form to four numbers, or null; an object fills in from `cur`, so `{ top: 4 }` moves one side.
|
|
20
|
+
const read = (v: unknown, cur: number[]): number[] | null => {
|
|
21
|
+
if (typeof v === "number") return Number.isFinite(v) ? [v, v, v, v] : null;
|
|
22
|
+
if (typeof v === "string") { const n = lengths(v); return n ? expand(n.map((x) => x[0])) : null; }
|
|
23
|
+
if (Array.isArray(v)) { const n = v.map(num); return n.length >= 1 && n.length <= 4 && n.every((x) => x !== null) ? expand(n as number[]) : null; }
|
|
24
|
+
if (v && typeof v === "object") { const o = v as Record<string, unknown>, out = cur.slice(); let any = false; (["top", "right", "bottom", "left"] as const).forEach((k, i) => { const x = num(o[k]); if (x !== null) { out[i] = x; any = true; } }); return any ? out : null; }
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// The unit a shorthand string says, when its sides agree: "1rem 2rem" is rem, and so is "0 1rem 2". A zero doesn't vote,
|
|
29
|
+
// as 0 is 0 in any unit. Mixed units can't be kept, since the control edits four numbers in one unit, so they warn and
|
|
30
|
+
// read as px, as every string did before; "" when the string names no unit.
|
|
31
|
+
const saidUnit = (v: unknown, name: string, sides: string[]) => {
|
|
32
|
+
const n = typeof v === "string" && lengths(v); if (!n) return "";
|
|
33
|
+
const said = expand(n.map((_, i) => i)).map((i, s) => [sides[s].toLowerCase(), n[i][0] ? n[i][1] : ""]).filter(([, u]) => u);
|
|
34
|
+
if (new Set(said.map(([, u]) => u)).size < 2) return said.length ? said[0][1] : "";
|
|
35
|
+
console.warn(`[tweaks] sides "${name}": "${v}" mixes units (${said.map((s) => s.join(" ")).join(", ")}) and the control edits one, so all four read as px; write one unit, or set unit`);
|
|
36
|
+
return "";
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function createSides(meta: Meta, onChange: OnChange): Control {
|
|
40
|
+
const corners = !!meta.corners, names = corners ? ["Top-left", "Top-right", "Bottom-right", "Bottom-left"] : ["Top", "Right", "Bottom", "Left"];
|
|
41
|
+
const name = meta.label || meta.key, init = meta.value ?? meta;
|
|
42
|
+
const unit = (typeof meta.unit === "string" && meta.unit.trim()) || saidUnit(init, name, names) || "px"; // an explicit unit wins over the string's
|
|
43
|
+
let v = read(init, [0, 0, 0, 0]);
|
|
44
|
+
// No step: 1, or finer when a side has decimals, so "0.5rem 1.25rem" keeps its quarter rem (decimalStep, as the number's).
|
|
45
|
+
const spec = { step: meta.step ?? Math.min(1, ...(v || []).map(decimalStep)), min: meta.min ?? (corners ? 0 : undefined), max: meta.max, unit }; // a radius can't go negative; a margin can
|
|
46
|
+
const fmt = (n: number) => String(+n.toFixed(Math.max(2, stepPrecision(+spec.step)))); // the readout and css at the step's decimals, so 0.125rem isn't 0.13rem
|
|
47
|
+
// A default it can't read still starts at 0, but says so: a padding that quietly lost its value looks like the page's bug.
|
|
48
|
+
// An object naming no side stays quiet, as markup hands its whole dataset over when there's no data-value.
|
|
49
|
+
if (!v && meta.value != null && (typeof meta.value !== "object" || Array.isArray(meta.value))) console.warn(`[tweaks] sides "${name}": can't read ${JSON.stringify(meta.value)?.slice(0, 60)}, so it starts at 0; it takes one to four numbers or plain lengths, like "8px 16px"`);
|
|
50
|
+
v = v || [0, 0, 0, 0];
|
|
51
|
+
let linked = v.every((x) => x === v[0]);
|
|
52
|
+
const root = el("div", "tw-sides");
|
|
53
|
+
const all = numField({ label: meta.label, value: v[0], row: true, ...spec }, (n) => { v = [n, n, n, n]; update(); emit(); });
|
|
54
|
+
// One toggle, moved to whichever block is showing; last in both, so it sits on the same pixels either way. It says what
|
|
55
|
+
// a click opens — each corner or side on its own — and is pressed while they are.
|
|
56
|
+
const split = corners ? "Independent corners" : "Independent sides";
|
|
57
|
+
const link = btn("tw-sides-link", ICON_SPLIT); link.setAttribute("aria-label", split); link.title = split; link.addEventListener("click", () => toggle());
|
|
58
|
+
// The head has the row's geometry, so opening moves nothing: the readout takes the field's place, with the unit once.
|
|
59
|
+
const multi = el("div", "tw-sides-multi"), head = el("div", "tw-sides-head"), sum = el("span", "tw-sides-sum"), nums = document.createTextNode("");
|
|
60
|
+
sum.append(nums, txt("span", "tw-sides-unit", unit)); head.append(txt("span", "tw-row-label", meta.label), sum);
|
|
61
|
+
const fields = el("div", "tw-fields");
|
|
62
|
+
const flds: NumField[] = names.map((n, i) => numField({ label: n, value: v[i], ...spec }, (x) => { v[i] = x; update(); emit(); }));
|
|
63
|
+
// The input and the tooltip carry the full name; on screen each field wears its glyph, one drawing turned per place.
|
|
64
|
+
flds.forEach((f, i) => { const lab = f.el.querySelector<HTMLElement>(".tw-field-label")!; lab.title = names[i]; lab.classList.add("tw-sides-glyph"); lab.dataset.at = names[i].toLowerCase(); lab.innerHTML = corners ? ICON_CORNER : ICON_EDGE; fields.append(f.el); });
|
|
65
|
+
multi.append(head, fields); root.append(all.el, multi);
|
|
66
|
+
// As the fields fitted them.
|
|
67
|
+
v = flds.map((f) => f.get());
|
|
68
|
+
|
|
69
|
+
const css = () => { const [t, r, b, l] = v.map(fmt), u = (s: string) => (s === "0" ? "0" : s + unit); return (t === b && r === l ? (t === r ? [t] : [t, r]) : r === l ? [t, r, b] : [t, r, b, l]).map(u).join(" "); };
|
|
70
|
+
const value = (): SidesValue => ({ top: v[0], right: v[1], bottom: v[2], left: v[3], css: css() });
|
|
71
|
+
const emit = () => onChange(value());
|
|
72
|
+
const update = () => {
|
|
73
|
+
root.classList.toggle("is-linked", linked); link.setAttribute("aria-pressed", String(!linked));
|
|
74
|
+
(linked ? all.el : head).append(link);
|
|
75
|
+
all.set(v[0]); flds.forEach((f, i) => f.set(v[i]));
|
|
76
|
+
nums.data = v.map(fmt).join(" ");
|
|
77
|
+
};
|
|
78
|
+
const toggle = () => { linked = !linked; if (linked) v = [v[0], v[0], v[0], v[0]]; update(); emit(); link.focus(); };
|
|
79
|
+
update();
|
|
80
|
+
|
|
81
|
+
// Set/restore: equal sides re-link, unequal ones unlink, so a restored "8 16 8 16" opens the fields it needs.
|
|
82
|
+
const set = (x: unknown) => { const n = read(x, v); if (!n) return; flds.forEach((f, i) => f.set(n[i])); v = flds.map((f) => f.get()); linked = v.every((s) => s === v[0]); update(); };
|
|
83
|
+
return { el: root, set, get: value, range: all.range };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
registerControl("sides", createSides);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// ── Spring config — physics (stiffness/damping/mass) or a perceptual time (duration/bounce) mode, over one settle-curve preview. Lazy. ──
|
|
2
|
+
import { el, txt, onReady, onLive, clamp, createSegmented, registerControl } from "../shared.js";
|
|
3
|
+
import { dragGesture, numField, cssVar, accentColor, springCurve, springFromTime, boxFrac, notice, SPRING_DUR } from "../heavy.js";
|
|
4
|
+
import type { OnChange, NumField } from "../shared.js";
|
|
5
|
+
import type { Meta } from "../schema.js";
|
|
6
|
+
import type { Control } from "../types.js";
|
|
7
|
+
|
|
8
|
+
type SpringMode = "time" | "physics";
|
|
9
|
+
/** The physics triple every emitted value carries. */
|
|
10
|
+
interface Phys { stiffness: number; damping: number; mass: number }
|
|
11
|
+
type PhysKey = keyof Phys;
|
|
12
|
+
/** The perceptual pair the time mode edits. */
|
|
13
|
+
interface SpringTime { visualDuration: number; bounce: number }
|
|
14
|
+
/** What set() accepts: any subset of the physics, the time pair and an explicit mode. */
|
|
15
|
+
type SpringValue = Partial<Phys & SpringTime & { mode: SpringMode }>;
|
|
16
|
+
|
|
17
|
+
function createSpring(meta: Meta, onChange: OnChange): Control {
|
|
18
|
+
// The fields' floors, non-finite to the defaults: a stiffness or mass of 0 would divide the settle curve to NaN.
|
|
19
|
+
const cl = (v: unknown, lo: number, def: number) => (Number.isFinite(+v) ? Math.max(lo, +v) : def);
|
|
20
|
+
const clampS = (o: { stiffness?: unknown; damping?: unknown; mass?: unknown }): Phys => ({ stiffness: cl(o.stiffness, 1, 300), damping: cl(o.damping, 0.5, 26), mass: cl(o.mass, 0.1, 1) });
|
|
21
|
+
const has = (x: unknown) => Number.isFinite(+x);
|
|
22
|
+
|
|
23
|
+
// Time mode edits visualDuration + bounce (Motion's spring shorthand), mapped onto the physics the preview and emit share
|
|
24
|
+
// exactly as Motion resolves them (springFromTime): stiffness = (2π / (visualDuration × 1.2))², ζ from bounce floored at
|
|
25
|
+
// 0.05, a negative bounce overdamped. Not through the physics pad's clamps, which would cut a short spring's stiffness.
|
|
26
|
+
// An authored duration past the editor's range is clamped, and the console says so once.
|
|
27
|
+
const [DUR_MIN, DUR_MAX] = SPRING_DUR;
|
|
28
|
+
let said = false;
|
|
29
|
+
const clampDur = (v: unknown) => {
|
|
30
|
+
const n = has(v) ? +v : 0.5, c = clamp(n, DUR_MIN, DUR_MAX);
|
|
31
|
+
if (c !== n && !said) { said = true; notice("springDur", [meta.key, n, c]); }
|
|
32
|
+
return c;
|
|
33
|
+
};
|
|
34
|
+
const clampBounce = (v: unknown) => clamp(has(v) ? +v : 0.2, -1, 1); // Motion takes a negative bounce: overdamped
|
|
35
|
+
// Ten significant figures: the trig round trip otherwise hands back 300.00000000000006 for a stiffness of 300.
|
|
36
|
+
const sig = (x: number) => +x.toPrecision(10);
|
|
37
|
+
const timeToPhysics = (t: SpringTime): Phys => { const p = springFromTime(t.visualDuration, t.bounce); return { stiffness: sig(p.stiffness), damping: sig(p.damping), mass: p.mass }; };
|
|
38
|
+
|
|
39
|
+
// Top-level props (the inline shorthand) or the `value:{…}` form, value winning; the mode is explicit or inferred, time winning.
|
|
40
|
+
const explicit = (m: unknown): SpringMode | null => (m === "time" || m === "physics" ? m : null);
|
|
41
|
+
const init = { stiffness: meta.stiffness, damping: meta.damping, mass: meta.mass, visualDuration: meta.visualDuration, bounce: meta.bounce, ...(meta.value || {}) };
|
|
42
|
+
let mode: SpringMode = explicit(init.mode) ?? explicit(meta.mode) ?? ((has(init.visualDuration) || has(init.bounce)) ? "time" : "physics");
|
|
43
|
+
const time: SpringTime = { visualDuration: clampDur(init.visualDuration), bounce: clampBounce(init.bounce) };
|
|
44
|
+
let phys = clampS({ stiffness: init.stiffness, damping: init.damping, mass: init.mass });
|
|
45
|
+
|
|
46
|
+
const root = el("div", "tw-spring");
|
|
47
|
+
const viz = el("div", "tw-spring-viz");
|
|
48
|
+
const canvas = document.createElement("canvas"); canvas.className = "tw-spring-canvas"; viz.append(canvas);
|
|
49
|
+
const physFields = el("div", "tw-fields");
|
|
50
|
+
const timeFields = el("div", "tw-fields");
|
|
51
|
+
const ctx = canvas.getContext("2d");
|
|
52
|
+
|
|
53
|
+
// The preview and the emitted value both read this, so they can never disagree.
|
|
54
|
+
const resolved = () => (mode === "time" ? timeToPhysics(time) : clampS(phys));
|
|
55
|
+
const draw = () => {
|
|
56
|
+
// Layout size: getBoundingClientRect includes the pop's entrance scale.
|
|
57
|
+
const w = Math.max(1, viz.clientWidth), h = Math.max(1, viz.clientHeight); if (w < 2) return;
|
|
58
|
+
const dpr = window.devicePixelRatio || 1; canvas.width = w * dpr; canvas.height = h * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
59
|
+
ctx.clearRect(0, 0, w, h);
|
|
60
|
+
const s = resolved();
|
|
61
|
+
// Sample ~one point per device pixel so fast oscillations stay smooth, not angular.
|
|
62
|
+
const pts = springCurve(s.stiffness, s.damping, s.mass, Math.max(160, Math.round(w * dpr)));
|
|
63
|
+
const peak = Math.max(1.05, ...pts), pad = 8; // the bezier editor's PAD, so the two previews inset alike
|
|
64
|
+
const X = (i: number) => pad + (i / (pts.length - 1)) * (w - 2 * pad);
|
|
65
|
+
const Y = (v: number) => h - pad - (v / peak) * (h - 2 * pad);
|
|
66
|
+
ctx.strokeStyle = cssVar(root, "--tw-surface-active") || "rgba(255,255,255,0.1)"; ctx.lineWidth = 1;
|
|
67
|
+
ctx.beginPath(); ctx.moveTo(pad, Y(1)); ctx.lineTo(w - pad, Y(1)); ctx.stroke(); // target (=1)
|
|
68
|
+
ctx.strokeStyle = accentColor(root); ctx.lineWidth = 1.5; ctx.lineJoin = ctx.lineCap = "round";
|
|
69
|
+
ctx.beginPath(); pts.forEach((v, i) => (i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v)))); ctx.stroke();
|
|
70
|
+
};
|
|
71
|
+
// The resolved {k,d,m} plus, in time mode, visualDuration/bounce, from which set() infers the mode back from persistence.
|
|
72
|
+
const resolvedValue = (): Phys & Partial<SpringTime> => (mode === "time" ? { ...resolved(), visualDuration: time.visualDuration, bounce: time.bounce } : resolved());
|
|
73
|
+
const emit = () => onChange(resolvedValue());
|
|
74
|
+
|
|
75
|
+
const flds: Partial<Record<PhysKey, NumField>> = {};
|
|
76
|
+
([["stiffness", "Stiffness", 1], ["damping", "Damping", 0.5], ["mass", "Mass", 0.1]] as const).forEach(([key, lab, step]) => {
|
|
77
|
+
flds[key] = numField({ label: lab, value: phys[key], step, min: step }, (v) => { phys[key] = v; draw(); emit(); });
|
|
78
|
+
physFields.append(flds[key].el);
|
|
79
|
+
});
|
|
80
|
+
// The field shows ms; the value stays Motion's visualDuration in seconds.
|
|
81
|
+
const durFld = numField({ label: "Duration", value: time.visualDuration * 1000, step: 10, min: DUR_MIN * 1000, max: DUR_MAX * 1000, unit: "ms" }, (v) => { time.visualDuration = clampDur(v / 1000); draw(); emit(); });
|
|
82
|
+
const bounceFld = numField({ label: "Bounce", value: time.bounce * 100, step: 5, min: -100, max: 100, unit: "%" }, (v) => { time.bounce = clampBounce(v / 100); draw(); emit(); }); // shown as a percentage; the value stays Motion's −1–1
|
|
83
|
+
timeFields.append(durFld.el, bounceFld.el);
|
|
84
|
+
|
|
85
|
+
const showMode = () => { timeFields.style.display = mode === "time" ? "" : "none"; physFields.style.display = mode === "physics" ? "" : "none"; };
|
|
86
|
+
const switchMode = (m: unknown) => { if ((m !== "time" && m !== "physics") || m === mode) return; mode = m; showMode(); draw(); emit(); };
|
|
87
|
+
const modeToggle = createSegmented([{ value: "time", label: "Time" }, { value: "physics", label: "Physics" }], mode, switchMode, "Spring mode");
|
|
88
|
+
const modeRow = el("div", "tw-row");
|
|
89
|
+
modeRow.append(txt("span", "tw-row-label", "Mode"), modeToggle.el);
|
|
90
|
+
// A caption, so two springs in one panel can be told apart and the filter matches shown text.
|
|
91
|
+
if (meta.label) root.append(txt("div", "tw-spring-label", meta.label));
|
|
92
|
+
root.append(viz, modeRow, physFields, timeFields);
|
|
93
|
+
|
|
94
|
+
// Drag the preview to tune by feel: horizontal sets stiffness or duration, vertical sets damping or bounce.
|
|
95
|
+
const ST_MIN = 1, ST_MAX = 500, DA_MIN = 1, DA_MAX = 40, DRAG_DUR_MIN = 0.1, DRAG_DUR_MAX = 1; // the feel-tuning span; the fields reach further
|
|
96
|
+
const fromPointer = (e: PointerEvent) => {
|
|
97
|
+
const [px, py] = boxFrac(e, viz);
|
|
98
|
+
// Set the field (it snaps and clamps), then read back, so readout, value and preview agree.
|
|
99
|
+
if (mode === "time") {
|
|
100
|
+
durFld.set((DRAG_DUR_MIN + px * (DRAG_DUR_MAX - DRAG_DUR_MIN)) * 1000); time.visualDuration = durFld.get() / 1000;
|
|
101
|
+
bounceFld.set((1 - 2 * py) * 100); time.bounce = bounceFld.get() / 100; // top = bounce 100%, the middle 0, the bottom −100% (overdamped)
|
|
102
|
+
} else {
|
|
103
|
+
flds.stiffness.set(ST_MIN + px * (ST_MAX - ST_MIN)); phys.stiffness = flds.stiffness.get();
|
|
104
|
+
flds.damping.set(DA_MIN + py * (DA_MAX - DA_MIN)); phys.damping = flds.damping.get(); // top = low damping
|
|
105
|
+
}
|
|
106
|
+
draw(); emit();
|
|
107
|
+
};
|
|
108
|
+
dragGesture(viz, {
|
|
109
|
+
onDown: (e) => { e.preventDefault(); viz.classList.add("is-dragging"); fromPointer(e); },
|
|
110
|
+
onMove: fromPointer,
|
|
111
|
+
onEnd: () => viz.classList.remove("is-dragging"),
|
|
112
|
+
});
|
|
113
|
+
showMode();
|
|
114
|
+
onReady(draw);
|
|
115
|
+
// The canvas reads theme tokens at draw time: redraw on resize, on a tab page revealing it (tw-reflow; it built at
|
|
116
|
+
// 0×0 while hidden), on an OS scheme flip and on a host re-theme — CSS re-themes the SVG controls, a canvas keeps a stale color.
|
|
117
|
+
onLive(canvas, [[window, "resize"], [window, "tw-reflow"], [matchMedia("(prefers-color-scheme: dark)"), "change"], [window, "tw-retheme"]], draw);
|
|
118
|
+
// A host flipping [data-tw-scheme] re-themes through CSS alone; the canvas needs a redraw. Let go once mounted and removed.
|
|
119
|
+
let schemeObs: MutationObserver | null = null, wasMounted = false;
|
|
120
|
+
if (typeof MutationObserver === "function") {
|
|
121
|
+
schemeObs = new MutationObserver(() => {
|
|
122
|
+
if (canvas.isConnected) { wasMounted = true; draw(); }
|
|
123
|
+
else if (wasMounted) { schemeObs.disconnect(); schemeObs = null; }
|
|
124
|
+
});
|
|
125
|
+
schemeObs.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ["data-tw-scheme"] });
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
el: root,
|
|
129
|
+
destroy: () => { if (schemeObs) { schemeObs.disconnect(); schemeObs = null; } },
|
|
130
|
+
// Silent set/restore: infer the mode from the keys, update caches and fields, redraw without emitting.
|
|
131
|
+
set: (v: SpringValue) => {
|
|
132
|
+
if (!v || typeof v !== "object") return;
|
|
133
|
+
const ex = explicit(v.mode), hasTime = has(v.visualDuration) || has(v.bounce), hasPhys = has(v.stiffness) || has(v.damping) || has(v.mass);
|
|
134
|
+
if (!hasTime && !hasPhys && !ex) return;
|
|
135
|
+
mode = ex ?? (hasTime ? "time" : "physics"); // an explicit mode wins; else time, the authoring mode
|
|
136
|
+
if (hasTime) { if (has(v.visualDuration)) time.visualDuration = clampDur(v.visualDuration); if (has(v.bounce)) time.bounce = clampBounce(v.bounce); }
|
|
137
|
+
// Restore the physics cache even alongside time keys (reset's default carries both), so a later toggle to Physics shows the intended spring.
|
|
138
|
+
if (hasPhys) phys = clampS({ ...phys, ...v });
|
|
139
|
+
modeToggle.set(mode);
|
|
140
|
+
durFld.set(time.visualDuration * 1000); bounceFld.set(time.bounce * 100);
|
|
141
|
+
flds.stiffness.set(phys.stiffness); flds.damping.set(phys.damping); flds.mass.set(phys.mass);
|
|
142
|
+
showMode(); draw();
|
|
143
|
+
},
|
|
144
|
+
get: () => resolvedValue(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
registerControl("spring", createSpring);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ── Tabs — group controls into pages; build() fills the page bodies. Lazy.
|
|
2
|
+
import { el, txt, onReady, measurePill, navIndex, requestReflow, registerControl } from "../shared.js";
|
|
3
|
+
import type { Meta } from "../schema.js";
|
|
4
|
+
|
|
5
|
+
/** The tabs handle: a page body per tab for build() to fill, and the active tab for the panel's UI state. */
|
|
6
|
+
export interface TabsControl { el: HTMLDivElement; bodies: HTMLDivElement[]; activate(i: number): void; active(): number }
|
|
7
|
+
|
|
8
|
+
let tabsSeq = 0; // unique ids for the tab ↔ tabpanel aria pairing
|
|
9
|
+
function createTabs(meta: Meta): TabsControl {
|
|
10
|
+
const uid = `tw-tabs-${++tabsSeq}`;
|
|
11
|
+
const root = el("div", "tw-tabs");
|
|
12
|
+
const bar = el("div", "tw-tabs-bar"); bar.setAttribute("role", "tablist");
|
|
13
|
+
const pill = el("div", "tw-tabs-pill"); bar.append(pill);
|
|
14
|
+
const pagesWrap = el("div", "tw-tabs-pages");
|
|
15
|
+
const bodies: HTMLDivElement[] = [];
|
|
16
|
+
const tabs = meta.pages.map((page, i) => {
|
|
17
|
+
const tab = txt("button", "tw-tabs-tab", page.title); tab.setAttribute("role", "tab");
|
|
18
|
+
tab.id = `${uid}-tab-${i}`; tab.setAttribute("aria-controls", `${uid}-page-${i}`);
|
|
19
|
+
const body = el("div", "tw-tabs-page tw-controls");
|
|
20
|
+
body.setAttribute("role", "tabpanel"); body.id = `${uid}-page-${i}`; body.setAttribute("aria-labelledby", tab.id);
|
|
21
|
+
bodies.push(body); pagesWrap.append(body);
|
|
22
|
+
tab.addEventListener("click", () => activate(i));
|
|
23
|
+
bar.append(tab); return tab;
|
|
24
|
+
});
|
|
25
|
+
root.append(bar, pagesWrap);
|
|
26
|
+
const measure = (animate?: boolean) => measurePill(bar, pill, animate); // slide the pill to the active tab
|
|
27
|
+
// The active tab and page; the roving tabindex is set from the build, not only after a switch.
|
|
28
|
+
const mark = (i: number) => {
|
|
29
|
+
tabs.forEach((b, k) => { b.dataset.active = String(k === i); b.setAttribute("aria-selected", String(k === i)); b.tabIndex = k === i ? 0 : -1; });
|
|
30
|
+
bodies.forEach((b, k) => (b.dataset.active = String(k === i)));
|
|
31
|
+
};
|
|
32
|
+
mark(0);
|
|
33
|
+
function activate(i: number) {
|
|
34
|
+
mark(i); measure(true);
|
|
35
|
+
requestReflow(); // controls built on a display:none page measured 0; let them re-measure
|
|
36
|
+
}
|
|
37
|
+
bar.addEventListener("keydown", (e) => {
|
|
38
|
+
const i = tabs.findIndex((b) => b.dataset.active === "true"); if (i < 0) return;
|
|
39
|
+
const j = navIndex(e.key, i, tabs.length, -1); if (j < 0) return; // cols −1: ↑/↓ stay the page's scroll keys
|
|
40
|
+
e.preventDefault(); activate(j); tabs[j].focus();
|
|
41
|
+
});
|
|
42
|
+
onReady(measure);
|
|
43
|
+
return { el: root, bodies, activate, active: () => tabs.findIndex((t) => t.dataset.active === "true") };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
registerControl("tabs", createTabs);
|
|
47
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* Tweaks — a dependency-free real-time parameter panel. The public entry: tweaks() builds a
|
|
2
|
+
* live panel from a plain schema object, enhance() turns [data-tw] markup into live controls,
|
|
3
|
+
* and mountControl() / createColorPicker() / gradientCss() are the controls without the panel. */
|
|
4
|
+
export { tweaks } from "./panel.js";
|
|
5
|
+
export { enhance } from "./enhance.js";
|
|
6
|
+
export { mountControl, createColorPicker } from "./standalone.js";
|
|
7
|
+
export { gradientCss, gradientStops } from "./easing.js";
|
|
8
|
+
// The public types, so `import type { Schema } from "tweakit"` works from either entry.
|
|
9
|
+
export type * from "./types.js";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* Gradient easing + gradient → CSS: the one place a gradient value becomes a stop list, so
|
|
2
|
+
* the editor draws what a host's CSS draws. Pure string + number work, no DOM.
|
|
3
|
+
* The eye reads a straight blend's ends as an edge and native easing hasn't shipped
|
|
4
|
+
* (csswg-drafts#1332), so Larsen's "easing gradients" are generated here: the curve sampled
|
|
5
|
+
* PARAMETRICALLY at 16 even t (x the position, y the blend), so any cubic-bezier works with
|
|
6
|
+
* no x→t solve; each sample is a color-mix in the value's own blend space. */
|
|
7
|
+
import type { GradientEasing, GradientStop, GradientValue } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/** cubic-bezier control points [x1, y1, x2, y2]. */
|
|
10
|
+
export type Bez = [number, number, number, number];
|
|
11
|
+
|
|
12
|
+
// The CSS easing keywords as cubic-beziers. Null-prototype, so "constructor" can't read a function off Object.prototype.
|
|
13
|
+
export const KEYWORDS: Record<string, Bez> = Object.assign(Object.create(null), {
|
|
14
|
+
ease: [0.25, 0.1, 0.25, 1], "ease-in": [0.42, 0, 1, 1], "ease-out": [0, 0, 0.58, 1], "ease-in-out": [0.42, 0, 0.58, 1],
|
|
15
|
+
});
|
|
16
|
+
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
|
|
17
|
+
const SAMPLES = 15; // 16 per segment, Larsen's count; the joins vanish
|
|
18
|
+
|
|
19
|
+
/** Bezier control points, or null for linear and anything unrecognized (bad input degrades to a
|
|
20
|
+
* straight blend). x1/x2 clamp into [0,1] as CSS requires; y may overshoot. */
|
|
21
|
+
export function parseEasing(e: unknown): Bez | null {
|
|
22
|
+
if (typeof e !== "string") return null;
|
|
23
|
+
const s = e.trim().toLowerCase();
|
|
24
|
+
if (KEYWORDS[s]) return KEYWORDS[s];
|
|
25
|
+
const m = /^cubic-bezier\(([^()]*)\)$/.exec(s); if (!m) return null;
|
|
26
|
+
const n = m[1].split(",").map((x) => (x.trim() ? Number(x) : NaN)); // an empty field is NaN, not Number("") → 0
|
|
27
|
+
if (n.length !== 4 || !n.every(Number.isFinite)) return null;
|
|
28
|
+
return [clamp01(n[0]), n[1], clamp01(n[2]), n[3]];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The curve at 16 even t: [x, y] pairs, y clamped to [0,1] since it becomes a color-mix percentage. */
|
|
32
|
+
export function easingSamples(b: Bez): Array<[number, number]> {
|
|
33
|
+
const out: Array<[number, number]> = [];
|
|
34
|
+
for (let k = 0; k <= SAMPLES; k++) {
|
|
35
|
+
const t = k / SAMPLES, u = 1 - t, p1 = 3 * u * u * t, p2 = 3 * u * t * t, p3 = t * t * t;
|
|
36
|
+
out.push([p1 * b[0] + p2 * b[2] + p3, clamp01(p1 * b[1] + p2 * b[3] + p3)]);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pct = (n: number) => +(n * 100).toFixed(2);
|
|
42
|
+
|
|
43
|
+
// A stop's color is written into the gradient as it is, so it must be one color token: a comma or a stray ")" splices in
|
|
44
|
+
// another layer (a url() the browser fetches), and var() can stand for anything. DOM-free, so by shape: a hex, a keyword,
|
|
45
|
+
// or one function with no nested parens; the editor's picker checks the grammar itself.
|
|
46
|
+
const STOP_COLOR = /^(?:#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(?!(?:url|var)\()[a-z][a-z-]*(?:\([\w\s.,%+\-\/]*\))?)$/i;
|
|
47
|
+
// The blend spaces CSS takes, and a hue method on the polar ones; anything else (`in junk`, `in 5`) falls to
|
|
48
|
+
// oklch, the default.
|
|
49
|
+
const INTERP = /^(?:(?:hsl|hwb|lch|oklch)(?: (?:shorter|longer|increasing|decreasing) hue)?|srgb(?:-linear)?|oklab|lab|display-p3|rec2020|a98-rgb|prophoto-rgb|xyz(?:-d50|-d65)?)$/i;
|
|
50
|
+
const blend = (v: GradientValue) => (typeof v.interpolation === "string" && INTERP.test(v.interpolation) ? v.interpolation : "oklch");
|
|
51
|
+
|
|
52
|
+
/** The stops a value can draw, sorted. A hand-built value can hold no stops, or stops without a
|
|
53
|
+
* color or a numeric pos, and any of those builds CSS the browser drops whole, silently;
|
|
54
|
+
* so they're set aside with a warning (heavy.ts badGradient, one per distinct fault). */
|
|
55
|
+
function drawable(value: unknown, fn: string): GradientStop[] {
|
|
56
|
+
const all: unknown = value && typeof value === "object" ? (value as GradientValue).stops : null;
|
|
57
|
+
const ok = Array.isArray(all) ? all.filter((s) => s && typeof s.color === "string" && STOP_COLOR.test(s.color.trim()) && Number.isFinite(s.pos)) : [];
|
|
58
|
+
if (!ok.length || ok.length < (all as unknown[]).length) import("./heavy.js").then((m) => m.badGradient(fn, value, all, ok.length), () => {});
|
|
59
|
+
return ok.sort((a, b) => a.pos - b.pos);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A gradient value's `<color-stop-list>` with the easing expanded; for a conic / radial gradient.
|
|
63
|
+
* One stop comes back doubled and none as `transparent, transparent`, so the list stays valid. */
|
|
64
|
+
export function gradientStops(value: GradientValue): string {
|
|
65
|
+
const stops = drawable(value, "gradientStops");
|
|
66
|
+
return stops.length ? stopList(value, stops) : "transparent, transparent";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stopList(value: GradientValue, stops: GradientStop[]): string {
|
|
70
|
+
const plain = (s: GradientStop) => `${s.color} ${pct(s.pos)}%`;
|
|
71
|
+
if (stops.length < 2) return `${stops[0].color}, ${stops[0].color}`; // one stop isn't a stop list; doubled, it's a solid fill
|
|
72
|
+
const bez = parseEasing(value.easing);
|
|
73
|
+
if (!bez) return stops.map(plain).join(", ");
|
|
74
|
+
const samples = easingSamples(bez), space = blend(value);
|
|
75
|
+
const out = [plain(stops[0])];
|
|
76
|
+
for (let i = 1; i < stops.length; i++) {
|
|
77
|
+
const a = stops[i - 1], b = stops[i], span = b.pos - a.pos;
|
|
78
|
+
// Two stops at one position are a hard edge: no samples between.
|
|
79
|
+
if (span > 1e-6) for (let k = 1; k < SAMPLES; k++) {
|
|
80
|
+
const [x, y] = samples[k];
|
|
81
|
+
out.push(`color-mix(in ${space}, ${a.color}, ${b.color} ${pct(y)}%) ${pct(a.pos + span * x)}%`);
|
|
82
|
+
}
|
|
83
|
+
out.push(plain(b));
|
|
84
|
+
}
|
|
85
|
+
return out.join(", ");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The `linear-gradient(…)` for a gradient value; `direction` is degrees or any CSS direction.
|
|
89
|
+
* One stop draws a solid fill and no stops is `none`, both still valid for background-image. */
|
|
90
|
+
export function gradientCss(value: GradientValue, direction: number | string = "to right"): string {
|
|
91
|
+
const stops = drawable(value, "gradientCss");
|
|
92
|
+
return stops.length ? `linear-gradient(in ${blend(value)} ${typeof direction === "number" ? `${direction}deg` : direction}, ${stopList(value, stops)})` : "none";
|
|
93
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* Markup-driven enhancement, core's side: enhance(). The work lives in markup.ts, a lazy chunk of the code-split build that
|
|
2
|
+
* only a page with markup fetches; core lends it what it shares with the panel, as a kit. Importing core binds nothing:
|
|
3
|
+
* the page scan on load is opt-in, in auto.ts (tweakit/auto), so the package can declare core free of side effects. */
|
|
4
|
+
import { getMarkup } from "./shared.js";
|
|
5
|
+
import { metaFor } from "./schema.js";
|
|
6
|
+
import { ensureForMetas, LAZY_IMPORT } from "./lazy.js";
|
|
7
|
+
import { createFolder, createControl } from "./controls/basic.js";
|
|
8
|
+
import { makeCopyBtn, makeResetBtn, addHintMarker } from "./feedback.js";
|
|
9
|
+
import type { MarkupKit } from "./markup.js";
|
|
10
|
+
|
|
11
|
+
declare const TW_SPLIT: boolean;
|
|
12
|
+
const kit: MarkupKit = { metaFor, ensureForMetas, LAZY_IMPORT, createFolder, createControl, makeCopyBtn, makeResetBtn, addHintMarker };
|
|
13
|
+
|
|
14
|
+
/** Bind every unbound [data-tw] host under `root` (the document by default). */
|
|
15
|
+
export function enhance(root: Document | Element = document): Promise<void> {
|
|
16
|
+
const run = getMarkup<MarkupKit>();
|
|
17
|
+
if (run || !TW_SPLIT) return run ? run(root, kit) : Promise.resolve(); // the single-file build registered it on load: synchronous, as ever
|
|
18
|
+
// A chunk that fails to load skips the markup, as a failed control chunk skips its control, instead of rejecting.
|
|
19
|
+
return import("./markup.js").then(() => getMarkup<MarkupKit>()!(root, kit), (e) => console.error("[tweaks] markup chunk failed to load:", e));
|
|
20
|
+
}
|