tweakit 0.6.0 → 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 (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  // ── Gradient — a wide-gamut OKLCH gradient editor. Lazy; depends on the color
2
2
  // module (reuses its picker body, parseColor, and oklchStr).
3
- import { el, btn, clamp, popover, registerControl, icon } from "../shared.js";
3
+ import { el, btn, clamp, popover, registerControl, icon, words } from "../shared.js";
4
4
  import { dragGesture, triggerRow, arrowStep } from "../heavy.js";
5
5
  import { createPickerBody, parseColor, isColor, oklchStr, CHECKER } from "./color.js";
6
6
  import { modeInterpolation, interpolationMode } from "../../wide-gamut.js";
@@ -13,7 +13,8 @@ import type { Control, GradientStop, GradientEasing } from "../types.js";
13
13
 
14
14
  const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
15
15
  /** The name an easing is stored under: trimmed and lowercased when parseEasing knows it, else "linear". */
16
- const easingName = (e: unknown): GradientEasing => (parseEasing(e) ? (e as string).trim().toLowerCase() as GradientEasing : "linear");
16
+ const easingName = (e: unknown): GradientEasing =>
17
+ parseEasing(e) ? ((e as string).trim().toLowerCase() as GradientEasing) : "linear";
17
18
 
18
19
  /** The blend progress at a position, linear between samples exactly as the browser draws the
19
20
  * stops, so a stop inserted at x takes the color the ramp really shows. Null is the identity. */
@@ -22,13 +23,19 @@ function easingAt(b: Bez | null, x: number): number {
22
23
  if (!b) return x;
23
24
  const s = easingSamples(b);
24
25
  for (let k = 1; k < s.length; k++) {
25
- if (x <= s[k][0]) { const [x0, y0] = s[k - 1], [x1, y1] = s[k]; return x1 > x0 ? y0 + (y1 - y0) * (x - x0) / (x1 - x0) : y1; }
26
+ if (x <= s[k][0]) {
27
+ const [x0, y0] = s[k - 1],
28
+ [x1, y1] = s[k];
29
+ return x1 > x0 ? y0 + ((y1 - y0) * (x - x0)) / (x1 - x0) : y1;
30
+ }
26
31
  }
27
32
  return 1;
28
33
  }
29
34
 
30
35
  /** A stop handle on the rail, carrying the stop it moves. */
31
- interface StopHandle extends HTMLButtonElement { _stop?: GradientStop }
36
+ interface StopHandle extends HTMLButtonElement {
37
+ _stop?: GradientStop;
38
+ }
32
39
 
33
40
  // ICON_PLUS — adapted from Hugeicons `add-01` (MIT), two paths merged. See ../../../THIRD-PARTY-NOTICES.md.
34
41
  const ICON_PLUS = icon('<path d="M12 5v14m7-7H5"/>', "", 2.5);
@@ -39,99 +46,197 @@ const ICON_PLUS = icon('<path d="M12 5v14m7-7H5"/>', "", 2.5);
39
46
  // host's `linear-gradient(in …)` through gradientCss() gets the same pixels. ──
40
47
  // A value's blend space / easing: an explicit string on the object form, else null (the array
41
48
  // shorthand carries none). Inspected field by field, so the normalizers take `any`.
42
- const field = (value: any, key: string): string | null => (value && !Array.isArray(value) && typeof value[key] === "string" ? value[key] : null);
49
+ const field = (value: any, key: string): string | null =>
50
+ value && !Array.isArray(value) && typeof value[key] === "string" ? value[key] : null;
43
51
  const parseInterp = (value: any) => field(value, "interpolation");
44
52
  const parseEase = (value: any) => field(value, "easing");
45
53
  // The easing menu; a stored cubic-bezier() not among them shows as a "Custom" option while it's the value.
46
- const EASINGS: Array<[GradientEasing, string]> = [["linear", "Linear"], ["ease", "Ease"], ["ease-in", "Ease in"], ["ease-out", "Ease out"], ["ease-in-out", "Ease in-out"]];
54
+ // value, strings key, word
55
+ const EASINGS: Array<[GradientEasing, string, string]> = [
56
+ ["linear", "easeLinear", "Linear"],
57
+ ["ease", "ease", "Ease"],
58
+ ["ease-in", "easeIn", "Ease in"],
59
+ ["ease-out", "easeOut", "Ease out"],
60
+ ["ease-in-out", "easeInOut", "Ease in-out"],
61
+ ];
47
62
  function normalizeStops(value: any): GradientStop[] {
48
- const DEF: GradientStop[] = [{ color: "oklch(0.72 0.19 25)", pos: 0 }, { color: "oklch(0.72 0.16 280)", pos: 1 }];
49
- const arr = Array.isArray(value) ? value : (value && Array.isArray(value.stops) ? value.stops : null);
63
+ const DEF: GradientStop[] = [
64
+ { color: "oklch(0.72 0.19 25)", pos: 0 },
65
+ { color: "oklch(0.72 0.16 280)", pos: 1 },
66
+ ];
67
+ const arr = Array.isArray(value) ? value : value && Array.isArray(value.stops) ? value.stops : null;
50
68
  // A stop's color must be a single color token: stop text goes straight into the preview's
51
69
  // `linear-gradient(…)`, where an unparsed string could splice in further layers (a url() fetch
52
70
  // from persisted state). A non-finite pos → 0, clamped into [0,1]; fewer than two usable stops → the default pair.
53
71
  // Trimmed as the gate trims it: the CSS tokenizer reads a non-ASCII space as part of a name.
54
- const stop = (color: string, pos: unknown): GradientStop | null => (isColor(color) ? { color: String(color).trim(), pos: clamp(+pos || 0, 0, 1) } : null);
55
- const out = (arr || []).map((s: any) => {
56
- if (Array.isArray(s)) return stop(s[0], s[1]);
57
- if (s && typeof s === "object") return stop(s.color, s.pos);
58
- return null;
59
- }).filter(Boolean);
72
+ const stop = (color: string, pos: unknown): GradientStop | null =>
73
+ isColor(color) ? { color: String(color).trim(), pos: clamp(+pos || 0, 0, 1) } : null;
74
+ const out = (arr || [])
75
+ .map((s: any) => {
76
+ if (Array.isArray(s)) return stop(s[0], s[1]);
77
+ if (s && typeof s === "object") return stop(s.color, s.pos);
78
+ return null;
79
+ })
80
+ .filter(Boolean);
60
81
  return out.length >= 2 ? out : DEF;
61
82
  }
62
83
  function createGradient(meta: Meta, onChange: OnChange): Control {
84
+ const tr = words();
63
85
  meta.value ??= meta.stops; // the verbose form's `stops` aliases `value`
64
- // The blend and easing read off `value: {…}` first, else the verbose form's top level, where a pasted value's fields land.
65
- const interp0 = parseInterp(meta.value) ?? parseInterp(meta), ease0 = parseEase(meta.value) ?? parseEase(meta);
86
+ // The blend and easing read off `value: {…}` first, else the verbose form's top level, where a pasted value's fields
87
+ // land.
88
+ const interp0 = parseInterp(meta.value) ?? parseInterp(meta),
89
+ ease0 = parseEase(meta.value) ?? parseEase(meta);
66
90
  let stops = normalizeStops(meta.value);
67
91
  let selStop = stops[0];
68
92
  let easing = easingName(ease0); // unrecognized reads as linear
69
93
 
70
94
  // ── Trigger row — the strip is the readout; the stop count rides along for the accessibility tree. ──
71
- const { root, trigger, right } = triggerRow("tw-gradient", meta.label ?? "Gradient"); // ??: an explicit "" label renders none
72
- const preview = el("span", "tw-trigger-chip tw-gradient-preview"); preview.setAttribute("role", "img");
95
+ // ??: an explicit "" label renders none
96
+ const { root, trigger, right } = triggerRow("twk-gradient", meta.label ?? tr("gradient", "Gradient"));
97
+ const preview = el("span", "twk-trigger-chip twk-gradient-preview");
98
+ preview.setAttribute("role", "img");
73
99
  right.append(preview);
74
100
 
75
101
  // ── Editor popover — carries the color popover's class so it inherits its tokens and shell. ──
76
- const pop = el("div", "tw-color-pop tw-gradient-pop");
77
- const barRow = el("div", "tw-gradient-bar-row");
78
- const bar = el("div", "tw-gradient-bar");
79
- const grad = el("div", "tw-gradient-grad");
80
- const rail = el("div", "tw-gradient-rail");
102
+ const pop = el("div", "twk-color-pop twk-gradient-pop");
103
+ const barRow = el("div", "twk-gradient-bar-row");
104
+ const bar = el("div", "twk-gradient-bar");
105
+ const grad = el("div", "twk-gradient-grad");
106
+ const rail = el("div", "twk-gradient-rail");
81
107
  bar.append(grad, rail);
82
- const addBtn = btn("tw-gradient-add", ICON_PLUS); addBtn.title = "Add a stop after the selected one"; addBtn.setAttribute("aria-label", "Add a color stop after the selected one");
108
+ const addBtn = btn("twk-gradient-add", ICON_PLUS);
109
+ addBtn.title = tr("addStop", "Add a stop after the selected one");
110
+ addBtn.setAttribute("aria-label", tr("addStopAria", "Add a color stop after the selected one"));
83
111
  barRow.append(bar, addBtn);
84
112
 
85
113
  const sorted = () => [...stops].sort((a, b) => a.pos - b.pos);
86
114
  // body.mode() is the single source of truth for the blend space, so the bar, preview and emitted
87
115
  // interpolation can't drift. (Stops keep their own notation; CSS lets `in srgb` carry oklch() stops.)
88
116
  const interp = () => modeInterpolation(body.mode());
89
- const value = () => ({ stops: sorted().map((s) => ({ color: s.color, pos: +s.pos.toFixed(4) })), interpolation: interp(), easing });
117
+ const value = () => ({
118
+ stops: sorted().map((s) => ({ color: s.color, pos: +s.pos.toFixed(4) })),
119
+ interpolation: interp(),
120
+ easing,
121
+ });
90
122
  // Both draw gradientCss(value()), so the editor can't show a ramp a host templating through it won't get.
91
- const paint = () => { const css = gradientCss(value()); grad.style.background = css; preview.style.background = `${css}, ${CHECKER}`; };
92
- const reflectCount = () => { preview.setAttribute("aria-label", `${stops.length} stop${stops.length === 1 ? "" : "s"}`); };
123
+ const paint = () => {
124
+ const css = gradientCss(value());
125
+ grad.style.background = css;
126
+ preview.style.background = `${css}, ${CHECKER}`;
127
+ };
128
+ const reflectCount = () => {
129
+ preview.setAttribute(
130
+ "aria-label",
131
+ tr("stops", (v) => `${v.n} stop${v.n === 1 ? "" : "s"}`, { n: stops.length }),
132
+ );
133
+ };
93
134
  const emit = () => onChange(value());
94
135
 
95
- const handleFor = (s: GradientStop) => [...(rail.children as HTMLCollectionOf<StopHandle>)].find((h) => h._stop === s); // the rail holds only stop handles
96
- // Unlike the standalone color control, a mode switch here IS a change to the gradient's output, so it repaints and re-emits.
97
- const body = createPickerBody({ value: selStop.color, mode: interpolationMode(interp0 || "oklch"), onMode: () => { paint(); emit(); } }, (c) => { // oklch: the documented default and colorAt's blend space
98
- selStop.color = c;
99
- handleFor(selStop)?.style.setProperty("--stop", c);
100
- paint(); emit();
101
- });
136
+ // the rail holds only stop handles
137
+ const handleFor = (s: GradientStop) =>
138
+ [...(rail.children as HTMLCollectionOf<StopHandle>)].find((h) => h._stop === s);
139
+ // Unlike the standalone color control, a mode switch here IS a change to the gradient's output, so it repaints and
140
+ // re-emits.
141
+ // oklch: the documented default and colorAt's blend space
142
+ const body = createPickerBody(
143
+ {
144
+ value: selStop.color,
145
+ mode: interpolationMode(interp0 || "oklch"),
146
+ onMode: () => {
147
+ paint();
148
+ emit();
149
+ },
150
+ },
151
+ (c) => {
152
+ selStop.color = c;
153
+ handleFor(selStop)?.style.setProperty("--stop", c);
154
+ paint();
155
+ emit();
156
+ },
157
+ );
102
158
  // Easing sits beside the mode: both are "how the ramp blends".
103
- const easeSel = el("select", "tw-color-mode tw-gradient-ease"); easeSel.setAttribute("aria-label", "Easing between stops"); easeSel.title = "How the ramp blends between stops";
104
- for (const [v, label] of EASINGS) { const o = document.createElement("option"); o.value = v; o.textContent = label; easeSel.append(o); }
159
+ const easeSel = el("select", "twk-color-mode twk-gradient-ease");
160
+ easeSel.setAttribute("aria-label", tr("stopEasing", "Easing between stops"));
161
+ easeSel.title = tr("stopEasingHint", "How the ramp blends between stops");
162
+ for (const [v, k, label] of EASINGS) {
163
+ const o = document.createElement("option");
164
+ o.value = v;
165
+ o.textContent = tr(k, label);
166
+ easeSel.append(o);
167
+ }
105
168
  const reflectEase = () => {
106
169
  let custom = easeSel.querySelector<HTMLOptionElement>("option[data-custom]");
107
170
  if (EASINGS.some(([v]) => v === easing)) custom?.remove();
108
- else { if (!custom) { custom = document.createElement("option"); custom.dataset.custom = ""; custom.textContent = "Custom"; easeSel.append(custom); } custom.value = easing; }
171
+ else {
172
+ if (!custom) {
173
+ custom = document.createElement("option");
174
+ custom.dataset.custom = "";
175
+ custom.textContent = tr("custom", "Custom");
176
+ easeSel.append(custom);
177
+ }
178
+ custom.value = easing;
179
+ }
109
180
  easeSel.value = easing;
110
181
  };
111
- easeSel.addEventListener("change", () => { easing = easeSel.value as GradientEasing; reflectEase(); paint(); emit(); }); // reflect: picking a keyword retires the Custom option
112
- body.el.querySelector(".tw-color-mode")!.after(easeSel); // after the mode select, before the gamut tag
182
+ // reflect: picking a keyword retires the Custom option
183
+ easeSel.addEventListener("change", () => {
184
+ easing = easeSel.value as GradientEasing;
185
+ reflectEase();
186
+ paint();
187
+ emit();
188
+ });
189
+ body.el.querySelector(".twk-color-mode")!.after(easeSel); // after the mode select, before the gamut tag
113
190
  reflectEase();
114
191
  pop.append(barRow, body.el);
115
192
  root.append(pop);
116
193
 
117
194
  // A stop's accessible name carries its position and selection, the state the ring shows sighted users.
118
- const labelStop = (h: StopHandle) => h.setAttribute("aria-label", `Color stop at ${Math.round(h._stop.pos * 100)}%${h._stop === selStop ? ", selected" : ""}`);
119
- const reflectSel = () => { for (const h of rail.children as HTMLCollectionOf<StopHandle>) { h.dataset.sel = String(h._stop === selStop); labelStop(h); } };
195
+ const labelStop = (h: StopHandle) =>
196
+ h.setAttribute(
197
+ "aria-label",
198
+ tr(
199
+ h._stop === selStop ? "stopAtSelected" : "stopAt",
200
+ h._stop === selStop ? "Color stop at {pos}%, selected" : "Color stop at {pos}%",
201
+ { pos: Math.round(h._stop.pos * 100) },
202
+ ),
203
+ );
204
+ const reflectSel = () => {
205
+ for (const h of rail.children as HTMLCollectionOf<StopHandle>) {
206
+ h.dataset.sel = String(h._stop === selStop);
207
+ labelStop(h);
208
+ }
209
+ };
120
210
  // A drag or an arrow moving a stop: its spot on the rail, its name, the ramp and the value follow.
121
- const moveStop = (h: StopHandle, pos: number) => { h._stop.pos = clamp(pos, 0, 1); h.style.left = h._stop.pos * 100 + "%"; labelStop(h); paint(); emit(); };
211
+ const moveStop = (h: StopHandle, pos: number) => {
212
+ h._stop.pos = clamp(pos, 0, 1);
213
+ h.style.left = h._stop.pos * 100 + "%";
214
+ labelStop(h);
215
+ paint();
216
+ emit();
217
+ };
122
218
  const renderHandles = () => {
123
219
  rail.replaceChildren();
124
220
  for (const s of stops) {
125
- const h: StopHandle = btn("tw-gradient-stop"); h._stop = s;
126
- h.style.left = s.pos * 100 + "%"; h.style.setProperty("--stop", s.color);
127
- h.dataset.sel = String(s === selStop); labelStop(h);
221
+ const h: StopHandle = btn("twk-gradient-stop");
222
+ h._stop = s;
223
+ h.style.left = s.pos * 100 + "%";
224
+ h.style.setProperty("--stop", s.color);
225
+ h.dataset.sel = String(s === selStop);
226
+ labelStop(h);
128
227
  // Focus is selection: tabbing onto a stop points the picker at it.
129
228
  h.addEventListener("focus", () => select(s, false));
130
229
  let offBar = false;
131
230
  dragGesture(h, {
132
231
  // preventDefault suppresses click-to-focus, so focus explicitly (ringless); that also blurs
133
232
  // a dirty channel field while the old stop is still selected, so its commit lands there.
134
- onDown: (e) => { e.preventDefault(); e.stopPropagation(); h.focus({ focusVisible: false }); select(s, false); offBar = false; },
233
+ onDown: (e) => {
234
+ e.preventDefault();
235
+ e.stopPropagation();
236
+ h.focus({ focusVisible: false });
237
+ select(s, false);
238
+ offBar = false;
239
+ },
135
240
  onMove: (e) => {
136
241
  // One rail rect for X and the off-bar Y test; the X math is posFromX inlined.
137
242
  const r = rail.getBoundingClientRect();
@@ -140,95 +245,169 @@ function createGradient(meta: Meta, onChange: OnChange): Control {
140
245
  offBar = stops.length > 2 && (e.clientY < r.top - 24 || e.clientY > r.bottom + 24);
141
246
  h.dataset.removing = String(offBar);
142
247
  },
143
- onEnd: () => { if (offBar) removeStop(s); else h.dataset.removing = "false"; },
248
+ onEnd: () => {
249
+ if (offBar) removeStop(s);
250
+ else h.dataset.removing = "false";
251
+ },
252
+ });
253
+ h.addEventListener("dblclick", (e) => {
254
+ e.preventDefault();
255
+ removeStop(s);
144
256
  });
145
- h.addEventListener("dblclick", (e) => { e.preventDefault(); removeStop(s); });
146
257
  // Arrows nudge by 0.01 (⇧ = 0.1); Delete stays on the popover.
147
258
  h.addEventListener("keydown", (e) => {
148
- const [d] = arrowStep(e); if (!d) return;
149
- e.preventDefault(); moveStop(h, s.pos + d * (e.shiftKey ? 0.1 : 0.01));
259
+ const [d] = arrowStep(e);
260
+ if (!d) return;
261
+ e.preventDefault();
262
+ moveStop(h, s.pos + d * (e.shiftKey ? 0.1 : 0.01));
150
263
  });
151
264
  rail.append(h);
152
265
  }
153
266
  };
154
267
  // A drag only flips the selected flag, so the dragged handle stays live. body.set runs before
155
268
  // selStop switches: re-pointing blurs a dirty channel field, whose commit must land on the old stop.
156
- const select = (s: GradientStop, rerender: boolean) => { if (s !== selStop) { body.set(s.color); selStop = s; } rerender ? renderHandles() : reflectSel(); };
269
+ const select = (s: GradientStop, rerender: boolean) => {
270
+ if (s !== selStop) {
271
+ body.set(s.color);
272
+ selStop = s;
273
+ }
274
+ rerender ? renderHandles() : reflectSel();
275
+ };
157
276
 
158
- const posFromX = (x: number) => { const r = rail.getBoundingClientRect(); return clamp((x - r.left) / (r.width || 1), 0, 1); };
277
+ const posFromX = (x: number) => {
278
+ const r = rail.getBoundingClientRect();
279
+ return clamp((x - r.left) / (r.width || 1), 0, 1);
280
+ };
159
281
 
160
- // A new stop's color: the bracketing stops interpolated in OKLCH at the EASED progress, so it lands invisibly on the ramp the bar draws.
282
+ // A new stop's color: the bracketing stops interpolated in OKLCH at the EASED progress, so it lands invisibly on the
283
+ // ramp the bar draws.
161
284
  const colorAt = (pos: number) => {
162
- const ss = sorted(); let lo = ss[0], hi = ss[ss.length - 1];
163
- for (let k = 0; k < ss.length - 1; k++) if (pos >= ss[k].pos && pos <= ss[k + 1].pos) { lo = ss[k]; hi = ss[k + 1]; break; }
164
- const t = easingAt(parseEasing(easing), hi.pos > lo.pos ? clamp((pos - lo.pos) / (hi.pos - lo.pos), 0, 1) : 0); // a pos outside the outermost stops never extrapolates
165
- const a = parseColor(lo.color), b = parseColor(hi.color);
285
+ const ss = sorted();
286
+ let lo = ss[0],
287
+ hi = ss[ss.length - 1];
288
+ for (let k = 0; k < ss.length - 1; k++)
289
+ if (pos >= ss[k].pos && pos <= ss[k + 1].pos) {
290
+ lo = ss[k];
291
+ hi = ss[k + 1];
292
+ break;
293
+ }
294
+ // a pos outside the outermost stops never extrapolates
295
+ const t = easingAt(parseEasing(easing), hi.pos > lo.pos ? clamp((pos - lo.pos) / (hi.pos - lo.pos), 0, 1) : 0);
296
+ const a = parseColor(lo.color),
297
+ b = parseColor(hi.color);
166
298
  // CSS missing-hue: a ~zero-chroma stop carries no hue, so the other's holds (white→red stays red).
167
299
  const ach = (c: Oklcha) => c[1] < 1e-4;
168
- let dh = b[2] - a[2]; if (dh > 180) dh -= 360; else if (dh < -180) dh += 360;
300
+ let dh = b[2] - a[2];
301
+ if (dh > 180) dh -= 360;
302
+ else if (dh < -180) dh += 360;
169
303
  if (ach(a) || ach(b)) dh = 0;
170
- const H = ((((ach(a) && !ach(b) ? b[2] : a[2]) + dh * t) % 360) + 360) % 360; // normalize to [0,360) so the picker reads it cleanly
304
+ // normalize to [0,360) so the picker reads it cleanly
305
+ const H = ((((ach(a) && !ach(b) ? b[2] : a[2]) + dh * t) % 360) + 360) % 360;
171
306
  return oklchStr(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, H, a[3] + (b[3] - a[3]) * t);
172
307
  };
173
308
  const removeStop = (s: GradientStop) => {
174
309
  if (stops.length <= 2) return;
175
- const i = stops.indexOf(s); if (i < 0) return; // a stale reference (rebuilt via set) must not splice(-1)
310
+ const i = stops.indexOf(s);
311
+ if (i < 0) return; // a stale reference (rebuilt via set) must not splice(-1)
176
312
  stops.splice(i, 1);
177
313
  const next = selStop === s ? stops[Math.max(0, i - 1)] : selStop;
178
- paint(); select(next, true); reflectCount(); emit();
314
+ paint();
315
+ select(next, true);
316
+ reflectCount();
317
+ emit();
179
318
  handleFor(selStop)?.focus(); // keep focus on a handle so Delete can chain
180
319
  };
181
320
  // Insert a stop at `pos`, colored by sampling the gradient there, and select it. Shared by the +
182
321
  // button (midway toward the selected stop's neighbor, so where it lands is predictable) and double-click on the bar.
183
322
  const insertStopAt = (pos: number) => {
184
323
  const s = { color: colorAt(pos), pos: clamp(pos, 0, 1) };
185
- stops.push(s); paint(); select(s, true); reflectCount(); emit();
324
+ stops.push(s);
325
+ paint();
326
+ select(s, true);
327
+ reflectCount();
328
+ emit();
186
329
  };
187
330
  // Midway toward the next stop, or toward the previous from the last. There are always two stops at least (the
188
331
  // normalizer's default pair, removeStop's floor), and the selected one is always among them.
189
332
  const addStop = () => {
190
- const ss = sorted(), i = ss.indexOf(selStop);
333
+ const ss = sorted(),
334
+ i = ss.indexOf(selStop);
191
335
  insertStopAt(i < ss.length - 1 ? (ss[i].pos + ss[i + 1].pos) / 2 : (ss[i - 1].pos + ss[i].pos) / 2);
192
336
  };
193
- addBtn.addEventListener("click", (e) => { e.stopPropagation(); addStop(); });
194
- // Double-click, not single, so it never fights grabbing a stop; a double-click on a stop falls through to its own handler.
337
+ addBtn.addEventListener("click", (e) => {
338
+ e.stopPropagation();
339
+ addStop();
340
+ });
341
+ // Double-click, not single, so it never fights grabbing a stop; a double-click on a stop falls through to its own
342
+ // handler.
195
343
  bar.addEventListener("dblclick", (e) => {
196
- if ((e.target as Element).closest(".tw-gradient-stop")) return;
344
+ if ((e.target as Element).closest(".twk-gradient-stop")) return;
197
345
  e.preventDefault();
198
346
  insertStopAt(posFromX(e.clientX));
199
347
  });
200
348
 
201
- // Delete / Backspace removes the selected stop, ignored while typing in a field and with Alt held (the panel's reset chord).
349
+ // Delete / Backspace removes the selected stop, ignored while typing in a field and with Alt held (the panel's reset
350
+ // chord).
202
351
  pop.tabIndex = -1;
203
352
  pop.addEventListener("keydown", (e) => {
204
- if ((e.key === "Delete" || e.key === "Backspace") && !e.altKey && !/^(input|textarea|select)$/i.test((e.target as Element).tagName) && stops.length > 2) { e.preventDefault(); removeStop(selStop); }
353
+ if (
354
+ (e.key === "Delete" || e.key === "Backspace") &&
355
+ !e.altKey &&
356
+ !/^(input|textarea|select)$/i.test((e.target as Element).tagName) &&
357
+ stops.length > 2
358
+ ) {
359
+ e.preventDefault();
360
+ removeStop(selStop);
361
+ }
205
362
  });
206
363
 
207
- // Focus lands on the selected stop, not the first: focus is selection, so that would re-point the picker on every open.
208
- popover(root, trigger, pop, { fallbackH: 392, onOpen: body.reflow, onReflow: body.reflow, initialFocus: () => handleFor(selStop) });
364
+ // Focus lands on the selected stop, not the first: focus is selection, so that would re-point the picker on every
365
+ // open.
366
+ popover(root, trigger, pop, {
367
+ fallbackH: 392,
368
+ onOpen: body.reflow,
369
+ onReflow: body.reflow,
370
+ initialFocus: () => handleFor(selStop),
371
+ });
209
372
 
210
- renderHandles(); paint(); reflectCount();
373
+ renderHandles();
374
+ paint();
375
+ reflectCount();
211
376
  return {
212
377
  el: root,
213
378
  set: (v: unknown) => {
214
379
  // Named → applied; absent → left as the user set it, never reset to OKLCH on a stops-only set.
215
- const ip = parseInterp(v); if (ip) body.setMode(interpolationMode(ip));
216
- const es = parseEase(v); if (es !== null) { easing = easingName(es); reflectEase(); } // same rule as the blend
380
+ const ip = parseInterp(v);
381
+ if (ip) body.setMode(interpolationMode(ip));
382
+ const es = parseEase(v);
383
+ // same rule as the blend
384
+ if (es !== null) {
385
+ easing = easingName(es);
386
+ reflectEase();
387
+ }
217
388
  const next = normalizeStops(v).sort((a, b) => a.pos - b.pos);
218
389
  if (next.length === stops.length) {
219
390
  // Same count (a host mirroring values back): update the stop objects in place, paired in
220
391
  // position order, so a live drag keeps its handle and the picker its stop.
221
392
  sorted().forEach((s, i) => {
222
- s.color = next[i].color; s.pos = next[i].pos;
223
- const h = handleFor(s); if (h) { h.style.left = s.pos * 100 + "%"; h.style.setProperty("--stop", s.color); }
393
+ s.color = next[i].color;
394
+ s.pos = next[i].pos;
395
+ const h = handleFor(s);
396
+ if (h) {
397
+ h.style.left = s.pos * 100 + "%";
398
+ h.style.setProperty("--stop", s.color);
399
+ }
224
400
  });
225
401
  } else {
226
402
  const selPos = selStop.pos;
227
403
  stops = next;
228
- selStop = stops.reduce((b, s) => (Math.abs(s.pos - selPos) < Math.abs(b.pos - selPos) ? s : b), stops[0]); // re-select the nearest position, not index 0
404
+ // re-select the nearest position, not index 0
405
+ selStop = stops.reduce((b, s) => (Math.abs(s.pos - selPos) < Math.abs(b.pos - selPos) ? s : b), stops[0]);
229
406
  renderHandles();
230
407
  }
231
- paint(); reflectCount(); body.set(selStop.color);
408
+ paint();
409
+ reflectCount();
410
+ body.set(selStop.color);
232
411
  },
233
412
  get: () => value(),
234
413
  };