tweakit 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/AGENTS.md +5 -5
  2. package/README.md +3 -3
  3. package/dist/tweaks/auto.js +1 -1
  4. package/dist/tweaks/auto.js.map +1 -1
  5. package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
  6. package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
  7. package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
  8. package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
  9. package/dist/tweaks/chunk-ETB35KFV.js +2 -0
  10. package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
  11. package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
  12. package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
  13. package/dist/tweaks/clip-A3VI53BK.js +2 -0
  14. package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
  15. package/dist/tweaks/color-UGYMAF3W.js +2 -0
  16. package/dist/tweaks/core.js +1 -1
  17. package/dist/tweaks/core.js.map +3 -3
  18. package/dist/tweaks/fold-GY47HIQA.js +2 -0
  19. package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
  20. package/dist/tweaks/gradient-BTX25WSH.js +2 -0
  21. package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
  22. package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
  23. package/dist/tweaks/image-HTQQ4WX6.js +4 -0
  24. package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
  25. package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
  26. package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
  27. package/dist/tweaks/interval-BHPI4LRN.js +2 -0
  28. package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
  29. package/dist/tweaks/markup-QM53TQJZ.js +2 -0
  30. package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
  31. package/dist/tweaks/monitor-722WIYGW.js +3 -0
  32. package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
  33. package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
  34. package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
  35. package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
  36. package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
  37. package/dist/tweaks/point-XDXKMDXH.js +2 -0
  38. package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
  39. package/dist/tweaks/presets-EUWNSCUW.js +2 -0
  40. package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
  41. package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
  42. package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
  43. package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
  44. package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
  45. package/dist/tweaks/sides-JWIURDA4.js +2 -0
  46. package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
  47. package/dist/tweaks/spring-BF5GBTPM.js +2 -0
  48. package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
  49. package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
  50. package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
  51. package/dist/tweaks.css +1 -1
  52. package/dist/tweaks.js +5 -4
  53. package/dist/tweaks.js.map +3 -3
  54. package/dist/types/tweaks/enhance.d.ts +3 -2
  55. package/dist/types/tweaks/types.d.ts +278 -72
  56. package/package.json +5 -2
  57. package/src/tweaks/auto.ts +9 -5
  58. package/src/tweaks/clip.ts +84 -22
  59. package/src/tweaks/controls/basic.ts +463 -139
  60. package/src/tweaks/controls/bezier.ts +152 -47
  61. package/src/tweaks/controls/color.ts +610 -161
  62. package/src/tweaks/controls/gradient.ts +256 -77
  63. package/src/tweaks/controls/image.ts +388 -132
  64. package/src/tweaks/controls/interval.ts +126 -41
  65. package/src/tweaks/controls/monitor.ts +142 -36
  66. package/src/tweaks/controls/motion.ts +231 -56
  67. package/src/tweaks/controls/plot.ts +321 -101
  68. package/src/tweaks/controls/point.ts +134 -45
  69. package/src/tweaks/controls/shadow.ts +103 -30
  70. package/src/tweaks/controls/sides.ts +150 -40
  71. package/src/tweaks/controls/spring.ts +220 -60
  72. package/src/tweaks/controls/tabs.ts +39 -18
  73. package/src/tweaks/core.ts +1 -1
  74. package/src/tweaks/easing.ts +45 -20
  75. package/src/tweaks/enhance.ts +21 -6
  76. package/src/tweaks/feedback.ts +133 -34
  77. package/src/tweaks/fold.ts +22 -10
  78. package/src/tweaks/heavy.ts +461 -137
  79. package/src/tweaks/icons.ts +12 -5
  80. package/src/tweaks/image-store.ts +131 -43
  81. package/src/tweaks/lazy.ts +36 -19
  82. package/src/tweaks/markup.ts +255 -92
  83. package/src/tweaks/panel.ts +1262 -438
  84. package/src/tweaks/presets.ts +85 -0
  85. package/src/tweaks/schema.ts +213 -51
  86. package/src/tweaks/shadow-root.ts +178 -0
  87. package/src/tweaks/shared.ts +802 -190
  88. package/src/tweaks/standalone.ts +120 -30
  89. package/src/tweaks/types.ts +644 -143
  90. package/src/tweaks/undo.ts +104 -0
  91. package/src/tweaks.css +556 -550
  92. package/src/wide-gamut.ts +473 -125
  93. package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
  94. package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
  95. package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
  96. package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
  97. package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
  98. package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
  99. package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
  100. package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
  101. package/dist/tweaks/clip-WW7JYO4X.js +0 -2
  102. package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
  103. package/dist/tweaks/color-YMMFURP6.js +0 -2
  104. package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
  105. package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
  106. package/dist/tweaks/gradient-JER7PW4D.js +0 -2
  107. package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
  108. package/dist/tweaks/image-H6QSLGO3.js +0 -4
  109. package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
  110. package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
  111. package/dist/tweaks/interval-TEUHQGZN.js +0 -2
  112. package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
  113. package/dist/tweaks/markup-NIBJD6E2.js +0 -2
  114. package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
  115. package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
  116. package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
  117. package/dist/tweaks/motion-FQPUBKKM.js +0 -2
  118. package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
  119. package/dist/tweaks/plot-2HBNBZSG.js +0 -3
  120. package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
  121. package/dist/tweaks/point-VV6XV3I6.js +0 -2
  122. package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
  123. package/dist/tweaks/shadow-5E4E55I2.js +0 -2
  124. package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
  125. package/dist/tweaks/sides-P3STEVYY.js +0 -2
  126. package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
  127. package/dist/tweaks/spring-HUVSXNVN.js +0 -2
  128. package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
  129. package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
  130. package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
  131. /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
  132. /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
