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,189 @@
1
+ // ── Persisted images. An upload's data URL can outgrow localStorage (~5 MB an origin), so a panel with `persist`
2
+ // keeps each one in IndexedDB and saves a short ref in its place: `tw-image:<hash>`. Records are keyed
3
+ // [storage key, hash], so one panel's images read as a range. Loaded only by a panel that persists
4
+ // an image control. Every call settles, never rejects: IndexedDB off or refused reads as null. ──
5
+
6
+ const REF = "tw-image:";
7
+ type Store = IDBObjectStore;
8
+ let db: Promise<IDBDatabase | null> | null = null;
9
+ // An open that never settles (another tab's pending delete or upgrade) reads as unavailable after 3s rather than
10
+ // holding panel.ready forever. The connection closes for another tab's upgrade or delete, and one the browser drops is
11
+ // reopened.
12
+ const open = () =>
13
+ (db ||= new Promise<IDBDatabase | null>((done) => {
14
+ let late = false;
15
+ const t = setTimeout(() => {
16
+ late = true;
17
+ db = null;
18
+ done(null);
19
+ }, 3000);
20
+ // no indexedDB at all
21
+ try {
22
+ const r = indexedDB.open("tweakit", 1);
23
+ r.onupgradeneeded = () => r.result.createObjectStore("images");
24
+ r.onsuccess = () => {
25
+ const d = r.result;
26
+ clearTimeout(t);
27
+ if (late) {
28
+ d.close();
29
+ return;
30
+ }
31
+ d.onversionchange = () => {
32
+ d.close();
33
+ db = null;
34
+ };
35
+ d.onclose = () => {
36
+ db = null;
37
+ };
38
+ done(d);
39
+ };
40
+ r.onerror = () => {
41
+ clearTimeout(t);
42
+ db = null;
43
+ done(null);
44
+ };
45
+ } catch {
46
+ clearTimeout(t);
47
+ done(null);
48
+ }
49
+ }));
50
+ // One transaction; resolves with what `fn` returns once it commits, null if it fails.
51
+ const run = <T>(mode: IDBTransactionMode, fn: (s: Store) => T) =>
52
+ open().then(
53
+ (d) =>
54
+ d &&
55
+ new Promise<T | null>((done) => {
56
+ try {
57
+ const t = d.transaction("images", mode),
58
+ out = fn(t.objectStore("images"));
59
+ t.oncomplete = () => done(out);
60
+ t.onerror = t.onabort = () => done(null);
61
+ } catch {
62
+ done(null);
63
+ }
64
+ }),
65
+ );
66
+ // an array sorts after every string, so [key, []] caps the range
67
+ const range = (key: string) => IDBKeyRange.bound([key], [key, []]);
68
+
69
+ // cyrb53: a 53-bit string hash, plenty to tell one panel's few images apart.
70
+ const hash = (s: string) => {
71
+ let h1 = 0xdeadbeef,
72
+ h2 = 0x41c6ce57;
73
+ for (let i = 0; i < s.length; i++) {
74
+ const c = s.charCodeAt(i);
75
+ h1 = Math.imul(h1 ^ c, 2654435761);
76
+ h2 = Math.imul(h2 ^ c, 1597334677);
77
+ }
78
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
79
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
80
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36) + s.length.toString(36);
81
+ };
82
+
83
+ const refFor = (dataUrl: string) => REF + hash(dataUrl);
84
+
85
+ /** Every image stored under `key`, ref → data URL; null when IndexedDB can't be used. */
86
+ const load = (key: string) =>
87
+ run("readonly", (s) => {
88
+ const out = new Map<string, string>();
89
+ s.openCursor(range(key)).onsuccess = function () {
90
+ const c = this.result;
91
+ if (!c) return;
92
+ if (typeof c.value === "string") out.set(REF + (c.key as string[])[1], c.value);
93
+ c.continue();
94
+ };
95
+ return out;
96
+ });
97
+ /** One image by its ref (a preset another tab saved after this panel loaded); null when it isn't there. */
98
+ const get = (key: string, ref: string) =>
99
+ run("readonly", (s) => {
100
+ const r = s.get([key, ref.slice(REF.length)]);
101
+ return r;
102
+ }).then((r) => (r && typeof r.result === "string" ? r.result : null));
103
+ const put = (key: string, ref: string, dataUrl: string) =>
104
+ run("readwrite", (s) => {
105
+ s.put(dataUrl, [key, ref.slice(REF.length)]);
106
+ return true;
107
+ });
108
+ /** Delete the images under `key` that `refs` name. */
109
+ const prune = (key: string, refs: string[]) =>
110
+ run("readwrite", (s) => {
111
+ for (const r of refs) s.delete([key, r.slice(REF.length)]);
112
+ return true;
113
+ });
114
+
115
+ /** Each ref a stored snapshot or preset names, read from its JSON whatever the schema: a renamed field's preset still
116
+ * counts. */
117
+ const refsIn = (v: unknown) => new Set<string>(JSON.stringify(v ?? null).match(/tw-image:[0-9a-z]+/g) || []);
118
+ /** Delete the records under `key` this panel knows (`known`) that nothing in `named` names any more. */
119
+ const pruneUnnamed = (key: string, known: Map<string, string>, named: unknown[]) => {
120
+ const keep = refsIn(named),
121
+ gone = [...known.keys()].filter((r) => !keep.has(r));
122
+ for (const r of gone) known.delete(r);
123
+ if (gone.length) prune(key, gone);
124
+ };
125
+ /** `presets` with `ref` swapped for `with` everywhere it appears, or null when nothing names it. */
126
+ const swapRef = (presets: unknown, ref: string, withText: string) => {
127
+ const text = JSON.stringify(presets);
128
+ return text.includes(ref) ? JSON.parse(text.split(ref).join(JSON.stringify(withText).slice(1, -1))) : null;
129
+ };
130
+
131
+ /** The store bound to one panel: `known` holds the records it has loaded or written (ref → data URL), `refused` runs
132
+ * when
133
+ * a write fails with its ref (the panel saves again, that image inline), and the presets pair reads and writes its
134
+ * presets. */
135
+ const bind = (
136
+ key: string,
137
+ known: Map<string, string>,
138
+ refused: (ref: string) => void,
139
+ readPresets: () => unknown,
140
+ writePresets: (v: unknown) => boolean,
141
+ ) => {
142
+ const failed = new Set<string>(),
143
+ hashed = new WeakMap<object, [string, string]>();
144
+ // Each row's last data URL and its ref, so a save that didn't touch the image doesn't hash megabytes again.
145
+ const refOf = (row: object, v: string) => {
146
+ const c = hashed.get(row);
147
+ if (c?.[0] === v) return c[1];
148
+ const r = refFor(v);
149
+ hashed.set(row, [v, r]);
150
+ return r;
151
+ };
152
+ // A record the store refused: a preset that names it takes the data URL inline, or an empty image if storage can't
153
+ // fit it.
154
+ const inlinePresets = (ref: string, v: string) => {
155
+ const inlined = swapRef(readPresets(), ref, v);
156
+ if (!inlined || writePresets(inlined)) return;
157
+ writePresets(swapRef(readPresets(), ref, ""));
158
+ console.warn("[tweaks] persist: an image a preset holds didn't fit in storage — the preset keeps the rest");
159
+ };
160
+ return {
161
+ /** The ref `row`'s image saves as, or null to save it inline (the store refused it). Written once, and again
162
+ * whenever
163
+ * `stored` (what storage holds now) no longer names it: a tab that replaced it may have pruned the record. */
164
+ save(row: object, v: string, stored: unknown) {
165
+ const ref = refOf(row, v);
166
+ if (failed.has(ref)) return null;
167
+ if (!known.has(ref) || !refsIn(stored).has(ref)) {
168
+ known.set(ref, v);
169
+ put(key, ref, v).then((ok) => {
170
+ if (ok) return;
171
+ known.delete(ref);
172
+ failed.add(ref);
173
+ refused(ref);
174
+ inlinePresets(ref, v);
175
+ });
176
+ }
177
+ return ref;
178
+ },
179
+ prune: (named: unknown[]) => pruneUnnamed(key, known, named),
180
+ /** A ref this panel hasn't seen (a preset another tab saved), read from the store. */
181
+ fetch: (ref: string) =>
182
+ get(key, ref).then((url) => {
183
+ if (url) known.set(ref, url);
184
+ return url;
185
+ }),
186
+ };
187
+ };
188
+
189
+ export { refFor, load, bind };
@@ -0,0 +1,63 @@
1
+ /* Lazy-control loading — the dynamic-import map behind the code-split build, plus the schema
2
+ * scan that preloads what a panel needs before it assembles. */
3
+ import { getControl } from "./shared.js";
4
+ import type { Meta } from "./schema.js";
5
+
6
+ /** What the preload scan reads off a meta. */
7
+ type MetaNode = Pick<Meta, "type" | "children" | "pages">;
8
+
9
+ // ── Lazy controls — each import registers its constructor(s) into the shared registry ──
10
+ // TW_SPLIT is an esbuild `define`: `false` for the single-file bundle, where single.ts imports
11
+ // every control statically, so this map is empty and esbuild drops the import()s.
12
+ declare const TW_SPLIT: boolean;
13
+ // null-prototype: "constructor" isn't a chunk
14
+ const LAZY_IMPORT: Record<string, () => Promise<unknown>> = Object.assign(
15
+ Object.create(null),
16
+ TW_SPLIT
17
+ ? {
18
+ number: () => import("./heavy.js"), // heavy.js registers the Number control itself
19
+ interval: () => import("./controls/interval.js"),
20
+ color: () => import("./controls/color.js"),
21
+ gradient: () => import("./controls/gradient.js"),
22
+ shadow: () => import("./controls/shadow.js"),
23
+ sides: () => import("./controls/sides.js"),
24
+ tabs: () => import("./controls/tabs.js"),
25
+ image: () => import("./controls/image.js"),
26
+ fpsgraph: () => import("./controls/monitor.js"),
27
+ monitor: () => import("./controls/monitor.js"),
28
+ spring: () => import("./controls/spring.js"),
29
+ cubicbezier: () => import("./controls/bezier.js"),
30
+ // motion registers last, so a failed editor chunk leaves it unregistered for the next panel to retry.
31
+ motion: () =>
32
+ Promise.all([import("./controls/bezier.js"), import("./controls/spring.js")]).then(
33
+ () => import("./controls/motion.js"),
34
+ ),
35
+ point: () => import("./controls/point.js"),
36
+ plot: () => import("./controls/plot.js"),
37
+ }
38
+ : {},
39
+ );
40
+ const loading: Record<string, Promise<unknown>> = {};
41
+ // a rejection isn't cached, so a later panel retries
42
+ const ensure = (type: string) =>
43
+ getControl(type) || !LAZY_IMPORT[type]
44
+ ? null
45
+ : (loading[type] ||= LAZY_IMPORT[type]().catch((e) => {
46
+ delete loading[type];
47
+ console.error(`[tweaks] control chunk "${type}" failed to load:`, e);
48
+ throw e;
49
+ }));
50
+ const scanTypes = (metas: MetaNode[], set = new Set<string>()) => {
51
+ for (const m of metas) {
52
+ set.add(m.type);
53
+ if (m.children) scanTypes(m.children, set);
54
+ if (m.pages) for (const pg of m.pages) scanTypes(pg.children, set);
55
+ }
56
+ return set;
57
+ };
58
+ const ensureForMetas = (metas: MetaNode[]) => {
59
+ const pend = [...scanTypes(metas)].map(ensure).filter(Boolean);
60
+ return pend.length ? Promise.all(pend) : null;
61
+ };
62
+
63
+ export { ensureForMetas, scanTypes, LAZY_IMPORT }; // LAZY_IMPORT is exported for the registry sync test only
@@ -0,0 +1,317 @@
1
+ /* Markup-driven enhancement — the work behind enhance(): it turns [data-twk] hosts into live controls, sharing the
2
+ * panel's meta derivation through the kit. A lazy chunk in the code-split build, fetched by the first enhance() on a
3
+ * page with markup (single.ts imports it statically). It imports shared.ts alone: a panel module imported here as well
4
+ * as by core would become a shared chunk every basic panel fetches, so core lends what it needs (enhance.ts builds the
5
+ * kit). */
6
+ import {
7
+ el,
8
+ btn,
9
+ foldBody,
10
+ getControl,
11
+ titleCase,
12
+ setMarkup,
13
+ controlTypes,
14
+ hasOwn,
15
+ valueChanged,
16
+ restoreDefault,
17
+ SPRING_KEYS,
18
+ using,
19
+ words,
20
+ } from "./shared.js";
21
+ import type { Words } from "./shared.js";
22
+ import type { metaFor } from "./schema.js";
23
+ import type { ensureForMetas, LAZY_IMPORT } from "./lazy.js";
24
+ import type { createFolder, createControl } from "./controls/basic.js";
25
+ import type { makeCopyBtn, makeResetBtn, addHintMarker } from "./feedback.js";
26
+ import type { Control, SchemaObject } from "./types.js";
27
+
28
+ /** What core lends the markup chunk. */
29
+ export interface MarkupKit {
30
+ metaFor: typeof metaFor;
31
+ ensureForMetas: typeof ensureForMetas;
32
+ LAZY_IMPORT: typeof LAZY_IMPORT;
33
+ createFolder: typeof createFolder;
34
+ createControl: typeof createControl;
35
+ makeCopyBtn: typeof makeCopyBtn;
36
+ makeResetBtn: typeof makeResetBtn;
37
+ addHintMarker: typeof addHintMarker;
38
+ }
39
+
40
+ // ── The markup forms — each [data-twk] type parses its dataset into the same verbose
41
+ // schema value TYPED_META consumes, then rides metaFor: ONE derivation, so markup defaults can't drift. ──
42
+ // the motion control reads the same markup
43
+ const springData = (d: DOMStringMap) => ({
44
+ ...Object.fromEntries(SPRING_KEYS.map((k) => [k, num(d[k])])),
45
+ mode: d.mode,
46
+ });
47
+ // the radio grid, by either name
48
+ const choices = (d: DOMStringMap) => ({ options: splitList(d.options), value: d.value, cols: num(d.cols) });
49
+ const DATA_VALUE: Partial<Record<SchemaObject["type"], (d: DOMStringMap, host: HTMLElement, label: string) => object>> =
50
+ {
51
+ // absent bounds derive from the value, like the schema
52
+ slider: (d) => ({
53
+ value: num(d.value),
54
+ min: num(d.min),
55
+ max: num(d.max),
56
+ step: num(d.step),
57
+ soft: flag(d.soft),
58
+ unit: d.unit,
59
+ }),
60
+ checkbox: (d) => (d.options ? { type: "radiogrid", ...choices(d) } : { value: d.checked === "true" }),
61
+ radiogrid: choices,
62
+ segmented: choices,
63
+ list: (d) => ({ options: splitList(d.options), value: d.value }),
64
+ // data-options is a comma list, so it takes URLs, not data URLs (they carry a comma).
65
+ image: (d) => ({
66
+ value: d.value,
67
+ options: d.options ? splitList(d.options) : undefined,
68
+ cols: num(d.cols),
69
+ maxSize: num(d.maxSize),
70
+ fit: d.fit === "false" ? false : num(d.fit),
71
+ quality: num(d.quality),
72
+ upload: d.upload === "false" ? false : undefined,
73
+ clear: d.clear === "false" ? false : undefined,
74
+ }),
75
+ buttongroup: (d) => ({ buttons: splitList(d.buttons).map((lab) => ({ label: lab })) }),
76
+ separator: () => ({}),
77
+ number: (d) => ({ value: num(d.value), min: num(d.min), max: num(d.max), step: num(d.step), unit: d.unit }),
78
+ text: (d) => ({ value: d.value ?? "", placeholder: d.placeholder, rows: num(d.rows) }),
79
+ fpsgraph: (d) => ({ label: d.label ?? "FPS" }),
80
+ interval: (d) => ({
81
+ value: d.value ? d.value.split(",").map(Number) : undefined,
82
+ min: num(d.min),
83
+ max: num(d.max),
84
+ step: num(d.step),
85
+ }),
86
+ // "8px 16px", or data-top / -right / -bottom / -left
87
+ sides: (d) => ({
88
+ value: d.value ?? d,
89
+ corners: flag(d.corners),
90
+ unit: d.unit,
91
+ step: num(d.step),
92
+ min: num(d.min),
93
+ max: num(d.max),
94
+ }),
95
+ // a box-shadow string, or data-x / -y / -blur / -color
96
+ shadow: (d) => ({ value: d.value ?? { ...d, inset: flag(d.inset) } }),
97
+ spring: springData,
98
+ // plus data-curve and data-duration
99
+ motion: (d) => ({
100
+ ...springData(d),
101
+ curve: d.curve && d.curve.includes(",") ? d.curve.split(",").map(Number) : d.curve,
102
+ duration: num(d.duration),
103
+ }),
104
+ cubicbezier: (d) => ({ value: d.value ? d.value.split(",").map(Number) : undefined }),
105
+ point: (d) => {
106
+ const vals = (d.value || "").split(",").map((s) => parseFloat(s));
107
+ return {
108
+ // no step: the control derives a continuous one
109
+ components: splitList(d.components || "X,Y").map((lab, i) => ({
110
+ key: lab.toLowerCase(),
111
+ label: lab,
112
+ value: isNaN(vals[i]) ? 0 : vals[i],
113
+ step: num(d.step),
114
+ min: num(d.min),
115
+ max: num(d.max),
116
+ })),
117
+ pad: flag(d.pad),
118
+ invertY: d.invertY === "true",
119
+ };
120
+ },
121
+ plot: (d) => ({
122
+ expr: d.expr,
123
+ xMin: num(d.xmin),
124
+ xMax: num(d.xmax),
125
+ yMin: num(d.ymin),
126
+ yMax: num(d.ymax),
127
+ samples: num(d.samples),
128
+ editable: d.editable !== "false",
129
+ }),
130
+ };
131
+ // non-numeric → undefined, so the default applies
132
+ const num = (s: string | undefined) => {
133
+ if (s == null || s === "") return undefined;
134
+ const n = +s;
135
+ return Number.isFinite(n) ? n : undefined;
136
+ };
137
+ const flag = (s: string | undefined) => s === "true" || s === ""; // boolean attributes: data-x / data-x="true"
138
+ const splitList = (s: string | undefined) =>
139
+ (s || "")
140
+ .split(",")
141
+ .map((t) => t.trim())
142
+ .filter(Boolean);
143
+ const dataMeta = (kit: MarkupKit, host: HTMLElement) => {
144
+ const d = host.dataset,
145
+ type = d.twk;
146
+ // Tabs are panel-only, so a tabs host is left untouched, as the markup page says; built, it fell through to a folder
147
+ // whose missing get() threw out of enhance() and left every later host claimed but empty.
148
+ if (type === "tabs") return null;
149
+ const label = d.label ?? titleCase(d.key || type); // data-label="" means no label, like `label: ""`
150
+ // A lazy type with no parser takes the dataset as its fields.
151
+ const v = hasOwn(DATA_VALUE, type)
152
+ ? DATA_VALUE[type]!(d, host, label)
153
+ : kit.LAZY_IMPORT[type] || getControl(type)
154
+ ? { ...d }
155
+ : null;
156
+ // A typo (`data-twk="slidr"`) rendered nothing and said nothing. The list names what markup builds, so not tabs.
157
+ if (!v) {
158
+ import("./heavy.js").then(
159
+ (m) =>
160
+ m.unknownType(
161
+ `data-twk="${type}"`,
162
+ String(type),
163
+ [...Object.keys(DATA_VALUE), ...Object.keys(kit.LAZY_IMPORT), ...controlTypes()].filter((t) => t !== "tabs"),
164
+ "markup",
165
+ ),
166
+ () => {},
167
+ );
168
+ return null;
169
+ }
170
+ // The label rides the verbose value; v spreads after, so a parser's own type or label wins. A form that comes back a
171
+ // folder (a lazy type's verbose form missing the field it hinges on) is no control either.
172
+ const m = kit.metaFor("v", { type, label, ...v });
173
+ return m && m.type !== "folder" ? m : null;
174
+ };
175
+
176
+ /** The handle enhance() hangs on a bound [data-twk] host: its control, the reset pair and its key. */
177
+ interface HostHandle {
178
+ ctrl: Control;
179
+ raw: unknown;
180
+ def: unknown;
181
+ key: string;
182
+ host: HTMLElement;
183
+ reset: () => void;
184
+ }
185
+ interface HostEl extends HTMLElement {
186
+ _tw?: HostHandle;
187
+ }
188
+
189
+ // Echo a control's value onto its host's data-value, in the comma form schema.ts parses. A plain
190
+ // object (a point, a spring) would stringify as "[object Object]", so take its values in order.
191
+ const writeDataValue = (host: HTMLElement, v: unknown) => {
192
+ host.dataset.value =
193
+ v == null ? "" : Array.isArray(v) ? v.join(",") : typeof v === "object" ? Object.values(v).join(",") : String(v);
194
+ };
195
+ // One change path for a markup host: echo onto data-value, then a bubbling twk:change with { key, value }.
196
+ const announce = (host: HTMLElement, key: string, v: unknown) => {
197
+ writeDataValue(host, v);
198
+ host.dispatchEvent(new CustomEvent("twk:change", { bubbles: true, detail: { key, value: v } }));
199
+ };
200
+ // querySelectorAll sees descendants only — a root that is itself a host counts too (a Document has no matches()).
201
+ const select = <T extends Element = HTMLElement>(root: Document | Element, sel: string): T[] => [
202
+ ...(typeof (root as Element).matches === "function" && (root as Element).matches(sel) ? [root as T] : []),
203
+ ...root.querySelectorAll<T>(sel),
204
+ ];
205
+ /** Bind every unbound [data-twk] host under `root`, in `strings` (enhance()'s option) over the English defaults. */
206
+ async function enhance(root: Document | Element, kit: MarkupKit, strings?: Words): Promise<void> {
207
+ const { createFolder, createControl, makeCopyBtn, makeResetBtn, addHintMarker, ensureForMetas } = kit,
208
+ tr = words(strings);
209
+ // Static showcase panels collapse like the real one: wrap the controls in a .twk-body, make the title the toggle.
210
+ select(root, '.twk-panel[data-mode="inline"]:not([data-twk-panel-bound])').forEach((panel) => {
211
+ const header = panel.querySelector(":scope > .twk-header");
212
+ const controls = panel.querySelector(":scope > .twk-controls");
213
+ if (!header || !controls) return;
214
+ panel.setAttribute("data-twk-panel-bound", "");
215
+ const body = el("div", "twk-body");
216
+ panel.insertBefore(body, controls);
217
+ body.append(controls);
218
+ let toggle = header.querySelector<HTMLButtonElement>(".twk-header-toggle");
219
+ const title = header.querySelector(".twk-title");
220
+ if (!toggle && title) {
221
+ toggle = btn("twk-header-toggle");
222
+ title.replaceWith(toggle);
223
+ toggle.append(title);
224
+ }
225
+ if (!toggle) return;
226
+ toggle.setAttribute("aria-expanded", "true");
227
+ toggle.addEventListener("click", () => foldBody(panel, toggle, body, !panel.classList.contains("is-collapsed")));
228
+ // The same toolbar tweaks() builds, over this panel's own [data-twk] controls (gathered at click time).
229
+ if (!header.querySelector(".twk-toolbar")) {
230
+ const live = () =>
231
+ [...panel.querySelectorAll<HostEl>("[data-twk]")]
232
+ .map((h) => h._tw)
233
+ .filter((t) => t && t.ctrl.get() !== undefined);
234
+ // Two hosts can share a key (a repeated data-key, or the same label): suffix the duplicates.
235
+ const values = () => {
236
+ // null-proto: a "__proto__" data-key is an ordinary key
237
+ const vals: Record<string, unknown> = Object.create(null);
238
+ for (const t of live()) {
239
+ let k = t.key,
240
+ n = 2;
241
+ while (hasOwn(vals, k)) k = `${t.key}-${n++}`;
242
+ vals[k] = t.ctrl.get();
243
+ }
244
+ return JSON.stringify(vals, null, 2);
245
+ };
246
+ const reset = () => {
247
+ for (const t of live()) t.reset();
248
+ };
249
+ const toolbar = el("div", "twk-toolbar");
250
+ // no defaults to diff against: the copy is every value
251
+ toolbar.append(
252
+ makeCopyBtn(() => [values(), -1], tr),
253
+ makeResetBtn(reset, tr),
254
+ );
255
+ header.append(toolbar);
256
+ }
257
+ });
258
+ // Folders first: move child hosts into the collapsible chrome.
259
+ select(root, '[data-twk="folder"]:not([data-twk-bound])').forEach((host) => {
260
+ host.setAttribute("data-twk-bound", "");
261
+ const f = createFolder({ label: host.dataset.label || tr("folder", "Folder") });
262
+ [...host.children].forEach((c) => f.body.append(c));
263
+ host.append(f.el);
264
+ });
265
+ // Claim each host synchronously, so a re-entrant enhance can't double-bind; then load and build.
266
+ const hosts = select<HostEl>(root, "[data-twk]:not([data-twk-bound])")
267
+ .map((h) => ({ host: h, meta: dataMeta(kit, h) }))
268
+ .filter((x) => x.meta);
269
+ hosts.forEach(({ host }) => host.setAttribute("data-twk-bound", ""));
270
+ const pend = ensureForMetas(hosts.map((x) => x.meta));
271
+ if (pend) await pend.catch(() => {}); // a failed chunk skips its controls instead of rejecting
272
+ // One host that fails to build costs only itself: the rest still bind.
273
+ for (const { host, meta } of hosts)
274
+ try {
275
+ const key = host.dataset.key || meta.label;
276
+ // Gated the way the panel gates notify(): a same-value emit announces nothing.
277
+ let last: unknown;
278
+ const emit = (v: unknown) => {
279
+ if (!valueChanged(last, v)) return;
280
+ last = v;
281
+ announce(host, key, v);
282
+ };
283
+ const ctrl = using(strings, () => createControl(meta, emit));
284
+ if (!ctrl) continue;
285
+ if (!host.closest(".twk-panel, .twk-portal")) host.classList.add("twk-portal"); // a bare host gets the token scope
286
+ host.append(ctrl.el);
287
+ if (host.dataset.hint) addHintMarker(ctrl.el, host.dataset.hint);
288
+ // raw = the markup's value, def = the form the control opened on: the panel's reset pair.
289
+ const def = ctrl.get();
290
+ last = def;
291
+ // The handle's set() runs the change path too, where the bare control's set() is silent.
292
+ const handle = {
293
+ ...ctrl,
294
+ set: (v: unknown) => {
295
+ ctrl.set(v);
296
+ emit(ctrl.get());
297
+ },
298
+ };
299
+ host._tw = {
300
+ ctrl: handle,
301
+ raw: meta.value,
302
+ def,
303
+ key,
304
+ host,
305
+ reset: () => {
306
+ restoreDefault(ctrl, meta.value, def);
307
+ emit(ctrl.get());
308
+ },
309
+ };
310
+ } catch (err) {
311
+ console.error(`[tweaks] data-twk="${host.dataset.twk}" could not be built — skipped:`, err);
312
+ }
313
+ }
314
+
315
+ setMarkup(enhance);
316
+
317
+ export { DATA_VALUE }; // for the registry sync test