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,85 @@
1
+ /* The presets menu (opts.persist): the toolbar button's pane for saving, loading and deleting named snapshots. Its own
2
+ * chunk, loaded when the button is first hovered, focused or pressed: a panel whose button nobody reaches never
3
+ * downloads it. */
4
+ import { el, btn, txt, popover, quietFocus } from "./shared.js";
5
+ import type { Tr } from "./shared.js";
6
+
7
+ /** Build the menu on `trigger`. `save` is false when storage refused the write. Returns the popover, which owns the
8
+ * button's clicks from here on. `announce` and the delete icon come from the core: imported here, they would split
9
+ * feedback.ts and icons.ts into a chunk of their own that every panel downloads. `tr` is the panel's words. */
10
+ export const presetsMenu = (
11
+ trigger: HTMLButtonElement,
12
+ names: () => string[],
13
+ save: (nm: string) => boolean,
14
+ load: (nm: string) => void,
15
+ remove: (nm: string) => void,
16
+ announce: (msg: string) => void,
17
+ iconX: string,
18
+ tr: Tr,
19
+ ) => {
20
+ const menu = el("div", "twk-presets-menu");
21
+ const saveRow = el("div", "twk-presets-save");
22
+ const input = el("input", "twk-presets-input");
23
+ input.type = "text";
24
+ input.placeholder = tr("presetName", "Preset name…");
25
+ input.spellcheck = false;
26
+ input.setAttribute("aria-label", tr("newPreset", "New preset name"));
27
+ quietFocus(input);
28
+ const saveBtn = txt("button", "twk-presets-savebtn", tr("save", "Save"));
29
+ saveRow.append(input, saveBtn);
30
+ const list = el("div", "twk-presets-list");
31
+ menu.append(saveRow, list);
32
+ const renderList = () => {
33
+ const all = names();
34
+ list.replaceChildren();
35
+ if (!all.length) return list.append(txt("div", "twk-presets-empty", tr("noPresets", "No presets yet")));
36
+ for (const nm of all) {
37
+ const row = el("div", "twk-presets-row");
38
+ const loadBtn = txt("button", "twk-presets-load", nm);
39
+ loadBtn.addEventListener("click", () => {
40
+ load(nm);
41
+ menuPop.close();
42
+ announce(tr("presetLoaded", "Loaded preset {name}", { name: nm }));
43
+ });
44
+ const del = btn("twk-presets-del", iconX);
45
+ del.setAttribute("aria-label", tr("presetDelete", "Delete preset {name}", { name: nm }));
46
+ del.addEventListener("click", (e) => {
47
+ e.stopPropagation();
48
+ remove(nm);
49
+ renderList();
50
+ });
51
+ row.append(loadBtn, del);
52
+ list.append(row);
53
+ }
54
+ };
55
+ // a refused save keeps the typed name
56
+ const doSave = () => {
57
+ const nm = input.value.trim();
58
+ if (!nm) {
59
+ input.focus();
60
+ return;
61
+ }
62
+ if (!save(nm)) {
63
+ announce(tr("presetFull", "Preset {name} not saved: storage is full", { name: nm }));
64
+ return;
65
+ }
66
+ input.value = "";
67
+ renderList();
68
+ announce(tr("presetSaved", "Saved preset {name}", { name: nm }));
69
+ };
70
+ // The shared popover shell; align:"end" hangs it off the button's right edge.
71
+ const menuPop = popover(trigger, trigger, menu, {
72
+ width: 208,
73
+ fallbackH: 160,
74
+ align: "end",
75
+ onOpen: () => {
76
+ renderList();
77
+ input.focus();
78
+ },
79
+ });
80
+ saveBtn.addEventListener("click", doSave);
81
+ input.addEventListener("keydown", (e) => {
82
+ if (e.key === "Enter") doSave();
83
+ });
84
+ return menuPop;
85
+ };
@@ -0,0 +1,351 @@
1
+ /* Schema derivation — ONE meta derivation for both entry points: a schema value (shorthand
2
+ * or verbose `{ type }`), and a [data-twk] dataset through markup.ts, parses into the same control meta. A lazy
3
+ * control that reads its own fields needs no table entry. test/registry.test.mjs cross-checks the tables. */
4
+ import {
5
+ titleCase,
6
+ isObj,
7
+ isColorStr,
8
+ optValue,
9
+ getControl,
10
+ controlTypes,
11
+ hasOwn,
12
+ isReservedKey,
13
+ SPRING_KEYS,
14
+ notice,
15
+ words,
16
+ } from "./shared.js";
17
+ import { LAZY_IMPORT } from "./lazy.js";
18
+ import type { SchemaObject, Option, Get, Control } from "./types.js";
19
+
20
+ /** A point control's component spec, as the public verbose form declares it. */
21
+ type PointComponent = Extract<
22
+ SchemaObject,
23
+ { type: "point"; components: ReadonlyArray<unknown> }
24
+ >["components"][number];
25
+ /** One page of a tabs control: its slug key, title and the metas built inside it. */
26
+ interface TabPage {
27
+ key: string;
28
+ title: string;
29
+ children: Meta[];
30
+ }
31
+ /** The normalized control meta — ONE shape for every control type. The per-type fields are
32
+ * optional; type / key / label are always set. `value` is the authored default in the control's own shape. */
33
+ interface Meta {
34
+ type: string;
35
+ key: string;
36
+ label: string;
37
+ value?: any; // host-authored, in the control's own shape
38
+ // Per-control options (ControlOptions), attached by metaFor.
39
+ render?: (get: Get) => boolean;
40
+ disabled?: boolean | ((get: Get) => boolean);
41
+ hint?: string;
42
+ persist?: boolean;
43
+ // Ranges — slider, number, interval, monitor.
44
+ // corners: the sides control labels its four as corners and floors them at 0
45
+ min?: number;
46
+ max?: number;
47
+ step?: number;
48
+ soft?: boolean;
49
+ unit?: string;
50
+ corners?: boolean;
51
+ // slider: readout from the value, and a typed one back
52
+ format?: (value: number) => string;
53
+ parse?: (text: string) => number;
54
+ // Single-select — list, radiogrid.
55
+ options?: Option[];
56
+ cols?: number;
57
+ // Text.
58
+ rows?: number;
59
+ placeholder?: string;
60
+ // Spring (also tolerated top-level, beside the normalized `value`).
61
+ mode?: "time" | "physics";
62
+ stiffness?: number;
63
+ damping?: number;
64
+ mass?: number;
65
+ visualDuration?: number;
66
+ bounce?: number;
67
+ // Point.
68
+ components?: PointComponent[];
69
+ pad?: boolean;
70
+ invertY?: boolean;
71
+ // Gradient: the verbose form may say `stops` for `value`.
72
+ stops?: unknown;
73
+ // Plot.
74
+ expr?: string;
75
+ fn?: ((x: number) => number) | null;
76
+ xMin?: number;
77
+ xMax?: number;
78
+ yMin?: number;
79
+ yMax?: number;
80
+ samples?: number;
81
+ editable?: boolean;
82
+ // Monitor.
83
+ get?: () => number | string;
84
+ graph?: boolean;
85
+ view?: "graph" | "text";
86
+ interval?: number;
87
+ decimals?: number;
88
+ // Image (`options` and `cols` above).
89
+ maxSize?: number;
90
+ fit?: number | false;
91
+ quality?: number;
92
+ upload?: boolean;
93
+ clear?: boolean;
94
+ // Button / button group.
95
+ action?: () => void;
96
+ buttons?: Record<string, () => void> | Array<{ label: string; action: () => void }>;
97
+ // Folder / tabs.
98
+ children?: Meta[];
99
+ pages?: TabPage[];
100
+ }
101
+ /** A verbose handler's result: the control's own fields, or falsy for a malformed shape. */
102
+ type MetaFields = Partial<Meta> | false;
103
+
104
+ // Parse one schema entry → a control meta, or null for an unknown shape. Per-control options
105
+ // (render / disabled / hint / persist) ride on any object-form value, whatever control it infers.
106
+ function metaFor(key: string, value: any, depth = 0): Meta | null {
107
+ if (isReservedKey(key)) return null;
108
+ const meta = baseMetaFor(key, value, depth);
109
+ if (meta && value && typeof value === "object") {
110
+ if (typeof value.render === "function") meta.render = value.render;
111
+ if (value.disabled != null) meta.disabled = value.disabled;
112
+ if (value.hint != null) meta.hint = String(value.hint);
113
+ if (value.persist === false) meta.persist = false;
114
+ }
115
+ return meta;
116
+ }
117
+ // ── Verbose `{ type: "…" }` forms — one handler per control type, returning only the control's
118
+ // own fields; baseMetaFor stamps type / key / label on top, and a handler may override them. A
119
+ // falsy return marks a malformed shape, skipped with a console error like a throw. ──
120
+ // "segmented" is an alias: a single-select from a list renders as the radio grid; the pill is for booleans.
121
+ const radiogridMeta = (v: any): MetaFields =>
122
+ Array.isArray(v.options) && {
123
+ type: "radiogrid",
124
+ options: v.options,
125
+ value: v.value ?? optValue(v.options[0]),
126
+ cols: v.cols,
127
+ };
128
+ // `??` semantics: an explicit "" (no label) survives where `||` fell back to the key.
129
+ const ownLabel = (v: any, label: string) => (v.label == null ? label : String(v.label));
130
+ const fnOf = (f: unknown) => (typeof f === "function" ? (f as any) : undefined);
131
+ const unitOf = (u: unknown) => (typeof u === "string" && u.trim() ? u.trim().slice(0, 12) : undefined);
132
+ // Typed against the public SchemaObject union, so tsc flags a control type added to types.ts
133
+ // but missing here. "button" has no handler: the `{ action }` shorthand covers its verbose form.
134
+ const TYPED_META: Partial<Record<SchemaObject["type"], (v: any, depth: number, key: string) => MetaFields>> = {
135
+ // Absent min/max derive in the control from the value the range must contain, like the bare-number shorthand:
136
+ // `{ type: "slider", value: 50 }` spans 0–150 like `size: 50`.
137
+ slider: (v) => ({
138
+ value: v.value ?? v.min ?? 0,
139
+ min: v.min,
140
+ max: v.max,
141
+ step: v.step,
142
+ soft: v.soft,
143
+ unit: unitOf(v.unit),
144
+ format: fnOf(v.format),
145
+ parse: fnOf(v.parse),
146
+ }),
147
+ // no step: the number control (heavy.ts) fits one to the default
148
+ number: (v) => ({ value: v.value ?? 0, min: v.min, max: v.max, step: v.step, soft: v.soft, unit: unitOf(v.unit) }),
149
+ checkbox: (v) => ({ value: !!v.value }),
150
+ radiogrid: radiogridMeta,
151
+ segmented: radiogridMeta,
152
+ list: (v) => Array.isArray(v.options) && { options: v.options, value: v.value ?? optValue(v.options[0]) },
153
+
154
+ text: (v) => ({ value: v.value ?? "", rows: v.rows, placeholder: v.placeholder }),
155
+ // The config reads off the top level or a nested `value: {…}`, both published forms; the time
156
+ // pair and mode ride along only when present, so the control can infer the mode.
157
+ spring: (v) => {
158
+ const s = isObj(v.value) ? v.value : v;
159
+ const value: any = { stiffness: s.stiffness ?? 300, damping: s.damping ?? 26, mass: s.mass ?? 1 };
160
+ if (Number.isFinite(+s.visualDuration)) value.visualDuration = +s.visualDuration;
161
+ if (Number.isFinite(+s.bounce)) value.bounce = +s.bounce;
162
+ const mode = v.mode ?? s.mode;
163
+ if (mode === "time" || mode === "physics") value.mode = mode; // rides on the value, so a reset restores the mode
164
+ return { value };
165
+ },
166
+
167
+ // Either mode's fields, top-level or nested, or `value` as a bare curve; the control infers
168
+ // the mode (spring keys → spring) and fills the defaults.
169
+ motion: (v) => {
170
+ // the array test first: isObj is true of an array
171
+ const s =
172
+ typeof v.value === "string" || Array.isArray(v.value) ? { ...v, curve: v.value } : isObj(v.value) ? v.value : v;
173
+ const springy = SPRING_KEYS.some((k) => s[k] != null);
174
+ // Both sides get a default, so a reset restores the editor you weren't looking at too.
175
+ const value: any = { curve: "ease", duration: 300, ...(springy ? {} : { visualDuration: 0.5, bounce: 0.2 }) };
176
+ for (const k of ["mode", "curve", "duration", ...SPRING_KEYS]) if (s[k] != null) value[k] = s[k];
177
+ if (value.mode !== "easing" && value.mode !== "spring") value.mode = springy ? "spring" : "easing";
178
+ return { value };
179
+ },
180
+ point: (v, _depth, at) => {
181
+ // `value: { x: 0, y: 0 }`, the shape the control hands back, names the components itself; a value beside components
182
+ // sets their defaults. With neither, a value makes it malformed, and no value a folder with a child named `type`. A
183
+ // value's component is a finite number or a numeric string; anything else (null, true, "abc") starts at 0 with one
184
+ // notice. A reserved key is skipped.
185
+ const bad: string[] = [];
186
+ const num = (k: string, n: unknown) => {
187
+ const x = typeof n === "number" ? n : typeof n === "string" && n.trim() ? +n : NaN;
188
+ if (Number.isFinite(x)) return x;
189
+ bad.push(k);
190
+ return 0;
191
+ };
192
+ const named =
193
+ isObj(v.value) && !Array.isArray(v.value) ? Object.keys(v.value).filter((k) => !isReservedKey(k)) : [];
194
+ if ((!Array.isArray(v.components) || !v.components.length) && named.length)
195
+ v = { ...v, components: named.map((key) => ({ key })) };
196
+ if (!Array.isArray(v.components) || !v.components.length) return false;
197
+ // A keyless component takes its label (lower-cased) or index; keys dedupe so two can't share one param.
198
+ const used = new Set<string>();
199
+ const components = v.components.flatMap((c: any, k: number) => {
200
+ const label = c.label ? String(c.label).toLowerCase() : "";
201
+ const base = String(c.key ?? ((isReservedKey(label) ? "" : label) || `c${k}`));
202
+ if (isReservedKey(base)) return [];
203
+ let key = base,
204
+ n = 2;
205
+ while (used.has(key)) key = `${base}-${n++}`;
206
+ used.add(key);
207
+ const own = isObj(v.value) && hasOwn(v.value, key) ? num(key, v.value[key]) : undefined;
208
+ return [{ ...c, key, ...(own !== undefined && { value: own }) }];
209
+ });
210
+ if (bad.length) notice("pointValue", at, bad.join(", "));
211
+ if (!components.length) return false;
212
+ // the default component map, for reset()
213
+ return {
214
+ components,
215
+ pad: v.pad,
216
+ invertY: v.invertY,
217
+ value: Object.fromEntries(components.map((c: PointComponent) => [c.key, c.value ?? 0])),
218
+ };
219
+ },
220
+ buttongroup: (v) => ({ buttons: v.buttons }),
221
+ separator: () => ({}),
222
+ // Page keys dedupe ("A!" and "A?" both slug to "a") so two pages can't share one params subtree.
223
+ tabs: (v, depth) => {
224
+ // no pages is nothing to show, not an empty bar
225
+ if (!v.pages || typeof v.pages !== "object" || !Object.keys(v.pages).length) return false;
226
+ const used = new Set<string>();
227
+ return {
228
+ pages: Object.entries(v.pages).map(([title, schema]: [string, any]) => {
229
+ const base =
230
+ title
231
+ .toLowerCase()
232
+ .replace(/[^a-z0-9]+/g, "-")
233
+ .replace(/^-+|-+$/g, "") || "tab";
234
+ let k = base,
235
+ n = 2;
236
+ while (used.has(k)) k = `${base}-${n++}`;
237
+ used.add(k);
238
+ // an empty title still gets a visible tab
239
+ return {
240
+ key: k,
241
+ title: title.trim() ? title : words()("tab", "Tab"),
242
+ children: Object.entries(schema)
243
+ .map(([ck, sv]) => metaFor(ck, sv, depth + 1))
244
+ .filter(Boolean),
245
+ };
246
+ }),
247
+ };
248
+ },
249
+ };
250
+
251
+ // The field each falsy-capable form hinges on: present-but-unusable is malformed, absent is a folder.
252
+ const REQUIRED: Record<string, string> = Object.assign(Object.create(null), {
253
+ list: "options",
254
+ radiogrid: "options",
255
+ segmented: "options",
256
+ point: "components",
257
+ tabs: "pages",
258
+ });
259
+ function baseMetaFor(key: string, value: any, depth = 0): Meta | null {
260
+ if (depth > 64) return null; // a pathological depth is skipped, not a RangeError
261
+ const label = titleCase(key);
262
+ const meta = (type: string, fields: Partial<Meta>, lab = label): Meta => ({ type, key, label: lab, ...fields });
263
+ if (isObj(value) && !Array.isArray(value) && hasOwn(TYPED_META, value.type)) {
264
+ // own key only: "toString" must miss
265
+ // A handler that throws or returns nothing for a malformed shape skips that control, not
266
+ // the whole panel. The label is resolved here, once, so every verbose form honors `{ label }`.
267
+ let fields: MetaFields;
268
+ // a guard can't narrow a property of an `any`
269
+ try {
270
+ fields = TYPED_META[value.type as keyof typeof TYPED_META]!(value, depth, key);
271
+ } catch (e) {
272
+ console.error(`[tweaks] malformed "${value.type}" schema value for "${key}" — control skipped:`, e);
273
+ return null;
274
+ }
275
+ // a String object would miss m.type === "tabs" downstream
276
+ if (fields) return meta(String(value.type), fields, ownLabel(value, label));
277
+ // Nothing back with the hinge field or a value present is malformed. With neither, this is a plain folder
278
+ // holding a child named `type` (`marker: { type: "point", size: 3 }`) — fall through.
279
+ const need = REQUIRED[value.type];
280
+ if (hasOwn(value, need) || hasOwn(value, "value")) {
281
+ console.error(
282
+ `[tweaks] malformed "${value.type}" schema value for "${key}" — ${hasOwn(value, need) ? `its ${need} can't be used` : `it needs ${need}${value.type === "point" ? " or a { x, y } value" : ""}`}; control skipped`,
283
+ );
284
+ return null;
285
+ }
286
+ }
287
+ // A lazy control with no handler reads its own fields off the verbose form as given.
288
+ // A folder and the color module's picker body are registered, but aren't types to write.
289
+ else if (
290
+ isObj(value) &&
291
+ !Array.isArray(value) &&
292
+ typeof value.type === "string" &&
293
+ value.type !== "folder" &&
294
+ value.type !== "colorpicker" &&
295
+ (LAZY_IMPORT[value.type] || getControl(value.type))
296
+ ) {
297
+ const { type, label: _l, ...fields } = value;
298
+ return meta(type, fields, ownLabel(value, label));
299
+ }
300
+ // A `type` no control answers to (a typo, or another library's name for one). With a field only a control reads, it's
301
+ // meant as one, and skipped: built as a folder, `bezier`'s value array would read as a slider's [value, min, max].
302
+ // With none it's a folder with a child named `type` (`font: { type: "Inter", size: 12 }`). The words are lazy.
303
+ else if (isObj(value) && !Array.isArray(value) && typeof value.type === "string") {
304
+ const control = hasOwn(value, "value") || Array.isArray(value.options) || typeof value.action === "function";
305
+ import("./heavy.js").then(
306
+ (m) =>
307
+ m.unknownType(
308
+ `"${key}"`,
309
+ value.type,
310
+ [...Object.keys(TYPED_META), ...Object.keys(LAZY_IMPORT), ...controlTypes()],
311
+ control ? "control" : "folder",
312
+ ),
313
+ () => {},
314
+ );
315
+ if (control) return null;
316
+ }
317
+ // ── Shorthand inference ──
318
+ // Interval / range: [[lo, hi], min, max, step?] — the first entry is a 2-tuple. Missing bounds and step derive in the
319
+ // control, as for `{ type: "interval" }`: [[20, 80]] spans 0–240 (the tuple's own 20–80 pinned both handles).
320
+ if (Array.isArray(value) && Array.isArray(value[0]) && value[0].length === 2 && typeof value[0][0] === "number")
321
+ return meta("interval", { value: value[0].map(Number), min: value[1], max: value[2], step: value[3] });
322
+ // [value, min?, max?, step?]: a short array ([n] = "just a default") takes the bare-number range. The slider derives
323
+ // a missing or non-number bound and infers the step after, so [5, NaN] can't double 5 on a NaN span.
324
+ if (Array.isArray(value) && value.length <= 4 && typeof value[0] === "number")
325
+ return meta("slider", { value: value[0], min: value[1], max: value[2], step: value[3] });
326
+ if (typeof value === "number") return meta("slider", { value });
327
+ if (typeof value === "boolean") return meta("checkbox", { value });
328
+ if (Array.isArray(value)) return meta("list", { options: value, value: optValue(value[0]) });
329
+ if (isObj(value) && typeof value.action === "function")
330
+ return meta("button", { action: value.action }, ownLabel(value, label));
331
+ if (isObj(value) && Array.isArray(value.options))
332
+ return meta("list", { options: value.options, value: value.value ?? optValue(value.options[0]) });
333
+ if (isColorStr(value)) return meta("color", { value });
334
+ if (typeof value === "string") return meta("text", { value });
335
+ // render / disabled / hint are the folder's chrome, not children.
336
+ if (isObj(value))
337
+ return meta("folder", {
338
+ children: Object.entries(value)
339
+ .filter(
340
+ ([k, v]) =>
341
+ !(k === "render" && typeof v === "function") && !((k === "disabled" || k === "hint") && v != null),
342
+ )
343
+ .map(([k, v]) => metaFor(k, v, depth + 1))
344
+ .filter(Boolean),
345
+ });
346
+ return null;
347
+ }
348
+ // Display/action controls carry no value; the panel build skips entry/reset/persist wiring.
349
+ const VALUELESS = new Set<string>(["button", "fpsgraph", "monitor", "buttongroup", "separator"]);
350
+ export { metaFor, VALUELESS, TYPED_META };
351
+ export type { Meta, TabPage, PointComponent };
@@ -0,0 +1,178 @@
1
+ /* opts.shadow — dresses a panel tweaks() built in a shadow root: the kit's stylesheet in it, a second host on <body>
2
+ * whose
3
+ * root takes its portals (a pane, a hint, the drag guide), and the page's scheme pin and tokens carried across both
4
+ * boundaries. Its own chunk, loaded only by a shadow panel. */
5
+ import { el, carryScheme, requestReflow } from "./shared.js";
6
+
7
+ declare const TW_SPLIT: boolean;
8
+ type Host = HTMLElement & {
9
+ _twPortal?: ShadowRoot;
10
+ _twSync?: () => void;
11
+ _twSlot?: Element;
12
+ _twOn?: (on: boolean) => void;
13
+ };
14
+ // Once a page: the kit's rules that must live outside any shadow root. A browser ignores @property inside one, and the
15
+ // live region, the clipboard field and a lifted panel's slot sit in the page, which a page of shadow panels alone
16
+ // doesn't style.
17
+ let pageRules = false;
18
+ const PAGE_CSS =
19
+ '@property --twk-float-gap { syntax: "<length>"; inherits: true; initial-value: -1px; } .twk-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .twk-lift-slot { display: block; flex: none; pointer-events: none; }';
20
+ const addPageRules = () => {
21
+ if (pageRules) return;
22
+ pageRules = true;
23
+ // no <style>: a strict CSP allows a constructed sheet
24
+ try {
25
+ const s = new CSSStyleSheet();
26
+ s.replaceSync(PAGE_CSS);
27
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, s];
28
+ } catch {
29
+ const s = el("style");
30
+ s.textContent = PAGE_CSS;
31
+ document.head.append(s);
32
+ }
33
+ };
34
+ // The kit's rules as the page already has them (tweakit/css imported or linked), copied into a sheet every shadow root
35
+ // adopts: no download, and a bundler's dev server that moves the module can't lose the file. A rule comes along when it
36
+ // names the kit (twk-): the kit's own, inside @media, @supports and @layer and through @import, and any of the page's
37
+ // rules aimed at the kit's classes, which then style a shadow panel too; a rule aimed at the page's own elements
38
+ // (`#app button`) can't reach in. A @keyframes block comes whole. It's this build's when it has the :host copy of the
39
+ // tokens. Looked for again by each panel until found, so a stylesheet that arrives later is still used.
40
+ let kitSheet: CSSStyleSheet | null = null;
41
+ const kitRules = (rules: CSSRuleList, found: { host: boolean }): string[] =>
42
+ [...rules].flatMap((r) => {
43
+ if ("styleSheet" in r) {
44
+ try {
45
+ return kitRules((r as CSSImportRule).styleSheet!.cssRules, found);
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+ if (!r.cssText.includes("twk-")) return [];
51
+ // appendRule: a @keyframes, kept whole
52
+ if (r instanceof CSSStyleRule || !("cssRules" in r) || "appendRule" in r) {
53
+ if (r.cssText.startsWith(":host")) found.host = true;
54
+ return [r.cssText];
55
+ }
56
+ const inner = kitRules((r as CSSGroupingRule).cssRules, found);
57
+ return inner.length ? [`${r.cssText.slice(0, r.cssText.indexOf("{"))}{${inner.join("")}}`] : [];
58
+ });
59
+ const pageKit = () => {
60
+ if (kitSheet || typeof CSSStyleSheet !== "function" || !("adoptedStyleSheets" in ShadowRoot.prototype))
61
+ return kitSheet;
62
+ const out: string[] = [],
63
+ found = { host: false };
64
+ // a cross-origin sheet can't be read
65
+ for (const s of document.styleSheets) {
66
+ try {
67
+ out.push(...kitRules(s.cssRules, found));
68
+ } catch {}
69
+ }
70
+ if (found.host) {
71
+ kitSheet = new CSSStyleSheet();
72
+ kitSheet.replaceSync(out.join("\n"));
73
+ }
74
+ return kitSheet;
75
+ };
76
+ // tweaks.css in a root: the page's copy when it has one, else a <link> to `href`, by default the file beside this
77
+ // module (dist/tweaks.css). A bundler that rewrites `new URL(…, import.meta.url)` copies it as an asset;
78
+ // `shadow: { href }` else.
79
+ const styled = (root: ShadowRoot, href: string, done?: () => void) => {
80
+ const kit = pageKit();
81
+ if (kit) {
82
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, kit];
83
+ done?.();
84
+ return;
85
+ }
86
+ const link = el("link");
87
+ link.rel = "stylesheet";
88
+ link.href = href;
89
+ if (done) {
90
+ link.addEventListener("load", done);
91
+ link.addEventListener("error", () => {
92
+ console.warn(`[tweaks] shadow: the stylesheet at ${href} didn't load — the panel is unstyled`);
93
+ done();
94
+ });
95
+ }
96
+ root.prepend(link);
97
+ };
98
+ // The host is a <twk-host>, defined here, so it hears when the page connects and disconnects it: the scheme pin is read
99
+ // again where it lands, and a host the page drops without destroy() lets go of its observer and portal root, as a
100
+ // light panel's listeners do (onLive). A page that took the name first keeps it, and its panels keep both until
101
+ // destroy().
102
+ if (typeof customElements !== "undefined" && !customElements.get("twk-host"))
103
+ customElements.define(
104
+ "twk-host",
105
+ class extends HTMLElement {
106
+ connectedCallback() {
107
+ (this as Host)._twOn?.(true);
108
+ }
109
+ disconnectedCallback() {
110
+ (this as Host)._twOn?.(false);
111
+ }
112
+ },
113
+ );
114
+
115
+ /** Style `host`'s shadow panel and open its portal root; returns the teardown destroy() runs. */
116
+ export const dress = (host: Host, panel: HTMLElement, opt: boolean | { href?: string } | undefined) => {
117
+ const href =
118
+ (typeof opt === "object" && opt?.href) ||
119
+ new URL(TW_SPLIT ? "../tweaks.css" : "./tweaks.css", import.meta.url).href;
120
+ addPageRules();
121
+ // Transitions are held while the stylesheet lands, or the panel's first show would animate from its unstyled layout.
122
+ // The controls measured themselves in that layout (a segmented pill, a slider's marks), so they measure again.
123
+ const settle = () => {
124
+ host.style.visibility = "";
125
+ requestReflow();
126
+ requestAnimationFrame(() => requestAnimationFrame(() => host.removeAttribute("data-twk-settling")));
127
+ };
128
+ host.setAttribute("data-twk-settling", "");
129
+ styled(host.shadowRoot!, href, settle);
130
+ // Portals go to a root of their own on <body>, as a light panel's go to <body>: a transformed or clipping ancestor of
131
+ // the host would otherwise capture their fixed position. It's put (back) on <body> whenever a portal asks for it.
132
+ const portalHost = el("div", "twk-portal-host"),
133
+ portals = portalHost.attachShadow({ mode: "open" });
134
+ styled(portals, href);
135
+ Object.defineProperty(host, "_twPortal", {
136
+ configurable: true,
137
+ get: () => {
138
+ if (!portalHost.isConnected) document.body.append(portalHost);
139
+ return portals;
140
+ },
141
+ });
142
+ // A portal's tokens: the panel's own, which page CSS may have set on the host, written on its root's host each time
143
+ // one opens (carrySkin), so a pane matches its panel; what the panel no longer has goes too. The panel's theme is
144
+ // carried per portal as before.
145
+ host._twSync = () => {
146
+ const cs = getComputedStyle(panel),
147
+ s = portalHost.style;
148
+ for (const k of [...s]) if (k.startsWith("--twk-")) s.removeProperty(k);
149
+ for (let i = 0; i < cs.length; i++) {
150
+ const k = cs[i];
151
+ if (k.startsWith("--twk-")) s.setProperty(k, cs.getPropertyValue(k));
152
+ }
153
+ };
154
+ // The scheme pin on the host or an ancestor can't reach in: mirror it onto the panel, and keep it as the page
155
+ // changes.
156
+ // A lifted panel reads it at the slot it left, as a light one carries it on the lift.
157
+ const pin = () => carryScheme(panel, host._twSlot || host);
158
+ const obs = typeof MutationObserver === "function" ? new MutationObserver(pin) : null;
159
+ // A move disconnects and reconnects in one task, so letting go waits a microtask to see whether the host came back.
160
+ host._twOn = (on) => {
161
+ if (on) {
162
+ pin();
163
+ obs?.observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ["data-twk-scheme"] });
164
+ } else
165
+ queueMicrotask(() => {
166
+ if (!host.isConnected) {
167
+ obs?.disconnect();
168
+ portalHost.remove();
169
+ }
170
+ });
171
+ };
172
+ host._twOn(host.isConnected);
173
+ return () => {
174
+ host._twOn = undefined;
175
+ obs?.disconnect();
176
+ portalHost.remove();
177
+ };
178
+ };