@@ -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
+ };
@@ -1,44 +1,102 @@
1
1
  /* Schema derivation — ONE meta derivation for both entry points: a schema value (shorthand
2
- * or verbose `{ type }`), and a [data-tw] dataset through markup.ts, parses into the same control meta. A lazy
2
+ * or verbose `{ type }`), and a [data-twk] dataset through markup.ts, parses into the same control meta. A lazy
3
3
  * control that reads its own fields needs no table entry. test/registry.test.mjs cross-checks the tables. */
4
- import { titleCase, isObj, isColorStr, optValue, getControl, controlTypes, hasOwn, isReservedKey, SPRING_KEYS, notice } from "./shared.js";
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";
5
17
  import { LAZY_IMPORT } from "./lazy.js";
6
18
  import type { SchemaObject, Option, Get, Control } from "./types.js";
7
19
 
8
20
  /** A point control's component spec, as the public verbose form declares it. */
9
- type PointComponent = Extract<SchemaObject, { type: "point"; components: ReadonlyArray<unknown> }>["components"][number];
21
+ type PointComponent = Extract<
22
+ SchemaObject,
23
+ { type: "point"; components: ReadonlyArray<unknown> }
24
+ >["components"][number];
10
25
  /** One page of a tabs control: its slug key, title and the metas built inside it. */
11
- interface TabPage { key: string; title: string; children: Meta[] }
26
+ interface TabPage {
27
+ key: string;
28
+ title: string;
29
+ children: Meta[];
30
+ }
12
31
  /** The normalized control meta — ONE shape for every control type. The per-type fields are
13
32
  * optional; type / key / label are always set. `value` is the authored default in the control's own shape. */
