tweakit 0.5.2 → 0.7.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.
Files changed (120) hide show
  1. package/AGENTS.md +32 -30
  2. package/README.md +55 -29
  3. package/THIRD-PARTY-NOTICES.md +0 -1
  4. package/dist/tweaks/auto.js +2 -0
  5. package/dist/tweaks/auto.js.map +6 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  7. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  9. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  11. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  13. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  14. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  15. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  16. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  17. package/dist/tweaks/color-UGYMAF3W.js.map +6 -0
  18. package/dist/tweaks/core.js +2 -1
  19. package/dist/tweaks/core.js.map +6 -0
  20. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  21. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  22. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  23. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  24. package/dist/tweaks/heavy-NPFIGHRM.js +2 -0
  25. package/dist/tweaks/heavy-NPFIGHRM.js.map +6 -0
  26. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  27. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  28. package/dist/tweaks/image-store-QD5SMZXG.js +2 -0
  29. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  30. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  31. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  32. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  33. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  34. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  35. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  36. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  37. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  38. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  39. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  40. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  41. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  42. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  43. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  44. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  45. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  46. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  47. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  48. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  49. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  50. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  51. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  52. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  53. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  54. package/dist/tweaks.css +1 -1
  55. package/dist/tweaks.js +7 -3
  56. package/dist/tweaks.js.map +6 -0
  57. package/dist/types/tweaks/easing.d.ts +8 -11
  58. package/dist/types/tweaks/enhance.d.ts +3 -2
  59. package/dist/types/tweaks/panel.d.ts +6 -4
  60. package/dist/types/tweaks/schema.d.ts +4 -8
  61. package/dist/types/tweaks/standalone.d.ts +3 -3
  62. package/dist/types/tweaks/types.d.ts +499 -116
  63. package/package.json +30 -5
  64. package/src/tweaks/auto.ts +16 -0
  65. package/src/tweaks/clip.ts +156 -0
  66. package/src/tweaks/controls/basic.ts +727 -0
  67. package/src/tweaks/controls/bezier.ts +204 -0
  68. package/src/tweaks/controls/color.ts +861 -0
  69. package/src/tweaks/controls/gradient.ts +419 -0
  70. package/src/tweaks/controls/image.ts +555 -0
  71. package/src/tweaks/controls/interval.ts +189 -0
  72. package/src/tweaks/controls/monitor.ts +211 -0
  73. package/src/tweaks/controls/motion.ts +316 -0
  74. package/src/tweaks/controls/plot.ts +440 -0
  75. package/src/tweaks/controls/point.ts +208 -0
  76. package/src/tweaks/controls/shadow.ts +156 -0
  77. package/src/tweaks/controls/sides.ts +196 -0
  78. package/src/tweaks/controls/spring.ts +308 -0
  79. package/src/tweaks/controls/tabs.ts +68 -0
  80. package/src/tweaks/core.ts +9 -0
  81. package/src/tweaks/easing.ts +118 -0
  82. package/src/tweaks/enhance.ts +35 -0
  83. package/src/tweaks/feedback.ts +218 -0
  84. package/src/tweaks/fold.ts +33 -0
  85. package/src/tweaks/heavy.ts +626 -0
  86. package/src/tweaks/icons.ts +26 -0
  87. package/src/tweaks/image-store.ts +189 -0
  88. package/src/tweaks/lazy.ts +63 -0
  89. package/src/tweaks/markup.ts +317 -0
  90. package/src/tweaks/panel.ts +1693 -0
  91. package/src/tweaks/presets.ts +85 -0
  92. package/src/tweaks/schema.ts +351 -0
  93. package/src/tweaks/shadow-root.ts +178 -0
  94. package/src/tweaks/shared.ts +1137 -0
  95. package/src/tweaks/single.ts +22 -0
  96. package/src/tweaks/standalone.ts +179 -0
  97. package/src/tweaks/types.ts +1027 -0
  98. package/src/tweaks/undo.ts +104 -0
  99. package/src/tweaks.css +965 -0
  100. package/src/wide-gamut.ts +658 -0
  101. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  102. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  103. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  104. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  105. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  106. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  107. package/dist/tweaks/fold-UGN7TXGX.js +0 -1
  108. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  109. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  110. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  111. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  112. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  113. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  114. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  115. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  116. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  117. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  118. package/dist/tweaks/sides-FH227Z54.js +0 -1
  119. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  120. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,156 @@
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, words } 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);
20
+ if (!m || !isColor(m[0])) return null;
21
+ const rest = (s.slice(0, m.index) + " " + s.slice(m.index + m[0].length)).trim().split(/\s+/).filter(Boolean);
22
+ const inset = rest.includes("inset"),
23
+ lens = rest.filter((t) => t !== "inset");
24
+ if (lens.length < 2 || lens.length > 4 || !lens.every((t) => LEN_RE.test(t))) return null;
25
+ const n = lens.map((t) => +t.replace(/px$/, ""));
26
+ return { inset, x: n[0], y: n[1], blur: n[2] ?? 0, spread: n[3] ?? 0, color: m[0] };
27
+ };
28
+
29
+ // the kit's chevron; importing icons.ts splits a 4th chunk
30
+ const CHEVRON = icon('<path d="M18 9s-4.4 6-6 6-6-6-6-6"/>', "twk-shadow-chevron", 2.5);
31
+ function createShadow(meta: Meta, onChange: OnChange): Control {
32
+ const tr = words();
33
+ const read = (v: unknown): ShadowInput | null => (typeof v === "string" ? parseShadow(v) : isObj(v) ? v : null);
34
+ const init = read(meta.value ?? meta) || {}; // the fields may sit on the meta itself (verbose form)
35
+ let inset = !!init.inset;
36
+
37
+ // ── Row — label, chip, summary. ──
38
+ // ??: an explicit "" label renders none
39
+ const { root, trigger, right } = triggerRow("twk-shadow", meta.label ?? tr("shadow", "Shadow"));
40
+ const chip = el("span", "twk-trigger-chip twk-shadow-chip"),
41
+ chipTile = el("span", "twk-shadow-chip-tile");
42
+ chip.append(chipTile);
43
+ const valueEl = el("span", "twk-trigger-value");
44
+ right.append(chip, valueEl);
45
+
46
+ // ── Popover — a preview card, the position switch, the four lengths, the picker. ──
47
+ const pop = el("div", "twk-color-pop twk-shadow-pop");
48
+ const stage = el("div", "twk-shadow-stage"),
49
+ card = el("div", "twk-shadow-card");
50
+ stage.append(card);
51
+ const insetSeg = createSegmented(
52
+ [
53
+ { value: "outer", label: tr("outer", "Outer") },
54
+ { value: "inner", label: tr("inner", "Inner") },
55
+ ],
56
+ inset ? "inner" : "outer",
57
+ (m: unknown) => {
58
+ if ((m === "inner") !== inset) {
59
+ inset = m === "inner";
60
+ update();
61
+ emit();
62
+ }
63
+ },
64
+ tr("shadowPosition", "Shadow position"),
65
+ );
66
+ const insetRow = el("div", "twk-row");
67
+ insetRow.append(txt("span", "twk-row-label", tr("position", "Position")), insetSeg.el);
68
+ const fields = el("div", "twk-fields twk-shadow-fields");
69
+ // The four lengths live in their fields, read as the fields fit them: an authored 2.5 shows "3" and emits 3px, and
70
+ // the blur's min clamps a negative one to 0.
71
+ const field = (label: string, value: number, min?: number): NumField => {
72
+ const f = numField({ label, value, step: 1, min, unit: "px" }, () => {
73
+ update();
74
+ emit();
75
+ });
76
+ fields.append(f.el);
77
+ return f;
78
+ };
79
+ const fx = field("X", num(init.x, DEF.x)),
80
+ fy = field("Y", num(init.y, DEF.y)),
81
+ fb = field(tr("blur", "Blur"), num(init.blur, DEF.blur), 0),
82
+ fs = field(tr("spread", "Spread"), num(init.spread, DEF.spread));
83
+ const lengths = () => [fx, fy, fb, fs].map((f) => f.get());
84
+ // The color row discloses the picker, so the pop opens compact.
85
+ const colorRow = btn("twk-trigger twk-shadow-color");
86
+ colorRow.setAttribute("aria-expanded", "false");
87
+ const cSwatch = el("span", "twk-trigger-chip twk-color-swatch"),
88
+ cText = el("span", "twk-trigger-value"),
89
+ cRight = el("span", "twk-trigger-right");
90
+ cRight.append(cSwatch, cText);
91
+ cRight.insertAdjacentHTML("beforeend", CHEVRON);
92
+ colorRow.append(txt("span", "twk-trigger-label", tr("color", "Color")), cRight);
93
+ const body = createPickerBody(
94
+ { value: typeof init.color === "string" && isColor(init.color) ? init.color : DEF.color, onMode: () => update() },
95
+ () => {
96
+ update();
97
+ emit();
98
+ },
99
+ );
100
+ body.el.hidden = true;
101
+ // re-place for the new height; the picker lays out now
102
+ colorRow.addEventListener("click", () => {
103
+ const show = body.el.hidden;
104
+ body.el.hidden = !show;
105
+ colorRow.setAttribute("aria-expanded", String(show));
106
+ pop_.reflow();
107
+ });
108
+ pop.append(stage, insetRow, fields, colorRow, body.el);
109
+ root.append(pop);
110
+
111
+ const cssAt = (k: number) =>
112
+ `${inset ? "inset " : ""}${lengths()
113
+ .map((n) => `${fmt(n * k)}px`)
114
+ .join(" ")} ${body.get()}`;
115
+ const value = (): ShadowValue => {
116
+ const [x, y, blur, spread] = lengths().map((n) => +fmt(n));
117
+ return { inset, x, y, blur, spread, color: body.get(), css: cssAt(1) };
118
+ };
119
+ const emit = () => onChange(value());
120
+ const update = () => {
121
+ card.style.boxShadow = cssAt(1);
122
+ chipTile.style.boxShadow = cssAt(0.3);
123
+ const [x, y, blur, spread] = lengths();
124
+ valueEl.textContent = `${inset ? tr("inset", "in") + " " : ""}${fmt(x)} ${fmt(y)} ${fmt(blur)}${spread ? " " + fmt(spread) : ""}`;
125
+ cSwatch.style.background = body.swatchCss();
126
+ cText.textContent = body.valueText();
127
+ };
128
+ const pop_ = popover(root, trigger, pop, { fallbackH: 300, onOpen: body.reflow, onReflow: body.reflow });
129
+ update();
130
+
131
+ // Set/restore: the object form or a strict box-shadow string; `css` is ignored, and the color goes through the
132
+ // picker.
133
+ const set = (v: unknown) => {
134
+ const inp = read(v);
135
+ if (!inp) return;
136
+ if (inp.inset != null) {
137
+ inset = !!inp.inset;
138
+ insetSeg.set(inset ? "inner" : "outer");
139
+ }
140
+ (
141
+ [
142
+ ["x", fx],
143
+ ["y", fy],
144
+ ["blur", fb],
145
+ ["spread", fs],
146
+ ] as const
147
+ ).forEach(([k, f]) => {
148
+ if (inp[k] != null) f.set(num(inp[k], f.get()));
149
+ });
150
+ if (typeof inp.color === "string" && isColor(inp.color)) body.set(inp.color);
151
+ update();
152
+ };
153
+ return { el: root, set, get: value };
154
+ }
155
+
156
+ registerControl("shadow", createShadow);
@@ -0,0 +1,196 @@
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, words } 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
+ // original: the four corner arcs of a rounded box, apart
12
+ const ICON_SPLIT = icon(
13
+ '<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"/>',
14
+ );
15
+ // original: the top-left quarter of a rounded box, turned per corner in CSS
16
+ const ICON_CORNER = icon('<path d="M3 13V8a5 5 0 0 1 5-5h5"/>', "", 1.5, 16);
17
+ // original: the top of that box, two of its corners joined, turned per side
18
+ const ICON_EDGE = icon('<path d="M3 10V8a5 5 0 0 1 5-5a5 5 0 0 1 5 5v2"/>', "", 1.5, 16);
19
+ const LEN_RE = /^(-?\d*\.?\d+)([a-z%]*)$/i;
20
+ const num = (v: unknown) => (v != null && v !== "" && Number.isFinite(+v) ? +v : null);
21
+ const expand = (n: number[]) =>
22
+ n.length === 1
23
+ ? [n[0], n[0], n[0], n[0]]
24
+ : n.length === 2
25
+ ? [n[0], n[1], n[0], n[1]]
26
+ : n.length === 3
27
+ ? [n[0], n[1], n[2], n[1]]
28
+ : n.slice(0, 4);
29
+ // A shorthand string as one to four [number, unit] pairs, or null for anything else: calc(), var(), five lengths, junk.
30
+ const lengths = (s: string) => {
31
+ const m = s
32
+ .trim()
33
+ .split(/\s+/)
34
+ .map((t) => LEN_RE.exec(t));
35
+ return m.length <= 4 && m.every(Boolean)
36
+ ? (m as RegExpExecArray[]).map((x) => [+x[1], x[2].toLowerCase()] as const)
37
+ : null;
38
+ };
39
+ // Any form to four numbers, or null; an object fills in from `cur`, so `{ top: 4 }` moves one side.
40
+ const read = (v: unknown, cur: number[]): number[] | null => {
41
+ if (typeof v === "number") return Number.isFinite(v) ? [v, v, v, v] : null;
42
+ if (typeof v === "string") {
43
+ const n = lengths(v);
44
+ return n ? expand(n.map((x) => x[0])) : null;
45
+ }
46
+ if (Array.isArray(v)) {
47
+ const n = v.map(num);
48
+ return n.length >= 1 && n.length <= 4 && n.every((x) => x !== null) ? expand(n as number[]) : null;
49
+ }
50
+ if (v && typeof v === "object") {
51
+ const o = v as Record<string, unknown>,
52
+ out = cur.slice();
53
+ let any = false;
54
+ (["top", "right", "bottom", "left"] as const).forEach((k, i) => {
55
+ const x = num(o[k]);
56
+ if (x !== null) {
57
+ out[i] = x;
58
+ any = true;
59
+ }
60
+ });
61
+ return any ? out : null;
62
+ }
63
+ return null;
64
+ };
65
+
66
+ // The unit a shorthand string says, when its sides agree: "1rem 2rem" is rem, and so is "0 1rem 2". A zero doesn't
67
+ // vote, as 0 is 0 in any unit. Mixed units can't be kept, since the control edits four numbers in one unit, so they
68
+ // warn and read as px, as every string did before; "" when the string names no unit.
69
+ const saidUnit = (v: unknown, name: string, sides: string[]) => {
70
+ const n = typeof v === "string" && lengths(v);
71
+ if (!n) return "";
72
+ const said = expand(n.map((_, i) => i))
73
+ .map((i, s) => [sides[s].toLowerCase(), n[i][0] ? n[i][1] : ""])
74
+ .filter(([, u]) => u);
75
+ if (new Set(said.map(([, u]) => u)).size < 2) return said.length ? said[0][1] : "";
76
+ console.warn(
77
+ `[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`,
78
+ );
79
+ return "";
80
+ };
81
+
82
+ function createSides(meta: Meta, onChange: OnChange): Control {
83
+ const corners = !!meta.corners,
84
+ names = corners ? ["Top-left", "Top-right", "Bottom-right", "Bottom-left"] : ["Top", "Right", "Bottom", "Left"];
85
+ // topLeft …; `names` stays the glyph's data-at
86
+ const tr = words(),
87
+ shown = names.map((n) =>
88
+ tr(
89
+ n.toLowerCase().replace(/-(\w)/, (_, c) => c.toUpperCase()),
90
+ n,
91
+ ),
92
+ );
93
+ const name = meta.label || meta.key,
94
+ init = meta.value ?? meta;
95
+ // an explicit unit wins over the string's
96
+ const unit = (typeof meta.unit === "string" && meta.unit.trim()) || saidUnit(init, name, names) || "px";
97
+ let v = read(init, [0, 0, 0, 0]);
98
+ // No step: 1, or finer when a side has decimals, so "0.5rem 1.25rem" keeps its quarter rem (decimalStep, as the
99
+ // number's).
100
+ // a radius can't go negative; a margin can
101
+ const spec = {
102
+ step: meta.step ?? Math.min(1, ...(v || []).map(decimalStep)),
103
+ min: meta.min ?? (corners ? 0 : undefined),
104
+ max: meta.max,
105
+ unit,
106
+ };
107
+ // the readout and css at the step's decimals, so 0.125rem isn't 0.13rem
108
+ const fmt = (n: number) => String(+n.toFixed(Math.max(2, stepPrecision(+spec.step))));
109
+ // 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
110
+ // bug.
111
+ // An object naming no side stays quiet, as markup hands its whole dataset over when there's no data-value.
112
+ if (!v && meta.value != null && (typeof meta.value !== "object" || Array.isArray(meta.value)))
113
+ console.warn(
114
+ `[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"`,
115
+ );
116
+ v = v || [0, 0, 0, 0];
117
+ let linked = v.every((x) => x === v[0]);
118
+ const root = el("div", "twk-sides");
119
+ const all = numField({ label: meta.label, value: v[0], row: true, ...spec }, (n) => {
120
+ v = [n, n, n, n];
121
+ update();
122
+ emit();
123
+ });
124
+ // One toggle, moved to whichever block is showing; last in both, so it sits on the same pixels either way. It says
125
+ // what a click opens — each corner or side on its own — and is pressed while they are.
126
+ const split = corners ? tr("splitCorners", "Independent corners") : tr("splitSides", "Independent sides");
127
+ const link = btn("twk-sides-link", ICON_SPLIT);
128
+ link.setAttribute("aria-label", split);
129
+ link.title = split;
130
+ link.addEventListener("click", () => toggle());
131
+ // The head has the row's geometry, so opening moves nothing: the readout takes the field's place, with the unit once.
132
+ const multi = el("div", "twk-sides-multi"),
133
+ head = el("div", "twk-sides-head"),
134
+ sum = el("span", "twk-sides-sum"),
135
+ nums = document.createTextNode("");
136
+ sum.append(nums, txt("span", "twk-sides-unit", unit));
137
+ head.append(txt("span", "twk-row-label", meta.label), sum);
138
+ const fields = el("div", "twk-fields");
139
+ const flds: NumField[] = shown.map((n, i) =>
140
+ numField({ label: n, value: v[i], ...spec }, (x) => {
141
+ v[i] = x;
142
+ update();
143
+ emit();
144
+ }),
145
+ );
146
+ // The input and the tooltip carry the full name; on screen each field wears its glyph, one drawing turned per place.
147
+ flds.forEach((f, i) => {
148
+ const lab = f.el.querySelector<HTMLElement>(".twk-field-label")!;
149
+ lab.title = shown[i];
150
+ lab.classList.add("twk-sides-glyph");
151
+ lab.dataset.at = names[i].toLowerCase();
152
+ lab.innerHTML = corners ? ICON_CORNER : ICON_EDGE;
153
+ fields.append(f.el);
154
+ });
155
+ multi.append(head, fields);
156
+ root.append(all.el, multi);
157
+ // As the fields fitted them.
158
+ v = flds.map((f) => f.get());
159
+
160
+ const css = () => {
161
+ const [t, r, b, l] = v.map(fmt),
162
+ u = (s: string) => (s === "0" ? "0" : s + unit);
163
+ return (t === b && r === l ? (t === r ? [t] : [t, r]) : r === l ? [t, r, b] : [t, r, b, l]).map(u).join(" ");
164
+ };
165
+ const value = (): SidesValue => ({ top: v[0], right: v[1], bottom: v[2], left: v[3], css: css() });
166
+ const emit = () => onChange(value());
167
+ const update = () => {
168
+ root.classList.toggle("is-linked", linked);
169
+ link.setAttribute("aria-pressed", String(!linked));
170
+ (linked ? all.el : head).append(link);
171
+ all.set(v[0]);
172
+ flds.forEach((f, i) => f.set(v[i]));
173
+ nums.data = v.map(fmt).join(" ");
174
+ };
175
+ const toggle = () => {
176
+ linked = !linked;
177
+ if (linked) v = [v[0], v[0], v[0], v[0]];
178
+ update();
179
+ emit();
180
+ link.focus();
181
+ };
182
+ update();
183
+
184
+ // Set/restore: equal sides re-link, unequal ones unlink, so a restored "8 16 8 16" opens the fields it needs.
185
+ const set = (x: unknown) => {
186
+ const n = read(x, v);
187
+ if (!n) return;
188
+ flds.forEach((f, i) => f.set(n[i]));
189
+ v = flds.map((f) => f.get());
190
+ linked = v.every((s) => s === v[0]);
191
+ update();
192
+ };
193
+ return { el: root, set, get: value, range: all.range };
194
+ }
195
+
196
+ registerControl("sides", createSides);
@@ -0,0 +1,308 @@
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, words } from "../shared.js";
3
+ import {
4
+ dragGesture,
5
+ numField,
6
+ cssVar,
7
+ accentColor,
8
+ springCurve,
9
+ springFromTime,
10
+ boxFrac,
11
+ notice,
12
+ SPRING_DUR,
13
+ } from "../heavy.js";
14
+ import type { OnChange, NumField } from "../shared.js";
15
+ import type { Meta } from "../schema.js";
16
+ import type { Control } from "../types.js";
17
+
18
+ type SpringMode = "time" | "physics";
19
+ /** The physics triple every emitted value carries. */
20
+ interface Phys {
21
+ stiffness: number;
22
+ damping: number;
23
+ mass: number;
24
+ }
25
+ type PhysKey = keyof Phys;
26
+ /** The perceptual pair the time mode edits. */
27
+ interface SpringTime {
28
+ visualDuration: number;
29
+ bounce: number;
30
+ }
31
+ /** What set() accepts: any subset of the physics, the time pair and an explicit mode. */
32
+ type SpringValue = Partial<Phys & SpringTime & { mode: SpringMode }>;
33
+
34
+ function createSpring(meta: Meta, onChange: OnChange): Control {
35
+ const tr = words();
36
+ // The fields' floors, non-finite to the defaults: a stiffness or mass of 0 would divide the settle curve to NaN.
37
+ const cl = (v: unknown, lo: number, def: number) => (Number.isFinite(+v) ? Math.max(lo, +v) : def);
38
+ const clampS = (o: { stiffness?: unknown; damping?: unknown; mass?: unknown }): Phys => ({
39
+ stiffness: cl(o.stiffness, 1, 300),
40
+ damping: cl(o.damping, 0.5, 26),
41
+ mass: cl(o.mass, 0.1, 1),
42
+ });
43
+ const has = (x: unknown) => Number.isFinite(+x);
44
+
45
+ // Time mode edits visualDuration + bounce (Motion's spring shorthand), mapped onto the physics the preview and emit
46
+ // share exactly as Motion resolves them (springFromTime): stiffness = (2π / (visualDuration × 1.2))², ζ from bounce
47
+ // floored at
48
+ // 0.05, a negative bounce overdamped. Not through the physics pad's clamps, which would cut a short spring's
49
+ // stiffness.
50
+ // An authored duration past the editor's range is clamped, and the console says so once.
51
+ const [DUR_MIN, DUR_MAX] = SPRING_DUR;
52
+ let said = false;
53
+ const clampDur = (v: unknown) => {
54
+ const n = has(v) ? +v : 0.5,
55
+ c = clamp(n, DUR_MIN, DUR_MAX);
56
+ if (c !== n && !said) {
57
+ said = true;
58
+ notice("springDur", [meta.key, n, c]);
59
+ }
60
+ return c;
61
+ };
62
+ const clampBounce = (v: unknown) => clamp(has(v) ? +v : 0.2, -1, 1); // Motion takes a negative bounce: overdamped
63
+ // Ten significant figures: the trig round trip otherwise hands back 300.00000000000006 for a stiffness of 300.
64
+ const sig = (x: number) => +x.toPrecision(10);
65
+ const timeToPhysics = (t: SpringTime): Phys => {
66
+ const p = springFromTime(t.visualDuration, t.bounce);
67
+ return { stiffness: sig(p.stiffness), damping: sig(p.damping), mass: p.mass };
68
+ };
69
+
70
+ // Top-level props (the inline shorthand) or the `value:{…}` form, value winning; the mode is explicit or inferred,
71
+ // time winning.
72
+ const explicit = (m: unknown): SpringMode | null => (m === "time" || m === "physics" ? m : null);
73
+ const init = {
74
+ stiffness: meta.stiffness,
75
+ damping: meta.damping,
76
+ mass: meta.mass,
77
+ visualDuration: meta.visualDuration,
78
+ bounce: meta.bounce,
79
+ ...(meta.value || {}),
80
+ };
81
+ let mode: SpringMode =
82
+ explicit(init.mode) ?? explicit(meta.mode) ?? (has(init.visualDuration) || has(init.bounce) ? "time" : "physics");
83
+ const time: SpringTime = { visualDuration: clampDur(init.visualDuration), bounce: clampBounce(init.bounce) };
84
+ let phys = clampS({ stiffness: init.stiffness, damping: init.damping, mass: init.mass });
85
+
86
+ const root = el("div", "twk-spring");
87
+ const viz = el("div", "twk-spring-viz");
88
+ const canvas = document.createElement("canvas");
89
+ canvas.className = "twk-spring-canvas";
90
+ viz.append(canvas);
91
+ const physFields = el("div", "twk-fields");
92
+ const timeFields = el("div", "twk-fields");
93
+ const ctx = canvas.getContext("2d");
94
+
95
+ // The preview and the emitted value both read this, so they can never disagree.
96
+ const resolved = () => (mode === "time" ? timeToPhysics(time) : clampS(phys));
97
+ const draw = () => {
98
+ // Layout size: getBoundingClientRect includes the pop's entrance scale.
99
+ const w = Math.max(1, viz.clientWidth),
100
+ h = Math.max(1, viz.clientHeight);
101
+ if (w < 2) return;
102
+ const dpr = window.devicePixelRatio || 1;
103
+ canvas.width = w * dpr;
104
+ canvas.height = h * dpr;
105
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
106
+ ctx.clearRect(0, 0, w, h);
107
+ const s = resolved();
108
+ // Sample ~one point per device pixel so fast oscillations stay smooth, not angular.
109
+ const pts = springCurve(s.stiffness, s.damping, s.mass, Math.max(160, Math.round(w * dpr)));
110
+ const peak = Math.max(1.05, ...pts),
111
+ pad = 8; // the bezier editor's PAD, so the two previews inset alike
112
+ const X = (i: number) => pad + (i / (pts.length - 1)) * (w - 2 * pad);
113
+ const Y = (v: number) => h - pad - (v / peak) * (h - 2 * pad);
114
+ ctx.strokeStyle = cssVar(root, "--twk-surface-active") || "rgba(255,255,255,0.1)";
115
+ ctx.lineWidth = 1;
116
+ ctx.beginPath();
117
+ ctx.moveTo(pad, Y(1));
118
+ ctx.lineTo(w - pad, Y(1));
119
+ ctx.stroke(); // target (=1)
120
+ ctx.strokeStyle = accentColor(root);
121
+ ctx.lineWidth = 1.5;
122
+ ctx.lineJoin = ctx.lineCap = "round";
123
+ ctx.beginPath();
124
+ pts.forEach((v, i) => (i ? ctx.lineTo(X(i), Y(v)) : ctx.moveTo(X(i), Y(v))));
125
+ ctx.stroke();
126
+ };
127
+ // The resolved {k,d,m} plus, in time mode, visualDuration/bounce, from which set() infers the mode back from
128
+ // persistence.
129
+ const resolvedValue = (): Phys & Partial<SpringTime> =>
130
+ mode === "time" ? { ...resolved(), visualDuration: time.visualDuration, bounce: time.bounce } : resolved();
131
+ const emit = () => onChange(resolvedValue());
132
+
133
+ const flds: Partial<Record<PhysKey, NumField>> = {};
134
+ (
135
+ [
136
+ ["stiffness", "Stiffness", 1],
137
+ ["damping", "Damping", 0.5],
138
+ ["mass", "Mass", 0.1],
139
+ ] as const
140
+ ).forEach(([key, lab, step]) => {
141
+ flds[key] = numField({ label: tr(key, lab), value: phys[key], step, min: step }, (v) => {
142
+ phys[key] = v;
143
+ draw();
144
+ emit();
145
+ });
146
+ physFields.append(flds[key].el);
147
+ });
148
+ // The field shows ms; the value stays Motion's visualDuration in seconds.
149
+ const durFld = numField(
150
+ {
151
+ label: tr("duration", "Duration"),
152
+ value: time.visualDuration * 1000,
153
+ step: 10,
154
+ min: DUR_MIN * 1000,
155
+ max: DUR_MAX * 1000,
156
+ unit: "ms",
157
+ },
158
+ (v) => {
159
+ time.visualDuration = clampDur(v / 1000);
160
+ draw();
161
+ emit();
162
+ },
163
+ );
164
+ // shown as a percentage; the value stays Motion's −1–1
165
+ const bounceFld = numField(
166
+ { label: tr("bounce", "Bounce"), value: time.bounce * 100, step: 5, min: -100, max: 100, unit: "%" },
167
+ (v) => {
168
+ time.bounce = clampBounce(v / 100);
169
+ draw();
170
+ emit();
171
+ },
172
+ );
173
+ timeFields.append(durFld.el, bounceFld.el);
174
+
175
+ const showMode = () => {
176
+ timeFields.style.display = mode === "time" ? "" : "none";
177
+ physFields.style.display = mode === "physics" ? "" : "none";
178
+ };
179
+ const switchMode = (m: unknown) => {
180
+ if ((m !== "time" && m !== "physics") || m === mode) return;
181
+ mode = m;
182
+ showMode();
183
+ draw();
184
+ emit();
185
+ };
186
+ const modeToggle = createSegmented(
187
+ [
188
+ { value: "time", label: tr("time", "Time") },
189
+ { value: "physics", label: tr("physics", "Physics") },
190
+ ],
191
+ mode,
192
+ switchMode,
193
+ tr("springMode", "Spring mode"),
194
+ );
195
+ const modeRow = el("div", "twk-row");
196
+ modeRow.append(txt("span", "twk-row-label", tr("mode", "Mode")), modeToggle.el);
197
+ // A caption, so two springs in one panel can be told apart and the filter matches shown text.
198
+ if (meta.label) root.append(txt("div", "twk-spring-label", meta.label));
199
+ root.append(viz, modeRow, physFields, timeFields);
200
+
201
+ // Drag the preview to tune by feel: horizontal sets stiffness or duration, vertical sets damping or bounce.
202
+ // the feel-tuning span; the fields reach further
203
+ const ST_MIN = 1,
204
+ ST_MAX = 500,
205
+ DA_MIN = 1,
206
+ DA_MAX = 40,
207
+ DRAG_DUR_MIN = 0.1,
208
+ DRAG_DUR_MAX = 1;
209
+ const fromPointer = (e: PointerEvent) => {
210
+ const [px, py] = boxFrac(e, viz);
211
+ // Set the field (it snaps and clamps), then read back, so readout, value and preview agree.
212
+ if (mode === "time") {
213
+ durFld.set((DRAG_DUR_MIN + px * (DRAG_DUR_MAX - DRAG_DUR_MIN)) * 1000);
214
+ time.visualDuration = durFld.get() / 1000;
215
+ // top = bounce 100%, the middle 0, the bottom −100% (overdamped)
216
+ bounceFld.set((1 - 2 * py) * 100);
217
+ time.bounce = bounceFld.get() / 100;
218
+ } else {
219
+ flds.stiffness.set(ST_MIN + px * (ST_MAX - ST_MIN));
220
+ phys.stiffness = flds.stiffness.get();
221
+ flds.damping.set(DA_MIN + py * (DA_MAX - DA_MIN));
222
+ phys.damping = flds.damping.get(); // top = low damping
223
+ }
224
+ draw();
225
+ emit();
226
+ };
227
+ dragGesture(viz, {
228
+ onDown: (e) => {
229
+ e.preventDefault();
230
+ viz.classList.add("is-dragging");
231
+ fromPointer(e);
232
+ },
233
+ onMove: fromPointer,
234
+ onEnd: () => viz.classList.remove("is-dragging"),
235
+ });
236
+ showMode();
237
+ onReady(draw);
238
+ // The canvas reads theme tokens at draw time: redraw on resize, on a tab page revealing it (twk-reflow; it built at
239
+ // 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
240
+ // stale color.
241
+ onLive(
242
+ canvas,
243
+ [
244
+ [window, "resize"],
245
+ [window, "twk-reflow"],
246
+ [matchMedia("(prefers-color-scheme: dark)"), "change"],
247
+ [window, "twk-retheme"],
248
+ ],
249
+ draw,
250
+ );
251
+ // A host flipping [data-twk-scheme] re-themes through CSS alone; the canvas needs a redraw. Let go once mounted and
252
+ // removed.
253
+ // The redraw waits a frame, for a shadow panel's own observer to mirror the pin first.
254
+ let schemeObs: MutationObserver | null = null,
255
+ wasMounted = false;
256
+ if (typeof MutationObserver === "function") {
257
+ schemeObs = new MutationObserver(() => {
258
+ if (canvas.isConnected) {
259
+ wasMounted = true;
260
+ requestAnimationFrame(draw);
261
+ } else if (wasMounted) {
262
+ schemeObs.disconnect();
263
+ schemeObs = null;
264
+ }
265
+ });
266
+ schemeObs.observe(document.documentElement, {
267
+ subtree: true,
268
+ attributes: true,
269
+ attributeFilter: ["data-twk-scheme"],
270
+ });
271
+ }
272
+ return {
273
+ el: root,
274
+ destroy: () => {
275
+ if (schemeObs) {
276
+ schemeObs.disconnect();
277
+ schemeObs = null;
278
+ }
279
+ },
280
+ // Silent set/restore: infer the mode from the keys, update caches and fields, redraw without emitting.
281
+ set: (v: SpringValue) => {
282
+ if (!v || typeof v !== "object") return;
283
+ const ex = explicit(v.mode),
284
+ hasTime = has(v.visualDuration) || has(v.bounce),
285
+ hasPhys = has(v.stiffness) || has(v.damping) || has(v.mass);
286
+ if (!hasTime && !hasPhys && !ex) return;
287
+ mode = ex ?? (hasTime ? "time" : "physics"); // an explicit mode wins; else time, the authoring mode
288
+ if (hasTime) {
289
+ if (has(v.visualDuration)) time.visualDuration = clampDur(v.visualDuration);
290
+ if (has(v.bounce)) time.bounce = clampBounce(v.bounce);
291
+ }
292
+ // Restore the physics cache even alongside time keys (reset's default carries both), so a later toggle to Physics
293
+ // shows the intended spring.
294
+ if (hasPhys) phys = clampS({ ...phys, ...v });
295
+ modeToggle.set(mode);
296
+ durFld.set(time.visualDuration * 1000);
297
+ bounceFld.set(time.bounce * 100);
298
+ flds.stiffness.set(phys.stiffness);
299
+ flds.damping.set(phys.damping);
300
+ flds.mass.set(phys.mass);
301
+ showMode();
302
+ draw();
303
+ },
304
+ get: () => resolvedValue(),
305
+ };
306
+ }
307
+
308
+ registerControl("spring", createSpring);