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
@@ -3,45 +3,66 @@ import { el, txt, onReady, measurePill, navIndex, requestReflow, registerControl
3
3
  import type { Meta } from "../schema.js";
4
4
 
5
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 }
6
+ export interface TabsControl {
7
+ el: HTMLDivElement;
8
+ bodies: HTMLDivElement[];
9
+ activate(i: number): void;
10
+ active(): number;
11
+ }
7
12
 
8
13
  let tabsSeq = 0; // unique ids for the tab ↔ tabpanel aria pairing
9
14
  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 uid = `twk-tabs-${++tabsSeq}`;
16
+ const root = el("div", "twk-tabs");
17
+ const bar = el("div", "twk-tabs-bar");
18
+ bar.setAttribute("role", "tablist");
19
+ const pill = el("div", "twk-tabs-pill");
20
+ bar.append(pill);
21
+ const pagesWrap = el("div", "twk-tabs-pages");
15
22
  const bodies: HTMLDivElement[] = [];
16
23
  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);
24
+ const tab = txt("button", "twk-tabs-tab", page.title);
25
+ tab.setAttribute("role", "tab");
26
+ tab.id = `${uid}-tab-${i}`;
27
+ tab.setAttribute("aria-controls", `${uid}-page-${i}`);
28
+ const body = el("div", "twk-tabs-page twk-controls");
29
+ body.setAttribute("role", "tabpanel");
30
+ body.id = `${uid}-page-${i}`;
31
+ body.setAttribute("aria-labelledby", tab.id);
32
+ bodies.push(body);
33
+ pagesWrap.append(body);
22
34
  tab.addEventListener("click", () => activate(i));
23
- bar.append(tab); return tab;
35
+ bar.append(tab);
36
+ return tab;
24
37
  });
25
38
  root.append(bar, pagesWrap);
26
39
  const measure = (animate?: boolean) => measurePill(bar, pill, animate); // slide the pill to the active tab
27
40
  // The active tab and page; the roving tabindex is set from the build, not only after a switch.
28
41
  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; });
42
+ tabs.forEach((b, k) => {
43
+ b.dataset.active = String(k === i);
44
+ b.setAttribute("aria-selected", String(k === i));
45
+ b.tabIndex = k === i ? 0 : -1;
46
+ });
30
47
  bodies.forEach((b, k) => (b.dataset.active = String(k === i)));
31
48
  };
32
49
  mark(0);
33
50
  function activate(i: number) {
34
- mark(i); measure(true);
51
+ mark(i);
52
+ measure(true);
35
53
  requestReflow(); // controls built on a display:none page measured 0; let them re-measure
36
54
  }
37
55
  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();
56
+ const i = tabs.findIndex((b) => b.dataset.active === "true");
57
+ if (i < 0) return;
58
+ const j = navIndex(e.key, i, tabs.length, -1);
59
+ if (j < 0) return; // cols −1: ↑/↓ stay the page's scroll keys
60
+ e.preventDefault();
61
+ activate(j);
62
+ tabs[j].focus();
41
63
  });
42
64
  onReady(measure);
43
65
  return { el: root, bodies, activate, active: () => tabs.findIndex((t) => t.dataset.active === "true") };
44
66
  }
45
67
 
46
68
  registerControl("tabs", createTabs);
47
-
@@ -1,5 +1,5 @@
1
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,
2
+ * live panel from a plain schema object, enhance() turns [data-twk] markup into live controls,
3
3
  * and mountControl() / createColorPicker() / gradientCss() are the controls without the panel. */
4
4
  export { tweaks } from "./panel.js";
5
5
  export { enhance } from "./enhance.js";
@@ -9,9 +9,13 @@ import type { GradientEasing, GradientStop, GradientValue } from "./types.js";
9
9
  /** cubic-bezier control points [x1, y1, x2, y2]. */
10
10
  export type Bez = [number, number, number, number];
11
11
 
