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,68 @@
1
+ // ── Tabs — group controls into pages; build() fills the page bodies. Lazy.
2
+ import { el, txt, onReady, measurePill, navIndex, requestReflow, registerControl } from "../shared.js";
3
+ import type { Meta } from "../schema.js";
4
+
5
+ /** The tabs handle: a page body per tab for build() to fill, and the active tab for the panel's UI state. */
6
+ export interface TabsControl {
7
+ el: HTMLDivElement;
8
+ bodies: HTMLDivElement[];
9
+ activate(i: number): void;
10
+ active(): number;
11
+ }
12
+
13
+ let tabsSeq = 0; // unique ids for the tab ↔ tabpanel aria pairing
14
+ function createTabs(meta: Meta): TabsControl {
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");
22
+ const bodies: HTMLDivElement[] = [];
23
+ const tabs = meta.pages.map((page, i) => {
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);
34
+ tab.addEventListener("click", () => activate(i));
35
+ bar.append(tab);
36
+ return tab;
37
+ });
38
+ root.append(bar, pagesWrap);
39
+ const measure = (animate?: boolean) => measurePill(bar, pill, animate); // slide the pill to the active tab
40
+ // The active tab and page; the roving tabindex is set from the build, not only after a switch.
41
+ const mark = (i: number) => {
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
+ });
47
+ bodies.forEach((b, k) => (b.dataset.active = String(k === i)));
48
+ };
49
+ mark(0);
50
+ function activate(i: number) {
51
+ mark(i);
52
+ measure(true);
53
+ requestReflow(); // controls built on a display:none page measured 0; let them re-measure
54
+ }
55
+ bar.addEventListener("keydown", (e) => {
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();
63
+ });
64
+ onReady(measure);
65
+ return { el: root, bodies, activate, active: () => tabs.findIndex((t) => t.dataset.active === "true") };
66
+ }
67
+
68
+ registerControl("tabs", createTabs);
@@ -0,0 +1,9 @@
1
+ /* Tweaks — a dependency-free real-time parameter panel. The public entry: tweaks() builds a
2
+ * live panel from a plain schema object, enhance() turns [data-twk] markup into live controls,
3
+ * and mountControl() / createColorPicker() / gradientCss() are the controls without the panel. */
4
+ export { tweaks } from "./panel.js";
5
+ export { enhance } from "./enhance.js";
6
+ export { mountControl, createColorPicker } from "./standalone.js";
7
+ export { gradientCss, gradientStops } from "./easing.js";
8
+ // The public types, so `import type { Schema } from "tweakit"` works from either entry.
9
+ export type * from "./types.js";
@@ -0,0 +1,118 @@
1
+ /* Gradient easing + gradient → CSS: the one place a gradient value becomes a stop list, so
2
+ * the editor draws what a host's CSS draws. Pure string + number work, no DOM.
3
+ * The eye reads a straight blend's ends as an edge and native easing hasn't shipped
4
+ * (csswg-drafts#1332), so Larsen's "easing gradients" are generated here: the curve sampled
5
+ * PARAMETRICALLY at 16 even t (x the position, y the blend), so any cubic-bezier works with
6
+ * no x→t solve; each sample is a color-mix in the value's own blend space. */
7
+ import type { GradientEasing, GradientStop, GradientValue } from "./types.js";
8
+
9
+ /** cubic-bezier control points [x1, y1, x2, y2]. */
10
+ export type Bez = [number, number, number, number];
11
+
12
+ // The CSS easing keywords as cubic-beziers. Null-prototype, so "constructor" can't read a function off
13
+ // Object.prototype.
14
+ export const KEYWORDS: Record<string, Bez> = Object.assign(Object.create(null), {
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],
19
+ });
20
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
21
+ const SAMPLES = 15; // 16 per segment, Larsen's count; the joins vanish
22
+
23
+ /** Bezier control points, or null for linear and anything unrecognized (bad input degrades to a
24
+ * straight blend). x1/x2 clamp into [0,1] as CSS requires; y may overshoot. */
25
+ export function parseEasing(e: unknown): Bez | null {
26
+ if (typeof e !== "string") return null;
27
+ const s = e.trim().toLowerCase();
28
+ if (KEYWORDS[s]) return KEYWORDS[s];
29
+ const m = /^cubic-bezier\(([^()]*)\)$/.exec(s);
30
+ if (!m) return null;
31
+ const n = m[1].split(",").map((x) => (x.trim() ? Number(x) : NaN)); // an empty field is NaN, not Number("") → 0
32
+ if (n.length !== 4 || !n.every(Number.isFinite)) return null;
33
+ return [clamp01(n[0]), n[1], clamp01(n[2]), n[3]];
34
+ }
35
+
36
+ /** The curve at 16 even t: [x, y] pairs, y clamped to [0,1] since it becomes a color-mix percentage. */
37
+ export function easingSamples(b: Bez): Array<[number, number]> {
38
+ const out: Array<[number, number]> = [];
39
+ for (let k = 0; k <= SAMPLES; k++) {
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;
45
+ out.push([p1 * b[0] + p2 * b[2] + p3, clamp01(p1 * b[1] + p2 * b[3] + p3)]);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const pct = (n: number) => +(n * 100).toFixed(2);
51
+
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;
57
+ // The blend spaces CSS takes, and a hue method on the polar ones; anything else (`in junk`, `in 5`) falls to
58
+ // oklch, the default.
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";
63
+
64
+ /** The stops a value can draw, sorted. A hand-built value can hold no stops, or stops without a
65
+ * color or a numeric pos, and any of those builds CSS the browser drops whole, silently;
66
+ * so they're set aside with a warning (heavy.ts badGradient, one per distinct fault). */
67
+ function drawable(value: unknown, fn: string): GradientStop[] {
68
+ const all: unknown = value && typeof value === "object" ? (value as GradientValue).stops : null;
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
+ );
77
+ return ok.sort((a, b) => a.pos - b.pos);
78
+ }
79
+
80
+ /** A gradient value's `<color-stop-list>` with the easing expanded; for a conic / radial gradient.
81
+ * One stop comes back doubled and none as `transparent, transparent`, so the list stays valid. */
82
+ export function gradientStops(value: GradientValue): string {
83
+ const stops = drawable(value, "gradientStops");
84
+ return stops.length ? stopList(value, stops) : "transparent, transparent";
85
+ }
86
+
87
+ function stopList(value: GradientValue, stops: GradientStop[]): string {
88
+ const plain = (s: GradientStop) => `${s.color} ${pct(s.pos)}%`;
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}`;
91
+ const bez = parseEasing(value.easing);
92
+ if (!bez) return stops.map(plain).join(", ");
93
+ const samples = easingSamples(bez),
94
+ space = blend(value);
95
+ const out = [plain(stops[0])];
96
+ for (let i = 1; i < stops.length; i++) {
97
+ const a = stops[i - 1],
98
+ b = stops[i],
99
+ span = b.pos - a.pos;
100
+ // Two stops at one position are a hard edge: no samples between.
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
+ }
106
+ out.push(plain(b));
107
+ }
108
+ return out.join(", ");
109
+ }
110
+
111
+ /** The `linear-gradient(…)` for a gradient value; `direction` is degrees or any CSS direction.
112
+ * One stop draws a solid fill and no stops is `none`, both still valid for background-image. */
113
+ export function gradientCss(value: GradientValue, direction: number | string = "to right"): string {
114
+ const stops = drawable(value, "gradientCss");
115
+ return stops.length
116
+ ? `linear-gradient(in ${blend(value)} ${typeof direction === "number" ? `${direction}deg` : direction}, ${stopList(value, stops)})`
117
+ : "none";
118
+ }
@@ -0,0 +1,35 @@
1
+ /* Markup-driven enhancement, core's side: enhance(). The work lives in markup.ts, a lazy chunk of the code-split build
2
+ * that
3
+ * only a page with markup fetches; core lends it what it shares with the panel, as a kit. Importing core binds nothing:
4
+ * the page scan on load is opt-in, in auto.ts (tweakit/auto), so the package can declare core free of side effects. */
5
+ import { getMarkup } from "./shared.js";
6
+ import { metaFor } from "./schema.js";
7
+ import { ensureForMetas, LAZY_IMPORT } from "./lazy.js";
8
+ import { createFolder, createControl } from "./controls/basic.js";
9
+ import { makeCopyBtn, makeResetBtn, addHintMarker } from "./feedback.js";
10
+ import type { MarkupKit } from "./markup.js";
11
+ import type { EnhanceOptions } from "./types.js";
12
+
13
+ declare const TW_SPLIT: boolean;
14
+ const kit: MarkupKit = {
15
+ metaFor,
16
+ ensureForMetas,
17
+ LAZY_IMPORT,
18
+ createFolder,
19
+ createControl,
20
+ makeCopyBtn,
21
+ makeResetBtn,
22
+ addHintMarker,
23
+ };
24
+
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> {
27
+ const run = getMarkup<MarkupKit>();
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();
30
+ // A chunk that fails to load skips the markup, as a failed control chunk skips its control, instead of rejecting.
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
+ );
35
+ }
@@ -0,0 +1,218 @@
1
+ /* Panel feedback chrome — the toolbar button factories (copy / reset and their one-shot
2
+ * animations), the hint tooltip and the clipboard helper. The screen-reader announcer they
3
+ * speak through lives in shared.ts, where the lazy controls reach it too. */
4
+ import { el, btn, carrySkin, placeBelow, announce, portalTo, words } from "./shared.js";
5
+ import type { Tr } from "./shared.js";
6
+ import { ICON_COPY, ICON_CHECK, ICON_RESET, ICON_INFO } from "./icons.js";
7
+
8
+ /** A toolbar button with its one-shot animation state: timer, spin settle hook, last click time. */
9
+ interface ToolbarBtn extends HTMLButtonElement {
10
+ _t?: number;
11
+ _spinSettle?: () => void;
12
+ _spinT?: number;
13
+ }
14
+
15
+ /** An icon toolbar button with matching title and aria-label. */
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
+ };
22
+ /** Copy: puts `text(changedOnly)` on the clipboard and confirms with the check swap, and to a screen reader in words.
23
+ * `text` returns the payload and a count (-1 for the full snapshot; 0 copies nothing).
24
+ * Both handlers bail while `disabled`: a synthetic dispatchEvent(click) reaches a disabled button. */
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
+ );
31
+ b.addEventListener("click", async (e) => {
32
+ if (b.disabled) return;
33
+ const [t, n] = text(e.shiftKey);
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
+ );
42
+ });
43
+ return b;
44
+ };
45
+ /** Reset: runs `onReset` behind the icon's one-shot spin. */
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
61
+ * second click mid-spin retargets smoothly instead of snapping back. Once settled the counter
62
+ * renormalizes to 0 with the transition suppressed (−n·360° is the same angle). "Settled" is the
63
+ * transform's own transitionend, not a timer: the hover wind-up shares the transform and restarts
64
+ * the clock. An end <250ms after the click is a stale wind settle; the timeout is only a fallback
65
+ * for when no transition runs at all (reduced motion, hidden panel). */
66
+ const spinReset = (btn: ToolbarBtn) => {
67
+ const svg = btn.querySelector("svg");
68
+ if (!btn._spinSettle) {
69
+ btn._spinSettle = () => {
70
+ clearTimeout(btn._t);
71
+ btn.classList.remove("is-spinning");
72
+ svg.style.transition = "none";
73
+ svg.style.setProperty("--twk-spin", "0deg");
74
+ // getBoundingClientRect, not offsetWidth: on an SVG element offsetWidth is undefined, so the
75
+ // reflow idiom flushed nothing and the −n·360° → 0 change animated as a visible unwind.
76
+ void svg.getBoundingClientRect();
77
+ svg.style.transition = "";
78
+ };
79
+ svg.addEventListener("transitionend", (e) => {
80
+ if (
81
+ e.propertyName === "transform" &&
82
+ btn.classList.contains("is-spinning") &&
83
+ performance.now() - btn._spinT > 250
84
+ )
85
+ btn._spinSettle();
86
+ });
87
+ }
88
+ btn._spinT = performance.now();
89
+ svg.style.setProperty("--twk-spin", `${(parseFloat(svg.style.getPropertyValue("--twk-spin")) || 0) - 360}deg`);
90
+ btn.classList.add("is-spinning");
91
+ clearTimeout(btn._t);
92
+ const fallback = () => {
93
+ if (svg.getAnimations && svg.getAnimations().length) btn._t = setTimeout(fallback, 250);
94
+ else btn._spinSettle();
95
+ };
96
+ btn._t = setTimeout(fallback, 600);
97
+ };
98
+
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
+ };
111
+ /** Hide the tip now; with `owner` (panel.destroy()), only a tip anchored inside it — the node is shared. */
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
+ };
118
+ function showHint(anchor: Element, text: string) {
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);
125
+ clearTimeout(hintTimer);
126
+ hintTip.textContent = text;
127
+ carrySkin(hintTip, anchor); // resolved at show time: setTheme may have run since build
128
+ const wasOpen = hintTip.classList.contains("is-open");
129
+ hintTip.style.visibility = "hidden";
130
+ hintTip.classList.add("is-open");
131
+ placeBelow(anchor, hintTip, { align: "center", prefer: "above", gap: 8 });
132
+ hintTip.style.visibility = "";
133
+ document.addEventListener("keydown", onHintKey);
134
+ hintAnchor = anchor;
135
+ // Unmount watchdog: a host removing the panel mid-hover would strand the open tip. One rAF per frame while open.
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
+ }
149
+ }
150
+ /** Every control's label element — the one list behind the hint marker's home and the per-control
151
+ * reset target (panel.ts wireReset), so the two can't drift. */
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";
154
+ /** A control's `hint` becomes a ⓘ marker beside its label that reveals the tooltip on hover/focus. */
155
+ function addHintMarker(node: Element, hint: string) {
156
+ const label = node.querySelector(LABEL_SEL) || node;
157
+ if (label === node && node.classList.contains("twk-separator")) return; // a divider has no label to carry a marker
158
+ // Inside a <button> (select trigger, folder header), which can't contain interactive content, the
159
+ // ⓘ is a decorative span and the hint rides the host button's aria-description for screen readers.
160
+ const hostBtn = label.closest("button");
161
+ const mark = hostBtn ? el("span", "twk-hint", ICON_INFO) : btn("twk-hint", ICON_INFO);
162
+ const show = () => showHint(mark, hint);
163
+ if (hostBtn) {
164
+ mark.setAttribute("aria-hidden", "true");
165
+ const prior = hostBtn.getAttribute("aria-description");
166
+ hostBtn.setAttribute("aria-description", prior ? `${prior}. ${hint}` : hint);
167
+ } else {
168
+ mark.setAttribute("aria-label", hint);
169
+ mark.addEventListener("focus", show);
170
+ mark.addEventListener("blur", hideHint);
171
+ // keyboard activation stays on the marker
172
+ mark.addEventListener("keydown", (e) => {
173
+ if (e.key === "Enter" || e.key === " ") e.stopPropagation();
174
+ });
175
+ }
176
+ mark.addEventListener("pointerenter", show);
177
+ mark.addEventListener("pointerleave", hideHint);
178
+ mark.addEventListener("pointerdown", (e) => e.stopPropagation()); // mustn't start a slider scrub or panel drag
179
+ // and (inside a control button) mustn't toggle the parent
180
+ mark.addEventListener("click", (e) => {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+ });
184
+ label.appendChild(mark);
185
+ }
186
+ async function copyText(text: string) {
187
+ try {
188
+ await navigator.clipboard.writeText(text);
189
+ return true;
190
+ } catch {}
191
+ // Fallback when the clipboard API is blocked (no user activation): textarea + execCommand.
192
+ const ta = document.createElement("textarea");
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 {}
203
+ document.body.removeChild(ta);
204
+ return ok;
205
+ }
206
+
207
+ export {
208
+ toolbarBtn,
209
+ makeCopyBtn,
210
+ makeResetBtn,
211
+ flashCopied,
212
+ copyText,
213
+ spinReset,
214
+ announce,
215
+ hideHintNow,
216
+ addHintMarker,
217
+ LABEL_SEL,
218
+ };
@@ -0,0 +1,33 @@
1
+ /* The fold a row behind a render condition hides and shows with. A panel with conditionals
2
+ * imports it once its first pass is done, so a basic panel never fetches it.
3
+ * The rows below close up over the row's height and gap as it's clipped from the bottom and
4
+ * fades; its own height is held so nothing inside squashes, and flipped mid-fold it turns
5
+ * around from where it's drawn. */
6
+ import { REDUCE_MOTION } from "./shared.js";
7
+
8
+ /** Fold `node` shut or open; reduced motion, no Animations API or a row with nothing drawn just toggle the class. */
9
+ export const fold = (node: HTMLElement & { _fold?: Animation }, hide: boolean) => {
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");
18
+ const h = !REDUCE_MOTION.matches && node.animate && node.offsetHeight;
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 {}
33
+ };