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.
- package/AGENTS.md +5 -5
- package/README.md +3 -3
- package/dist/tweaks/auto.js +1 -1
- package/dist/tweaks/auto.js.map +1 -1
- package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
- package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
- package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
- package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
- package/dist/tweaks/chunk-ETB35KFV.js +2 -0
- package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
- package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
- package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
- package/dist/tweaks/clip-A3VI53BK.js +2 -0
- package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
- package/dist/tweaks/color-UGYMAF3W.js +2 -0
- package/dist/tweaks/core.js +1 -1
- package/dist/tweaks/core.js.map +3 -3
- package/dist/tweaks/fold-GY47HIQA.js +2 -0
- package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
- package/dist/tweaks/gradient-BTX25WSH.js +2 -0
- package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
- package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
- package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
- package/dist/tweaks/interval-BHPI4LRN.js +2 -0
- package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
- package/dist/tweaks/markup-QM53TQJZ.js +2 -0
- package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
- package/dist/tweaks/monitor-722WIYGW.js +3 -0
- package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
- package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
- package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
- package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
- package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
- package/dist/tweaks/point-XDXKMDXH.js +2 -0
- package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
- package/dist/tweaks/presets-EUWNSCUW.js +2 -0
- package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
- package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
- package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
- package/dist/tweaks/sides-JWIURDA4.js +2 -0
- package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
- package/dist/tweaks/spring-BF5GBTPM.js +2 -0
- package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +5 -4
- package/dist/tweaks.js.map +3 -3
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/types.d.ts +278 -72
- package/package.json +5 -2
- package/src/tweaks/auto.ts +9 -5
- package/src/tweaks/clip.ts +84 -22
- package/src/tweaks/controls/basic.ts +463 -139
- package/src/tweaks/controls/bezier.ts +152 -47
- package/src/tweaks/controls/color.ts +610 -161
- package/src/tweaks/controls/gradient.ts +256 -77
- package/src/tweaks/controls/image.ts +388 -132
- package/src/tweaks/controls/interval.ts +126 -41
- package/src/tweaks/controls/monitor.ts +142 -36
- package/src/tweaks/controls/motion.ts +231 -56
- package/src/tweaks/controls/plot.ts +321 -101
- package/src/tweaks/controls/point.ts +134 -45
- package/src/tweaks/controls/shadow.ts +103 -30
- package/src/tweaks/controls/sides.ts +150 -40
- package/src/tweaks/controls/spring.ts +220 -60
- package/src/tweaks/controls/tabs.ts +39 -18
- package/src/tweaks/core.ts +1 -1
- package/src/tweaks/easing.ts +45 -20
- package/src/tweaks/enhance.ts +21 -6
- package/src/tweaks/feedback.ts +133 -34
- package/src/tweaks/fold.ts +22 -10
- package/src/tweaks/heavy.ts +461 -137
- package/src/tweaks/icons.ts +12 -5
- package/src/tweaks/image-store.ts +131 -43
- package/src/tweaks/lazy.ts +36 -19
- package/src/tweaks/markup.ts +255 -92
- package/src/tweaks/panel.ts +1262 -438
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +213 -51
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +802 -190
- package/src/tweaks/standalone.ts +120 -30
- package/src/tweaks/types.ts +644 -143
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +556 -550
- package/src/wide-gamut.ts +473 -125
- package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
- package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
- package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
- package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
- package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
- package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
- package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
- package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
- package/dist/tweaks/clip-WW7JYO4X.js +0 -2
- package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
- package/dist/tweaks/color-YMMFURP6.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
- package/dist/tweaks/gradient-JER7PW4D.js +0 -2
- package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
- package/dist/tweaks/image-H6QSLGO3.js +0 -4
- package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
- package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
- package/dist/tweaks/interval-TEUHQGZN.js +0 -2
- package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
- package/dist/tweaks/markup-NIBJD6E2.js +0 -2
- package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
- package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
- package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
- package/dist/tweaks/motion-FQPUBKKM.js +0 -2
- package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
- package/dist/tweaks/plot-2HBNBZSG.js +0 -3
- package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
- package/dist/tweaks/point-VV6XV3I6.js +0 -2
- package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
- package/dist/tweaks/shadow-5E4E55I2.js +0 -2
- package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
- package/dist/tweaks/sides-P3STEVYY.js +0 -2
- package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
- package/dist/tweaks/spring-HUVSXNVN.js +0 -2
- package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
- package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
- package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
- /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
- /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
|
+
};
|
package/src/tweaks/schema.ts
CHANGED
|
@@ -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-
|
|
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 {
|
|
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<
|
|
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 {
|
|
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;
|
|
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;
|
|
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
|
-
|
|
21
|
-
|
|
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[];
|
|
55
|
+
options?: Option[];
|
|
56
|
+
cols?: number;
|
|
24
57
|
// Text.
|
|
25
|
-
rows?: number;
|
|
58
|
+
rows?: number;
|
|
59
|
+
placeholder?: string;
|
|
26
60
|
// Spring (also tolerated top-level, beside the normalized `value`).
|
|
27
|
-
mode?: "time" | "physics";
|
|
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[];
|
|
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;
|
|
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;
|
|
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;
|
|
89
|
+
maxSize?: number;
|
|
90
|
+
fit?: number | false;
|
|
91
|
+
quality?: number;
|
|
92
|
+
upload?: boolean;
|
|
93
|
+
clear?: boolean;
|
|
38
94
|
// Button / button group.
|
|
39
|
-
action?: () => void;
|
|
95
|
+
action?: () => void;
|
|
96
|
+
buttons?: Record<string, () => void> | Array<{ label: string; action: () => void }>;
|
|
40
97
|
// Folder / tabs.
|
|
41
|
-
children?: Meta[];
|
|
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 =>
|
|
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) => ({
|
|
74
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
// their defaults.
|
|
107
|
-
// number or a numeric string; anything else (null, true, "abc") starts at 0 with one
|
|
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) => {
|
|
110
|
-
|
|
111
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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), {
|
|
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)) {
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
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")
|
|
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")
|
|
179
|
-
|
|
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))
|
|
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
|
+
};
|