12
- // The CSS easing keywords as cubic-beziers. Null-prototype, so "constructor" can't read a function off Object.prototype.
12
+ // The CSS easing keywords as cubic-beziers. Null-prototype, so "constructor" can't read a function off
13
+ // Object.prototype.
13
14
  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
+ ease: [0.25, 0.1, 0.25, 1],
16
+ "ease-in": [0.42, 0, 1, 1],
17
+ "ease-out": [0, 0, 0.58, 1],
18
+ "ease-in-out": [0.42, 0, 0.58, 1],
15
19
  });
16
20
  const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
17
21
  const SAMPLES = 15; // 16 per segment, Larsen's count; the joins vanish
@@ -22,7 +26,8 @@ export function parseEasing(e: unknown): Bez | null {
22
26
  if (typeof e !== "string") return null;
23
27
  const s = e.trim().toLowerCase();
24
28
  if (KEYWORDS[s]) return KEYWORDS[s];
25
- const m = /^cubic-bezier\(([^()]*)\)$/.exec(s); if (!m) return null;
29
+ const m = /^cubic-bezier\(([^()]*)\)$/.exec(s);
30
+ if (!m) return null;
26
31
  const n = m[1].split(",").map((x) => (x.trim() ? Number(x) : NaN)); // an empty field is NaN, not Number("") → 0
27
32
  if (n.length !== 4 || !n.every(Number.isFinite)) return null;
28
33
  return [clamp01(n[0]), n[1], clamp01(n[2]), n[3]];
@@ -32,7 +37,11 @@ export function parseEasing(e: unknown): Bez | null {
32
37
  export function easingSamples(b: Bez): Array<[number, number]> {
33
38
  const out: Array<[number, number]> = [];
34
39
  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;
40
+ const t = k / SAMPLES,
41
+ u = 1 - t,
42
+ p1 = 3 * u * u * t,
43
+ p2 = 3 * u * t * t,
44
+ p3 = t * t * t;
36
45
  out.push([p1 * b[0] + p2 * b[2] + p3, clamp01(p1 * b[1] + p2 * b[3] + p3)]);
37
46
  }
38
47
  return out;
@@ -40,22 +49,31 @@ export function easingSamples(b: Bez): Array<[number, number]> {
40
49
 
41
50
  const pct = (n: number) => +(n * 100).toFixed(2);
42
51
 
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;
52
+ // 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
53
+ // in another layer (a url() the browser fetches), and var() can stand for anything. DOM-free, so by shape: a hex, a
54
+ // keyword, or one function with no nested parens; the editor's picker checks the grammar itself.
55
+ const STOP_COLOR =
56
+ /^(?:#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(?!(?:url|var)\()[a-z][a-z-]*(?:\([\w\s.,%+\-\/]*\))?)$/i;
47
57
  // The blend spaces CSS takes, and a hue method on the polar ones; anything else (`in junk`, `in 5`) falls to
48
58
  // 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");
59
+ const INTERP =
60
+ /^(?:(?:hsl|hwb|lch|oklch)(?: (?:shorter|longer|increasing|decreasing) hue)?|srgb(?:-linear)?|oklab|lab|display-p3|rec2020|a98-rgb|prophoto-rgb|xyz(?:-d50|-d65)?)$/i;
61
+ const blend = (v: GradientValue) =>
62
+ typeof v.interpolation === "string" && INTERP.test(v.interpolation) ? v.interpolation : "oklch";
51
63
 
52
64
  /** The stops a value can draw, sorted. A hand-built value can hold no stops, or stops without a
53
65
  * color or a numeric pos, and any of those builds CSS the browser drops whole, silently;
54
66
  * so they're set aside with a warning (heavy.ts badGradient, one per distinct fault). */
55
67
  function drawable(value: unknown, fn: string): GradientStop[] {
56
68
  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), () => {});
69
+ const ok = Array.isArray(all)
70
+ ? all.filter((s) => s && typeof s.color === "string" && STOP_COLOR.test(s.color.trim()) && Number.isFinite(s.pos))
71
+ : [];
72
+ if (!ok.length || ok.length < (all as unknown[]).length)
73
+ import("./heavy.js").then(
74
+ (m) => m.badGradient(fn, value, all, ok.length),
75
+ () => {},
76
+ );
59
77
  return ok.sort((a, b) => a.pos - b.pos);
60
78
  }
61
79
 
@@ -68,18 +86,23 @@ export function gradientStops(value: GradientValue): string {
68
86
 
69
87
  function stopList(value: GradientValue, stops: GradientStop[]): string {
70
88
  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
89
+ // one stop isn't a stop list; doubled, it's a solid fill
90
+ if (stops.length < 2) return `${stops[0].color}, ${stops[0].color}`;
72
91
  const bez = parseEasing(value.easing);
73
92
  if (!bez) return stops.map(plain).join(", ");
74
- const samples = easingSamples(bez), space = blend(value);
93
+ const samples = easingSamples(bez),
94
+ space = blend(value);
75
95
  const out = [plain(stops[0])];
76
96
  for (let i = 1; i < stops.length; i++) {
77
- const a = stops[i - 1], b = stops[i], span = b.pos - a.pos;
97
+ const a = stops[i - 1],
98
+ b = stops[i],
99
+ span = b.pos - a.pos;
78
100
  // 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
- }
101
+ if (span > 1e-6)
102
+ for (let k = 1; k < SAMPLES; k++) {
103
+ const [x, y] = samples[k];
104
+ out.push(`color-mix(in ${space}, ${a.color}, ${b.color} ${pct(y)}%) ${pct(a.pos + span * x)}%`);
105
+ }
83
106
  out.push(plain(b));
84
107
  }
85
108
  return out.join(", ");
@@ -89,5 +112,7 @@ function stopList(value: GradientValue, stops: GradientStop[]): string {
89
112
  * One stop draws a solid fill and no stops is `none`, both still valid for background-image. */
90
113
  export function gradientCss(value: GradientValue, direction: number | string = "to right"): string {
91
114
  const stops = drawable(value, "gradientCss");
92
- return stops.length ? `linear-gradient(in ${blend(value)} ${typeof direction === "number" ? `${direction}deg` : direction}, ${stopList(value, stops)})` : "none";
115
+ return stops.length
116
+ ? `linear-gradient(in ${blend(value)} ${typeof direction === "number" ? `${direction}deg` : direction}, ${stopList(value, stops)})`
117
+ : "none";
93
118
  }
@@ -1,4 +1,5 @@
1
- /* Markup-driven enhancement, core's side: enhance(). The work lives in markup.ts, a lazy chunk of the code-split build that
1
+ /* Markup-driven enhancement, core's side: enhance(). The work lives in markup.ts, a lazy chunk of the code-split build
2
+ * that
2
3
  * only a page with markup fetches; core lends it what it shares with the panel, as a kit. Importing core binds nothing:
3
4
  * the page scan on load is opt-in, in auto.ts (tweakit/auto), so the package can declare core free of side effects. */
4
5
  import { getMarkup } from "./shared.js";
@@ -7,14 +8,28 @@ import { ensureForMetas, LAZY_IMPORT } from "./lazy.js";
7
8
  import { createFolder, createControl } from "./controls/basic.js";
8
9
  import { makeCopyBtn, makeResetBtn, addHintMarker } from "./feedback.js";
9
10
  import type { MarkupKit } from "./markup.js";
11
+ import type { EnhanceOptions } from "./types.js";
10
12
 
11
13
  declare const TW_SPLIT: boolean;
12
- const kit: MarkupKit = { metaFor, ensureForMetas, LAZY_IMPORT, createFolder, createControl, makeCopyBtn, makeResetBtn, addHintMarker };
14
+ const kit: MarkupKit = {
15
+ metaFor,
16
+ ensureForMetas,
17
+ LAZY_IMPORT,
18
+ createFolder,
19
+ createControl,
20
+ makeCopyBtn,
21
+ makeResetBtn,
22
+ addHintMarker,
23
+ };
13
24
 
14
- /** Bind every unbound [data-tw] host under `root` (the document by default). */
15
- export function enhance(root: Document | Element = document): Promise<void> {
25
+ /** Bind every unbound [data-twk] host under `root` (the document by default). */
26
+ export function enhance(root: Document | Element = document, opts: EnhanceOptions = {}): Promise<void> {
16
27
  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
28
+ // the single-file build registered it on load: synchronous, as ever
29
+ if (run || !TW_SPLIT) return run ? run(root, kit, opts.strings) : Promise.resolve();
18
30
  // 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));
31
+ return import("./markup.js").then(
32
+ () => getMarkup<MarkupKit>()!(root, kit, opts.strings),
33
+ (e) => console.error("[tweaks] markup chunk failed to load:", e),
34
+ );
20
35
  }
@@ -1,32 +1,63 @@
1
1
  /* Panel feedback chrome — the toolbar button factories (copy / reset and their one-shot
2
2
  * animations), the hint tooltip and the clipboard helper. The screen-reader announcer they
3
3
  * speak through lives in shared.ts, where the lazy controls reach it too. */
4
- import { el, btn, carrySkin, placeBelow, announce } from "./shared.js";
4
+ import { el, btn, carrySkin, placeBelow, announce, portalTo, words } from "./shared.js";
5
+ import type { Tr } from "./shared.js";
5
6
  import { ICON_COPY, ICON_CHECK, ICON_RESET, ICON_INFO } from "./icons.js";
6
7
 
7
8
  /** A toolbar button with its one-shot animation state: timer, spin settle hook, last click time. */
8
- interface ToolbarBtn extends HTMLButtonElement { _t?: number; _spinSettle?: () => void; _spinT?: number }
9
+ interface ToolbarBtn extends HTMLButtonElement {
10
+ _t?: number;
11
+ _spinSettle?: () => void;
12
+ _spinT?: number;
13
+ }
9
14
 
10
15
  /** An icon toolbar button with matching title and aria-label. */
11
- const toolbarBtn = (cls: string, icon: string, label: string): ToolbarBtn => { const b = btn("tw-toolbar-btn" + (cls ? " " + cls : ""), icon); b.title = label; b.setAttribute("aria-label", label); return b; };
16
+ const toolbarBtn = (cls: string, icon: string, label: string): ToolbarBtn => {
17
+ const b = btn("twk-toolbar-btn" + (cls ? " " + cls : ""), icon);
18
+ b.title = label;
19
+ b.setAttribute("aria-label", label);
20
+ return b;
21
+ };
12
22
  /** Copy: puts `text(changedOnly)` on the clipboard and confirms with the check swap, and to a screen reader in words.
13
23
  * `text` returns the payload and a count (-1 for the full snapshot; 0 copies nothing).
14
24
  * Both handlers bail while `disabled`: a synthetic dispatchEvent(click) reaches a disabled button. */
15
- const makeCopyBtn = (text: (changedOnly: boolean) => [string, number]) => {
16
- const b = toolbarBtn("tw-toolbar-btn--swap", `<span class="tw-toolbar-btn__icons">${ICON_COPY}${ICON_CHECK}</span>`, "Copy values (⇧: changed only)");
25
+ const makeCopyBtn = (text: (changedOnly: boolean) => [string, number], tr: Tr = words()) => {
26
+ const b = toolbarBtn(
27
+ "twk-toolbar-btn--swap",
28
+ `<span class="twk-toolbar-btn__icons">${ICON_COPY}${ICON_CHECK}</span>`,
29
+ tr("copy", "Copy values (⇧: changed only)"),
30
+ );
17
31
  b.addEventListener("click", async (e) => {
18
32
  if (b.disabled) return;
19
33
  const [t, n] = text(e.shiftKey);
20
- if (!n) return announce("Nothing has changed");
21
- if (!(await copyText(t))) return announce("Copy failed");
22
- flashCopied(b); announce(n > 0 ? `${n} changed value${n === 1 ? "" : "s"} copied` : "Values copied");
34
+ if (!n) return announce(tr("nothingChanged", "Nothing has changed"));
35
+ if (!(await copyText(t))) return announce(tr("copyFailed", "Copy failed"));
36
+ flashCopied(b);
37
+ announce(
38
+ n > 0
39
+ ? tr("copiedChanged", (v) => `${v.n} changed value${v.n === 1 ? "" : "s"} copied`, { n })
40
+ : tr("copied", "Values copied"),
41
+ );
23
42
  });
24
43
  return b;
25
44
  };
26
45
  /** Reset: runs `onReset` behind the icon's one-shot spin. */
27
- const makeResetBtn = (onReset: () => void) => { const b = toolbarBtn("tw-toolbar-btn--reset", ICON_RESET, "Reset"); b.addEventListener("click", () => { if (b.disabled) return; spinReset(b); onReset(); }); return b; };
28
- const flashCopied = (btn: ToolbarBtn) => { btn.classList.add("is-copied"); clearTimeout(btn._t); btn._t = setTimeout(() => btn.classList.remove("is-copied"), 1400); };
29
- /** Reset spin — an accumulated rotation on --tw-spin driven by the transform transition, so a
46
+ const makeResetBtn = (onReset: () => void, tr: Tr = words()) => {
47
+ const b = toolbarBtn("twk-toolbar-btn--reset", ICON_RESET, tr("reset", "Reset"));
48
+ b.addEventListener("click", () => {
49
+ if (b.disabled) return;
50
+ spinReset(b);
51
+ onReset();
52
+ });
53
+ return b;
54
+ };
55
+ const flashCopied = (btn: ToolbarBtn) => {
56
+ btn.classList.add("is-copied");
57
+ clearTimeout(btn._t);
58
+ btn._t = setTimeout(() => btn.classList.remove("is-copied"), 1400);
59
+ };
60
+ /** Reset spin — an accumulated rotation on --twk-spin driven by the transform transition, so a
30
61
  * second click mid-spin retargets smoothly instead of snapping back. Once settled the counter
31
62
  * renormalizes to 0 with the transition suppressed (−n·360° is the same angle). "Settled" is the
32
63
  * transform's own transitionend, not a timer: the hover wind-up shares the transform and restarts
@@ -38,56 +69,96 @@ const spinReset = (btn: ToolbarBtn) => {
38
69
  btn._spinSettle = () => {
39
70
  clearTimeout(btn._t);
40
71
  btn.classList.remove("is-spinning");
41
- svg.style.transition = "none"; svg.style.setProperty("--tw-spin", "0deg");
72
+ svg.style.transition = "none";
73
+ svg.style.setProperty("--twk-spin", "0deg");
42
74
  // getBoundingClientRect, not offsetWidth: on an SVG element offsetWidth is undefined, so the
43
75
  // reflow idiom flushed nothing and the −n·360° → 0 change animated as a visible unwind.
44
76
  void svg.getBoundingClientRect();
45
77
  svg.style.transition = "";
46
78
  };
47
79
  svg.addEventListener("transitionend", (e) => {
48
- if (e.propertyName === "transform" && btn.classList.contains("is-spinning") && performance.now() - btn._spinT > 250) btn._spinSettle();
80
+ if (
81
+ e.propertyName === "transform" &&
82
+ btn.classList.contains("is-spinning") &&
83
+ performance.now() - btn._spinT > 250
84
+ )
85
+ btn._spinSettle();
49
86
  });
50
87
  }
51
88
  btn._spinT = performance.now();
52
- svg.style.setProperty("--tw-spin", `${(parseFloat(svg.style.getPropertyValue("--tw-spin")) || 0) - 360}deg`);
89
+ svg.style.setProperty("--twk-spin", `${(parseFloat(svg.style.getPropertyValue("--twk-spin")) || 0) - 360}deg`);
53
90
  btn.classList.add("is-spinning");
54
91
  clearTimeout(btn._t);
55
- const fallback = () => { if (svg.getAnimations && svg.getAnimations().length) btn._t = setTimeout(fallback, 250); else btn._spinSettle(); };
92
+ const fallback = () => {
93
+ if (svg.getAnimations && svg.getAnimations().length) btn._t = setTimeout(fallback, 250);
94
+ else btn._spinSettle();
95
+ };
56
96
  btn._t = setTimeout(fallback, 600);
57
97
  };
58
98
 
59
- // Hint tooltip — one shared bubble, portaled to <body> so it clears the panel's overflow clip.
60
- let hintTip: HTMLDivElement | null = null, hintTimer = 0, hintAnchor: Element | null = null;
61
- const onHintKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); hideHintNow(); } }; // bound only while open; WCAG 1.4.13 (dismissable)
99
+ // Hint tooltip — one shared bubble, portaled where its panel's portals go (<body>, or a shadow panel's portal root) so
100
+ // it clears the panel's overflow clip.
101
+ let hintTip: HTMLDivElement | null = null,
102
+ hintTimer = 0,
103
+ hintAnchor: Element | null = null;
104
+ // bound only while open; WCAG 1.4.13 (dismissable)
105
+ const onHintKey = (e: KeyboardEvent) => {
106
+ if (e.key === "Escape") {
107
+ e.stopPropagation();
108
+ hideHintNow();
109
+ }
110
+ };
62
111
  /** Hide the tip now; with `owner` (panel.destroy()), only a tip anchored inside it — the node is shared. */
63
- const hideHintNow = (owner?: Element) => { if (owner && hintAnchor && !owner.contains(hintAnchor)) return; clearTimeout(hintTimer); document.removeEventListener("keydown", onHintKey); if (hintTip) hintTip.classList.remove("is-open"); };
112
+ const hideHintNow = (owner?: Element) => {
113
+ if (owner && hintAnchor && !owner.contains(hintAnchor)) return;
114
+ clearTimeout(hintTimer);
115
+ document.removeEventListener("keydown", onHintKey);
116
+ if (hintTip) hintTip.classList.remove("is-open");
117
+ };
64
118
  function showHint(anchor: Element, text: string) {
65
- if (!hintTip) { hintTip = el("div", "tw-tip tw-portal"); hintTip.setAttribute("role", "tooltip"); }
66
- if (!hintTip.isConnected) document.body.appendChild(hintTip); // re-home after a host replaced <body>'s content
119
+ if (!hintTip) {
120
+ hintTip = el("div", "twk-tip twk-portal");
121
+ hintTip.setAttribute("role", "tooltip");
122
+ }
123
+ // each show: the last hint may have been another root's, or a host replaced <body>'s content
124
+ portalTo(hintTip, anchor);
67
125
  clearTimeout(hintTimer);
68
126
  hintTip.textContent = text;
69
127
  carrySkin(hintTip, anchor); // resolved at show time: setTheme may have run since build
70
128
  const wasOpen = hintTip.classList.contains("is-open");
71
- hintTip.style.visibility = "hidden"; hintTip.classList.add("is-open");
129
+ hintTip.style.visibility = "hidden";
130
+ hintTip.classList.add("is-open");
72
131
  placeBelow(anchor, hintTip, { align: "center", prefer: "above", gap: 8 });
73
132
  hintTip.style.visibility = "";
74
133
  document.addEventListener("keydown", onHintKey);
75
134
  hintAnchor = anchor;
76
135
  // Unmount watchdog: a host removing the panel mid-hover would strand the open tip. One rAF per frame while open.
77
- if (!wasOpen) requestAnimationFrame(function watch() { if (!hintTip.classList.contains("is-open")) return; if (!hintAnchor.isConnected) return hideHintNow(); requestAnimationFrame(watch); });
136
+ if (!wasOpen)
137
+ requestAnimationFrame(function watch() {
138
+ if (!hintTip.classList.contains("is-open")) return;
139
+ if (!hintAnchor.isConnected) return hideHintNow();
140
+ requestAnimationFrame(watch);
141
+ });
142
+ }
143
+ // a short grace, so crossing marker → tip doesn't flicker
144
+ function hideHint() {
145
+ if (hintTip) {
146
+ clearTimeout(hintTimer);
147
+ hintTimer = setTimeout(hideHintNow, 80);
148
+ }
78
149
  }
79
- function hideHint() { if (hintTip) { clearTimeout(hintTimer); hintTimer = setTimeout(hideHintNow, 80); } } // a short grace, so crossing marker → tip doesn't flicker
80
150
  /** Every control's label element — the one list behind the hint marker's home and the per-control
81
151
  * reset target (panel.ts wireReset), so the two can't drift. */
82
- const LABEL_SEL = ".tw-slider-label, .tw-row-label, .tw-select-label, .tw-trigger-label, .tw-radiogrid-label, .tw-field-label, .tw-folder-title, .tw-fps-label, .tw-plot-label, .tw-spring-label, .tw-bezier-label";
152
+ const LABEL_SEL =
153
+ ".twk-slider-label, .twk-row-label, .twk-select-label, .twk-trigger-label, .twk-radiogrid-label, .twk-field-label, .twk-folder-title, .twk-fps-label, .twk-plot-label, .twk-spring-label, .twk-bezier-label";
83
154
  /** A control's `hint` becomes a ⓘ marker beside its label that reveals the tooltip on hover/focus. */
84
155
  function addHintMarker(node: Element, hint: string) {
85
156
  const label = node.querySelector(LABEL_SEL) || node;
86
- if (label === node && node.classList.contains("tw-separator")) return; // a divider has no label to carry a marker
157
+ if (label === node && node.classList.contains("twk-separator")) return; // a divider has no label to carry a marker
87
158
  // Inside a <button> (select trigger, folder header), which can't contain interactive content, the
88
159
  // ⓘ is a decorative span and the hint rides the host button's aria-description for screen readers.
89
160
  const hostBtn = label.closest("button");
90
- const mark = hostBtn ? el("span", "tw-hint", ICON_INFO) : btn("tw-hint", ICON_INFO);
161
+ const mark = hostBtn ? el("span", "twk-hint", ICON_INFO) : btn("twk-hint", ICON_INFO);
91
162
  const show = () => showHint(mark, hint);
92
163
  if (hostBtn) {
93
164
  mark.setAttribute("aria-hidden", "true");
@@ -97,23 +168,51 @@ function addHintMarker(node: Element, hint: string) {
97
168
  mark.setAttribute("aria-label", hint);
98
169
  mark.addEventListener("focus", show);
99
170
  mark.addEventListener("blur", hideHint);
100
- mark.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") e.stopPropagation(); }); // keyboard activation stays on the marker
171
+ // keyboard activation stays on the marker
172
+ mark.addEventListener("keydown", (e) => {
173
+ if (e.key === "Enter" || e.key === " ") e.stopPropagation();
174
+ });
101
175
  }
102
176
  mark.addEventListener("pointerenter", show);
103
177
  mark.addEventListener("pointerleave", hideHint);
104
178
  mark.addEventListener("pointerdown", (e) => e.stopPropagation()); // mustn't start a slider scrub or panel drag
105
- mark.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); }); // and (inside a control button) mustn't toggle the parent
179
+ // and (inside a control button) mustn't toggle the parent
180
+ mark.addEventListener("click", (e) => {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+ });
106
184
  label.appendChild(mark);
107
185
  }
108
186
  async function copyText(text: string) {
109
- try { await navigator.clipboard.writeText(text); return true; } catch {}
187
+ try {
188
+ await navigator.clipboard.writeText(text);
189
+ return true;
190
+ } catch {}
110
191
  // Fallback when the clipboard API is blocked (no user activation): textarea + execCommand.
111
192
  const ta = document.createElement("textarea");
112
- ta.value = text; ta.style.position = "fixed"; ta.style.top = "-9999px";
113
- document.body.appendChild(ta); ta.focus(); ta.select();
114
- let ok = false; try { ok = document.execCommand("copy"); } catch {}
193
+ ta.value = text;
194
+ ta.style.position = "fixed";
195
+ ta.style.top = "-9999px";
196
+ document.body.appendChild(ta);
197
+ ta.focus();
198
+ ta.select();
199
+ let ok = false;
200
+ try {
201
+ ok = document.execCommand("copy");
202
+ } catch {}
115
203
  document.body.removeChild(ta);
116
204
  return ok;
117
205
  }
118
206
 
119
- export { toolbarBtn, makeCopyBtn, makeResetBtn, flashCopied, copyText, spinReset, announce, hideHintNow, addHintMarker, LABEL_SEL };
207
+ export {
208
+ toolbarBtn,
209
+ makeCopyBtn,
210
+ makeResetBtn,
211
+ flashCopied,
212
+ copyText,
213
+ spinReset,
214
+ announce,
215
+ hideHintNow,
216
+ addHintMarker,
217
+ LABEL_SEL,
218
+ };
@@ -7,15 +7,27 @@ import { REDUCE_MOTION } from "./shared.js";
7
7
 
8
8
  /** Fold `node` shut or open; reduced motion, no Animations API or a row with nothing drawn just toggle the class. */
9
9
  export const fold = (node: HTMLElement & { _fold?: Animation }, hide: boolean) => {
10
- const cs = getComputedStyle(node), at = node._fold?.playState == "running" && { marginBottom: cs.marginBottom, clipPath: cs.clipPath, opacity: cs.opacity };
11
- node._fold?.cancel(); node.classList.remove("tw-cond-hidden");
10
+ const cs = getComputedStyle(node),
11
+ at = node._fold?.playState == "running" && {
12
+ marginBottom: cs.marginBottom,
13
+ clipPath: cs.clipPath,
14
+ opacity: cs.opacity,
15
+ };
16
+ node._fold?.cancel();
17
+ node.classList.remove("twk-cond-hidden");
12
18
  const h = !REDUCE_MOTION.matches && node.animate && node.offsetHeight;
13
- node.classList.toggle("tw-cond-hidden", hide);
14
- if (h) try {
15
- // d is the room the row takes: its height, the 6px stack gap (tweaks.css) and its own top margin.
16
- const d = h + 6 + parseFloat(cs.marginTop), H = h + "px", shut = { height: H, marginBottom: -d + "px", clipPath: `inset(0 0 ${d}px)`, opacity: 0 }, open = { height: H, clipPath: "inset(0)" };
17
- (node._fold = node.animate([at || (hide ? open : shut), hide ? shut : open], { duration: 350, easing: cs.getPropertyValue("--tw-ease-fold").trim() }))
18
- .onfinish = () => node.classList.toggle("tw-cond-hidden", hide);
19
- node.classList.remove("tw-cond-hidden"); // after animate(): a refused easing leaves the toggle done
20
- } catch {}
19
+ node.classList.toggle("twk-cond-hidden", hide);
20
+ if (h)
21
+ try {
22
+ // d is the room the row takes: its height, the 6px stack gap (tweaks.css) and its own top margin.
23
+ const d = h + 6 + parseFloat(cs.marginTop),
24
+ H = h + "px",
25
+ shut = { height: H, marginBottom: -d + "px", clipPath: `inset(0 0 ${d}px)`, opacity: 0 },
26
+ open = { height: H, clipPath: "inset(0)" };
27
+ (node._fold = node.animate([at || (hide ? open : shut), hide ? shut : open], {
28
+ duration: 350,
29
+ easing: cs.getPropertyValue("--twk-ease-fold").trim(),
30
+ })).onfinish = () => node.classList.toggle("twk-cond-hidden", hide);
31
+ node.classList.remove("twk-cond-hidden"); // after animate(): a refused easing leaves the toggle done
32
+ } catch {}
21
33
  };