14
33
  interface Meta {
15
- type: string; key: string; label: string;
34
+ type: string;
35
+ key: string;
36
+ label: string;
16
37
  value?: any; // host-authored, in the control's own shape
17
38
  // Per-control options (ControlOptions), attached by metaFor.
18
- render?: (get: Get) => boolean; disabled?: boolean | ((get: Get) => boolean); hint?: string; persist?: boolean;
39
+ render?: (get: Get) => boolean;
40
+ disabled?: boolean | ((get: Get) => boolean);
41
+ hint?: string;
42
+ persist?: boolean;
19
43
  // Ranges — slider, number, interval, monitor.
20
- min?: number; max?: number; step?: number; soft?: boolean; unit?: string; corners?: boolean; // corners: the sides control labels its four as corners and floors them at 0
21
- format?: (value: number) => string; parse?: (text: string) => number; // slider: readout from the value, and a typed one back
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;
22
54
  // Single-select — list, radiogrid.
23
- options?: Option[]; cols?: number;
55
+ options?: Option[];
56
+ cols?: number;
24
57
  // Text.
25
- rows?: number; placeholder?: string;
58
+ rows?: number;
59
+ placeholder?: string;
26
60
  // Spring (also tolerated top-level, beside the normalized `value`).
27
- mode?: "time" | "physics"; stiffness?: number; damping?: number; mass?: number; visualDuration?: number; bounce?: number;
61
+ mode?: "time" | "physics";
62
+ stiffness?: number;
63
+ damping?: number;
64
+ mass?: number;
65
+ visualDuration?: number;
66
+ bounce?: number;
28
67
  // Point.
29
- components?: PointComponent[]; pad?: boolean; invertY?: boolean;
68
+ components?: PointComponent[];
69
+ pad?: boolean;
70
+ invertY?: boolean;
30
71
  // Gradient: the verbose form may say `stops` for `value`.
31
72
  stops?: unknown;
32
73
  // Plot.
33
- expr?: string; fn?: ((x: number) => number) | null; xMin?: number; xMax?: number; yMin?: number; yMax?: number; samples?: number; editable?: boolean;
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;
34
82
  // Monitor.
35
- get?: () => number | string; graph?: boolean; view?: "graph" | "text"; interval?: number; decimals?: number;
83
+ get?: () => number | string;
84
+ graph?: boolean;
85
+ view?: "graph" | "text";
86
+ interval?: number;
87
+ decimals?: number;
36
88
  // Image (`options` and `cols` above).
37
- maxSize?: number; fit?: number | false; quality?: number; upload?: boolean; clear?: boolean;
89
+ maxSize?: number;
90
+ fit?: number | false;
91
+ quality?: number;
92
+ upload?: boolean;
93
+ clear?: boolean;
38
94
  // Button / button group.
39
- action?: () => void; buttons?: Record<string, () => void> | Array<{ label: string; action: () => void }>;
95
+ action?: () => void;
96
+ buttons?: Record<string, () => void> | Array<{ label: string; action: () => void }>;
40
97
  // Folder / tabs.
41
- children?: Meta[]; pages?: TabPage[];
98
+ children?: Meta[];
99
+ pages?: TabPage[];
42
100
  }
43
101
  /** A verbose handler's result: the control's own fields, or falsy for a malformed shape. */
44
102
  type MetaFields = Partial<Meta> | false;
@@ -60,7 +118,13 @@ function metaFor(key: string, value: any, depth = 0): Meta | null {
60
118
  // own fields; baseMetaFor stamps type / key / label on top, and a handler may override them. A
61
119
  // falsy return marks a malformed shape, skipped with a console error like a throw. ──
62
120
  // "segmented" is an alias: a single-select from a list renders as the radio grid; the pill is for booleans.
63
- const radiogridMeta = (v: any): MetaFields => Array.isArray(v.options) && { type: "radiogrid", options: v.options, value: v.value ?? optValue(v.options[0]), cols: v.cols };
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
+ };
64
128
  // `??` semantics: an explicit "" (no label) survives where `||` fell back to the key.
65
129
  const ownLabel = (v: any, label: string) => (v.label == null ? label : String(v.label));
66
130
  const fnOf = (f: unknown) => (typeof f === "function" ? (f as any) : undefined);
@@ -70,8 +134,18 @@ const unitOf = (u: unknown) => (typeof u === "string" && u.trim() ? u.trim().sli
70
134
  const TYPED_META: Partial<Record<SchemaObject["type"], (v: any, depth: number, key: string) => MetaFields>> = {
71
135
  // Absent min/max derive in the control from the value the range must contain, like the bare-number shorthand:
72
136
  // `{ type: "slider", value: 50 }` spans 0–150 like `size: 50`.
73
- slider: (v) => ({ value: v.value ?? v.min ?? 0, min: v.min, max: v.max, step: v.step, soft: v.soft, unit: unitOf(v.unit), format: fnOf(v.format), parse: fnOf(v.parse) }),
74
- number: (v) => ({ value: v.value ?? 0, min: v.min, max: v.max, step: v.step, soft: v.soft, unit: unitOf(v.unit) }), // no step: the number control (heavy.ts) fits one to the default
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) }),
75
149
  checkbox: (v) => ({ value: !!v.value }),
76
150
  radiogrid: radiogridMeta,
77
151
  segmented: radiogridMeta,
@@ -93,7 +167,9 @@ const TYPED_META: Partial<Record<SchemaObject["type"], (v: any, depth: number, k
93
167
  // Either mode's fields, top-level or nested, or `value` as a bare curve; the control infers
94
168
  // the mode (spring keys → spring) and fills the defaults.
95
169
  motion: (v) => {
96
- const s = typeof v.value === "string" || Array.isArray(v.value) ? { ...v, curve: v.value } : isObj(v.value) ? v.value : v; // the array test first: isObj is true of an array
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;
97
173
  const springy = SPRING_KEYS.some((k) => s[k] != null);
98
174
  // Both sides get a default, so a reset restores the editor you weren't looking at too.
99
175
  const value: any = { curve: "ease", duration: 300, ...(springy ? {} : { visualDuration: 0.5, bounce: 0.2 }) };
@@ -102,13 +178,21 @@ const TYPED_META: Partial<Record<SchemaObject["type"], (v: any, depth: number, k
102
178
  return { value };
103
179
  },
104
180
  point: (v, _depth, at) => {
105
- // `value: { x: 0, y: 0 }`, the shape the control hands back, names the components itself; a value beside components sets
106
- // their defaults. Without either, it's a folder with a child named `type`, as before. A value's component is a finite
107
- // number or a numeric string; anything else (null, true, "abc") starts at 0 with one notice. A reserved key is skipped.
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.
108
185
  const bad: string[] = [];
109
- const num = (k: string, n: unknown) => { const x = typeof n === "number" ? n : typeof n === "string" && n.trim() ? +n : NaN; if (Number.isFinite(x)) return x; bad.push(k); return 0; };
110
- const named = isObj(v.value) && !Array.isArray(v.value) ? Object.keys(v.value).filter((k) => !isReservedKey(k)) : [];
111
- if ((!Array.isArray(v.components) || !v.components.length) && named.length) v = { ...v, components: named.map((key) => ({ key })) };
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 })) };
112
196
  if (!Array.isArray(v.components) || !v.components.length) return false;
113
197
  // A keyless component takes its label (lower-cased) or index; keys dedupe so two can't share one param.
114
198
  const used = new Set<string>();
@@ -116,54 +200,120 @@ const TYPED_META: Partial<Record<SchemaObject["type"], (v: any, depth: number, k
116
200
  const label = c.label ? String(c.label).toLowerCase() : "";
117
201
  const base = String(c.key ?? ((isReservedKey(label) ? "" : label) || `c${k}`));
118
202
  if (isReservedKey(base)) return [];
119
- let key = base, n = 2; while (used.has(key)) key = `${base}-${n++}`; used.add(key);
203
+ let key = base,
204
+ n = 2;
205
+ while (used.has(key)) key = `${base}-${n++}`;
206
+ used.add(key);
120
207
  const own = isObj(v.value) && hasOwn(v.value, key) ? num(key, v.value[key]) : undefined;
121
208
  return [{ ...c, key, ...(own !== undefined && { value: own }) }];
122
209
  });
123
210
  if (bad.length) notice("pointValue", at, bad.join(", "));
124
211
  if (!components.length) return false;
125
- return { components, pad: v.pad, invertY: v.invertY, value: Object.fromEntries(components.map((c: PointComponent) => [c.key, c.value ?? 0])) }; // the default component map, for reset()
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
+ };
126
219
  },
127
220
  buttongroup: (v) => ({ buttons: v.buttons }),
128
221
  separator: () => ({}),
129
222
  // Page keys dedupe ("A!" and "A?" both slug to "a") so two pages can't share one params subtree.
130
223
  tabs: (v, depth) => {
131
- if (!v.pages || typeof v.pages !== "object" || !Object.keys(v.pages).length) return false; // no pages is nothing to show, not an empty bar
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;
132
226
  const used = new Set<string>();
133
- return { pages: Object.entries(v.pages).map(([title, schema]: [string, any]) => {
134
- const base = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "tab";
135
- let k = base, n = 2; while (used.has(k)) k = `${base}-${n++}`; used.add(k);
136
- return { key: k, title: title.trim() ? title : "Tab", children: Object.entries(schema).map(([ck, sv]) => metaFor(ck, sv, depth + 1)).filter(Boolean) }; // an empty title still gets a visible tab
137
- }) };
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
+ };
138
248
  },
139
249
  };
140
250
 
141
251
  // The field each falsy-capable form hinges on: present-but-unusable is malformed, absent is a folder.
142
- const REQUIRED: Record<string, string> = Object.assign(Object.create(null), { list: "options", radiogrid: "options", segmented: "options", point: "components", tabs: "pages" });
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
+ });
143
259
  function baseMetaFor(key: string, value: any, depth = 0): Meta | null {
144
260
  if (depth > 64) return null; // a pathological depth is skipped, not a RangeError
145
261
  const label = titleCase(key);
146
262
  const meta = (type: string, fields: Partial<Meta>, lab = label): Meta => ({ type, key, label: lab, ...fields });
147
- if (isObj(value) && !Array.isArray(value) && hasOwn(TYPED_META, value.type)) { // own key only: "toString" must miss
263
+ if (isObj(value) && !Array.isArray(value) && hasOwn(TYPED_META, value.type)) {
264
+ // own key only: "toString" must miss
148
265
  // A handler that throws or returns nothing for a malformed shape skips that control, not
149
266
  // the whole panel. The label is resolved here, once, so every verbose form honors `{ label }`.
150
267
  let fields: MetaFields;
151
- try { fields = TYPED_META[value.type as keyof typeof TYPED_META]!(value, depth, key); } // a guard can't narrow a property of an `any`
152
- catch (e) { console.error(`[tweaks] malformed "${value.type}" schema value for "${key}" — control skipped:`, e); return null; }
153
- if (fields) return meta(String(value.type), fields, ownLabel(value, label)); // a String object would miss m.type === "tabs" downstream
154
- // Nothing back with the hinge field present is malformed. Absent, this is a plain folder
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
155
278
  // holding a child named `type` (`marker: { type: "point", size: 3 }`) — fall through.
156
- if (hasOwn(value, REQUIRED[value.type])) { console.error(`[tweaks] malformed "${value.type}" schema value for "${key}" — control skipped`); return null; }
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
+ }
157
286
  }
158
287
  // A lazy control with no handler reads its own fields off the verbose form as given.
159
- else if (isObj(value) && !Array.isArray(value) && typeof value.type === "string" && (LAZY_IMPORT[value.type] || getControl(value.type))) {
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
+ ) {
160
297
  const { type, label: _l, ...fields } = value;
161
298
  return meta(type, fields, ownLabel(value, label));
162
299
  }
163
- // A `type` no control answers to: a typo (`slidr`) built a folder of `type` and `value` rows without a word. It may be a
164
- // folder with a child named `type`, so it still builds one; the console says so, with the near type, from a lazy chunk.
165
- else if (isObj(value) && !Array.isArray(value) && typeof value.type === "string" && !hasOwn(TYPED_META, value.type))
166
- import("./heavy.js").then((m) => m.unknownType(`"${key}"`, value.type, [...Object.keys(TYPED_META), ...Object.keys(LAZY_IMPORT), ...controlTypes()], "builds a folder"), () => {});
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
+ }
167
317
  // ── Shorthand inference ──
168
318
  // Interval / range: [[lo, hi], min, max, step?] — the first entry is a 2-tuple. Missing bounds and step derive in the
169
319
  // control, as for `{ type: "interval" }`: [[20, 80]] spans 0–240 (the tuple's own 20–80 pinned both handles).
@@ -171,16 +321,28 @@ function baseMetaFor(key: string, value: any, depth = 0): Meta | null {
171
321
  return meta("interval", { value: value[0].map(Number), min: value[1], max: value[2], step: value[3] });
172
322
  // [value, min?, max?, step?]: a short array ([n] = "just a default") takes the bare-number range. The slider derives
173
323
  // a missing or non-number bound and infers the step after, so [5, NaN] can't double 5 on a NaN span.
174
- if (Array.isArray(value) && value.length <= 4 && typeof value[0] === "number") return meta("slider", { value: value[0], min: value[1], max: value[2], step: value[3] });
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] });
175
326
  if (typeof value === "number") return meta("slider", { value });
176
327
  if (typeof value === "boolean") return meta("checkbox", { value });
177
328
  if (Array.isArray(value)) return meta("list", { options: value, value: optValue(value[0]) });
178
- if (isObj(value) && typeof value.action === "function") return meta("button", { action: value.action }, ownLabel(value, label));
179
- if (isObj(value) && Array.isArray(value.options)) return meta("list", { options: value.options, value: value.value ?? optValue(value.options[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]) });
180
333
  if (isColorStr(value)) return meta("color", { value });
181
334
  if (typeof value === "string") return meta("text", { value });
182
335
  // render / disabled / hint are the folder's chrome, not children.
183
- if (isObj(value)) return meta("folder", { children: Object.entries(value).filter(([k, v]) => !(k === "render" && typeof v === "function") && !((k === "disabled" || k === "hint") && v != null)).map(([k, v]) => metaFor(k, v, depth + 1)).filter(Boolean) });
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
+ });
184
346
  return null;
185
347
  }
186
348
  // Display/action controls carry no value; the panel build skips entry/reset/persist wiring.
@@ -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
+ };