tweakit 0.5.2 → 0.6.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 (111) hide show
  1. package/AGENTS.md +31 -29
  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-JFEZMCLP.js +2 -0
  7. package/dist/tweaks/bezier-JFEZMCLP.js.map +6 -0
  8. package/dist/tweaks/chunk-6UO4DHBN.js +2 -0
  9. package/dist/tweaks/chunk-6UO4DHBN.js.map +6 -0
  10. package/dist/tweaks/chunk-DIQMLBAS.js +2 -0
  11. package/dist/tweaks/chunk-DIQMLBAS.js.map +6 -0
  12. package/dist/tweaks/chunk-XE6OUJBG.js +2 -0
  13. package/dist/tweaks/chunk-XE6OUJBG.js.map +6 -0
  14. package/dist/tweaks/clip-WW7JYO4X.js +2 -0
  15. package/dist/tweaks/clip-WW7JYO4X.js.map +6 -0
  16. package/dist/tweaks/color-YMMFURP6.js +2 -0
  17. package/dist/tweaks/color-YMMFURP6.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-UGN7TXGX.js → fold-U6YRZLCJ.js} +2 -1
  21. package/dist/tweaks/fold-U6YRZLCJ.js.map +6 -0
  22. package/dist/tweaks/gradient-JER7PW4D.js +2 -0
  23. package/dist/tweaks/gradient-JER7PW4D.js.map +6 -0
  24. package/dist/tweaks/heavy-CLD7UAVI.js +2 -0
  25. package/dist/tweaks/heavy-CLD7UAVI.js.map +6 -0
  26. package/dist/tweaks/image-H6QSLGO3.js +4 -0
  27. package/dist/tweaks/image-H6QSLGO3.js.map +6 -0
  28. package/dist/tweaks/image-store-WRKY2NPU.js +2 -0
  29. package/dist/tweaks/image-store-WRKY2NPU.js.map +6 -0
  30. package/dist/tweaks/interval-TEUHQGZN.js +2 -0
  31. package/dist/tweaks/interval-TEUHQGZN.js.map +6 -0
  32. package/dist/tweaks/markup-NIBJD6E2.js +2 -0
  33. package/dist/tweaks/markup-NIBJD6E2.js.map +6 -0
  34. package/dist/tweaks/monitor-3BY7YCGX.js +3 -0
  35. package/dist/tweaks/monitor-3BY7YCGX.js.map +6 -0
  36. package/dist/tweaks/motion-FQPUBKKM.js +2 -0
  37. package/dist/tweaks/motion-FQPUBKKM.js.map +6 -0
  38. package/dist/tweaks/plot-2HBNBZSG.js +3 -0
  39. package/dist/tweaks/plot-2HBNBZSG.js.map +6 -0
  40. package/dist/tweaks/point-VV6XV3I6.js +2 -0
  41. package/dist/tweaks/point-VV6XV3I6.js.map +6 -0
  42. package/dist/tweaks/shadow-5E4E55I2.js +2 -0
  43. package/dist/tweaks/shadow-5E4E55I2.js.map +6 -0
  44. package/dist/tweaks/sides-P3STEVYY.js +2 -0
  45. package/dist/tweaks/sides-P3STEVYY.js.map +6 -0
  46. package/dist/tweaks/spring-HUVSXNVN.js +2 -0
  47. package/dist/tweaks/spring-HUVSXNVN.js.map +6 -0
  48. package/dist/tweaks/tabs-76FW7LQ7.js +2 -0
  49. package/dist/tweaks/tabs-76FW7LQ7.js.map +6 -0
  50. package/dist/tweaks.css +1 -1
  51. package/dist/tweaks.js +6 -3
  52. package/dist/tweaks.js.map +6 -0
  53. package/dist/types/tweaks/easing.d.ts +8 -11
  54. package/dist/types/tweaks/panel.d.ts +6 -4
  55. package/dist/types/tweaks/schema.d.ts +4 -8
  56. package/dist/types/tweaks/standalone.d.ts +3 -3
  57. package/dist/types/tweaks/types.d.ts +278 -101
  58. package/package.json +27 -5
  59. package/src/tweaks/auto.ts +12 -0
  60. package/src/tweaks/clip.ts +94 -0
  61. package/src/tweaks/controls/basic.ts +403 -0
  62. package/src/tweaks/controls/bezier.ts +99 -0
  63. package/src/tweaks/controls/color.ts +412 -0
  64. package/src/tweaks/controls/gradient.ts +240 -0
  65. package/src/tweaks/controls/image.ts +299 -0
  66. package/src/tweaks/controls/interval.ts +104 -0
  67. package/src/tweaks/controls/monitor.ts +105 -0
  68. package/src/tweaks/controls/motion.ts +141 -0
  69. package/src/tweaks/controls/plot.ts +220 -0
  70. package/src/tweaks/controls/point.ts +119 -0
  71. package/src/tweaks/controls/shadow.ts +83 -0
  72. package/src/tweaks/controls/sides.ts +86 -0
  73. package/src/tweaks/controls/spring.ts +148 -0
  74. package/src/tweaks/controls/tabs.ts +47 -0
  75. package/src/tweaks/core.ts +9 -0
  76. package/src/tweaks/easing.ts +93 -0
  77. package/src/tweaks/enhance.ts +20 -0
  78. package/src/tweaks/feedback.ts +119 -0
  79. package/src/tweaks/fold.ts +21 -0
  80. package/src/tweaks/heavy.ts +302 -0
  81. package/src/tweaks/icons.ts +19 -0
  82. package/src/tweaks/image-store.ts +101 -0
  83. package/src/tweaks/lazy.ts +46 -0
  84. package/src/tweaks/markup.ts +154 -0
  85. package/src/tweaks/panel.ts +869 -0
  86. package/src/tweaks/schema.ts +189 -0
  87. package/src/tweaks/shared.ts +525 -0
  88. package/src/tweaks/single.ts +22 -0
  89. package/src/tweaks/standalone.ts +89 -0
  90. package/src/tweaks/types.ts +526 -0
  91. package/src/tweaks.css +959 -0
  92. package/src/wide-gamut.ts +310 -0
  93. package/dist/tweaks/bezier-2RABC6LD.js +0 -1
  94. package/dist/tweaks/chunk-4ATI34PB.js +0 -1
  95. package/dist/tweaks/chunk-7OSCMZ2J.js +0 -1
  96. package/dist/tweaks/chunk-W3JNIJ2F.js +0 -1
  97. package/dist/tweaks/clip-ECGCECEC.js +0 -1
  98. package/dist/tweaks/colour-2RE3FQ5E.js +0 -1
  99. package/dist/tweaks/gradient-DB6XTDHO.js +0 -1
  100. package/dist/tweaks/heavy-PKOMHB3C.js +0 -1
  101. package/dist/tweaks/image-7MCLBOZ3.js +0 -1
  102. package/dist/tweaks/interval-L2E4L3AZ.js +0 -1
  103. package/dist/tweaks/markup-ZJJ6CCWM.js +0 -1
  104. package/dist/tweaks/monitor-IEK742PG.js +0 -2
  105. package/dist/tweaks/motion-N5UNVR55.js +0 -1
  106. package/dist/tweaks/plot-VVTOY3RA.js +0 -2
  107. package/dist/tweaks/point-YMMHSLO7.js +0 -1
  108. package/dist/tweaks/shadow-IKYOV3VN.js +0 -1
  109. package/dist/tweaks/sides-FH227Z54.js +0 -1
  110. package/dist/tweaks/spring-3PKAFIH7.js +0 -1
  111. package/dist/tweaks/tabs-BKK5NIIY.js +0 -1
@@ -0,0 +1,89 @@
1
+ /* Standalone controls — the kit's controls without the panel: mountControl() builds one control
2
+ * into a host of your own, createColorPicker() hands out the color editor surface alone. */
3
+ import { el, getControl, onReady, setDisabled, valueChanged } from "./shared.js";
4
+ import { metaFor, VALUELESS } from "./schema.js";
5
+ import { ensureForMetas } from "./lazy.js";
6
+ import { createControl } from "./controls/basic.js";
7
+ import { addHintMarker } from "./feedback.js";
8
+ import type { SchemaValue, HeldValue, KnownType, MountOptions, MountedControl, ColorPickerOptions, ColorPicker, ColorMode, Control , Get } from "./types.js";
9
+ import type { Meta } from "./schema.js";
10
+ import type { PickerBody, PickerOptions } from "./controls/color.js";
11
+
12
+ // Outside a .tw-panel the cascade delivers none of the kit's tokens, so the wrapper carries .tw-portal.
13
+ const scope = (cls: string) => el("div", `${cls} tw-standalone tw-portal`);
14
+ // Run `build` once the lazy module has landed (synchronously when none is missing); the handled fork keeps a failed chunk out of the unhandled-rejection channel.
15
+ const whenLoaded = <H>(metas: Array<Pick<Meta, "type">>, build: () => void, handle: H): Promise<H> => {
16
+ const pend = ensureForMetas(metas);
17
+ if (!pend) { build(); return Promise.resolve(handle); }
18
+ const ready = pend.then(build).then(() => handle);
19
+ ready.catch(() => {});
20
+ return ready;
21
+ };
22
+
23
+ /** One control from a schema value, into `host`; a folder or tabs set is tweaks()'s to build. */
24
+ export function mountControl<const V extends HeldValue>(host: Element, value: V & KnownType<V>, opts?: MountOptions): MountedControl;
25
+ export function mountControl(host: Element, value: SchemaValue, opts: MountOptions = {}): MountedControl {
26
+ const key = opts.key ?? "value";
27
+ const meta = metaFor(key, value);
28
+ if (!meta || meta.type === "folder" || meta.type === "tabs") throw new TypeError(`[tweaks] mountControl: "${key}" is not a single-control schema value`);
29
+ if (opts.label != null) meta.label = String(opts.label); // the option wins over the derived label; "" = no label
30
+ const valued = !VALUELESS.has(meta.type); // display/action controls carry no value: set/get are inert
31
+ const root = scope("tw-control"); // the control fills it in on ready
32
+ host.append(root);
33
+ let ctrl: Control | null = null, destroyed = false, last: unknown = meta.value, parked: unknown, queued = false;
34
+ // Gated the way the panel gates notify(): a same-value set() stays silent instead of echoing.
35
+ const emit = (v: unknown) => { if (!valueChanged(last, v)) return; last = v; opts.onChange && opts.onChange(v, key); };
36
+ const build = () => {
37
+ if (destroyed) return; // destroy() before the chunk landed — nothing to mount
38
+ ctrl = createControl(meta, valued ? emit : () => {});
39
+ if (!ctrl) return; // a failed chunk degrades to an empty wrapper
40
+ if (meta.hint) addHintMarker(ctrl.el, meta.hint);
41
+ // Resolved once at mount: with no siblings for a predicate to read, `get` answers undefined.
42
+ const none: Get = () => undefined;
43
+ try { if (meta.render && !meta.render(none)) ctrl.el.classList.add("tw-cond-hidden"); } catch {}
44
+ try { const d = typeof meta.disabled === "function" ? meta.disabled(none) : meta.disabled; if (d) setDisabled(ctrl.el, true); } catch {} // each predicate fails on its own
45
+ root.append(ctrl.el);
46
+ if (!valued) return;
47
+ last = ctrl.get(); // the form the control opened on
48
+ if (queued) { ctrl.set(parked); emit(ctrl.get()); } // a set() from the lazy window applies and announces now
49
+ };
50
+ const handle: MountedControl = {
51
+ el: root,
52
+ set: (v) => { if (destroyed || !valued) return; if (ctrl) { ctrl.set(v); emit(ctrl.get()); } else { parked = v; queued = true; } },
53
+ get: () => (!valued ? undefined : ctrl ? ctrl.get() : queued ? parked : meta.value),
54
+ destroy: () => { destroyed = true; if (ctrl && typeof ctrl.destroy === "function") ctrl.destroy(); root.dataset.twDestroyed = ""; root.remove(); }, // onLive listeners release on their next event by this mark
55
+ ready: null,
56
+ };
57
+ handle.ready = whenLoaded([meta], build, handle);
58
+ return handle;
59
+ }
60
+
61
+ /** The color editor surface alone, for a host page that brings its own swatch. */
62
+ export function createColorPicker(opts: ColorPickerOptions = {}): ColorPicker {
63
+ const root = scope("tw-color-picker");
64
+ let body: PickerBody | null = null, destroyed = false, last = opts.value, parked: string, queued = false, parkedMode: ColorMode = null;
65
+ const emit = (v: string) => { if (!valueChanged(last, v)) return; last = v; opts.onChange && opts.onChange(v); };
66
+ const build = () => {
67
+ if (destroyed) return;
68
+ const make = getControl<PickerBody, PickerOptions>("colorpicker"); // registered by the color module beside its control
69
+ if (!make) return;
70
+ body = make({ value: opts.value, mode: opts.mode }, emit);
71
+ last = body.get();
72
+ root.append(body.el);
73
+ if (parkedMode) body.setMode(parkedMode);
74
+ if (queued) { body.set(parked); emit(body.get()); }
75
+ onReady(body.reflow); // next frame: the thumbs need a real box
76
+ };
77
+ const picker: ColorPicker = {
78
+ el: root,
79
+ set: (v) => { if (destroyed) return; if (body) { body.set(v); emit(body.get()); } else { parked = v; queued = true; } },
80
+ get: () => (body ? body.get() : queued ? parked : opts.value),
81
+ mode: () => (body ? body.mode() : parkedMode || opts.mode || "oklch"),
82
+ setMode: (m) => { if (body) body.setMode(m); else parkedMode = m; },
83
+ reflow: () => { if (body) body.reflow(); },
84
+ destroy: () => { destroyed = true; root.remove(); },
85
+ ready: null,
86
+ };
87
+ picker.ready = whenLoaded([{ type: "color" }], build, picker);
88
+ return picker;
89
+ }
@@ -0,0 +1,526 @@
1
+ /* Public types for the Tweakit API — the schema you hand to tweaks() and the
2
+ * panel you get back. These are what ship as .d.ts, so consumers get autocomplete on
3
+ * the schema shorthands + the verbose control forms + the panel methods. */
4
+
5
+ /** An option for a select / radio grid: a bare string or number, or `{ value, label }`; the param is the value as written. */
6
+ export type Option = string | number | { value: string | number; label?: string; color?: string }; // color: any CSS color, shown as a swatch beside a dropdown option (and the chosen one on the row)
7
+
8
+ /** A color stop in a gradient. */
9
+ export type GradientStop = { color: string; pos: number };
10
+
11
+ /** The CSS `<color-interpolation-method>` a gradient blends through — the space of the
12
+ * mode the stops were authored in. Template it as `linear-gradient(in <interpolation> …)`
13
+ * so your CSS ramp matches the editor's preview exactly. */
14
+ export type GradientInterpolation = "srgb" | "hsl" | "hwb" | "oklch" | "oklab" | "lch" | "lab" | "display-p3" | "rec2020";
15
+
16
+ /** What a sides control accepts: one number for all four; `[vertical, horizontal]`,
17
+ * `[top, horizontal, bottom]` or `[top, right, bottom, left]` (CSS order); a CSS shorthand
18
+ * string like `"1rem 2rem"` (its unit becomes the control's when `unit` isn't given; mixed
19
+ * units warn and read as px); or any subset of
20
+ * the four as an object. With `corners: true` the four are the corners from top-left round,
21
+ * the same CSS order, under the same keys, and they floor at 0 unless `min` says otherwise. */
22
+ export type SidesInput = number | readonly number[] | string | { top?: number; right?: number; bottom?: number; left?: number };
23
+ /** A sides control's value: the four numbers in CSS order plus the shorthand they collapse
24
+ * to (`css`, in the control's unit, `0` bare), so `el.style.padding = css`. */
25
+ export interface SidesValue { top: number; right: number; bottom: number; left: number; css: string }
26
+
27
+ /** What a shadow control accepts — any subset of its fields (px, `color` any color the kit
28
+ * parses); or, as `value`, a `box-shadow` string: optional `inset`, two to four px lengths,
29
+ * one color, one layer. */
30
+ export interface ShadowInput { inset?: boolean; x?: number; y?: number; blur?: number; spread?: number; color?: string }
31
+ /** A shadow control's value — the fields, plus the `box-shadow` CSS they resolve to (`css`),
32
+ * built from the numbers and the picker's own color notation, so `el.style.boxShadow = css`. */
33
+ export interface ShadowValue { inset: boolean; x: number; y: number; blur: number; spread: number; color: string; css: string }
34
+ /** What a motion control accepts — any subset of either mode's fields. A `curve` may be
35
+ * the four cubic-bezier numbers or a CSS keyword (`ease`, `ease-out`, …); `duration` is
36
+ * the easing's, in ms. Spring keys select the spring mode unless `mode` says otherwise. */
37
+ export interface MotionInput { mode?: "easing" | "spring"; curve?: readonly [number, number, number, number] | string; duration?: number; stiffness?: number; damping?: number; mass?: number; visualDuration?: number; bounce?: number }
38
+ /** A motion control's value: an easing curve with its duration, or a spring — each also
39
+ * carrying the CSS it resolves to, `duration` in ms and an `easing` string (`cubic-bezier(…)`,
40
+ * or a sampled `linear(…)` for the spring), so a host can write
41
+ * `transition: transform ${duration}ms ${easing}` in either mode. */
42
+ export type MotionValue =
43
+ | { mode: "easing"; curve: [number, number, number, number]; duration: number; easing: string }
44
+ | { mode: "spring"; stiffness: number; damping: number; mass: number; visualDuration?: number; bounce?: number; duration: number; easing: string };
45
+
46
+ /** How a gradient blends between neighboring stops: `"linear"` (CSS's own straight blend,
47
+ * the default) or a CSS easing — `ease`, `ease-in`, `ease-out`, `ease-in-out`, or any
48
+ * `cubic-bezier(x1, y1, x2, y2)`. No browser eases a gradient natively, so `gradientCss()`
49
+ * samples the curve into stops; template through it rather than by hand. */
50
+ export type GradientEasing = "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | `cubic-bezier(${string})`;
51
+
52
+ /** A gradient control's value — the stops, the blend space, and the easing between stops.
53
+ * Hand it to `gradientCss(value, angle?)` for the CSS, or `gradientStops(value)` for just
54
+ * the stop list (a conic / radial gradient). */
55
+ export interface GradientValue { stops: GradientStop[]; interpolation?: GradientInterpolation; easing?: GradientEasing }
56
+
57
+ /** Reader passed to a conditional `render` / `disabled` predicate — `get("siblingKey")`. */
58
+ export type Get = (key: string) => unknown;
59
+
60
+ /** Per-control extras that can ride on any object-form value. */
61
+ export interface ControlOptions {
62
+ /** The label shown for this control, in place of the title-cased key; `""` shows no label.
63
+ * Honored by every verbose `{ type }` form (in a plain folder object, `label` is a child). */
64
+ label?: string;
65
+ /** Show/hide this control based on other values. */
66
+ render?: (get: Get) => boolean;
67
+ /** Gray-out + lock this control (static, or derived from other values). */
68
+ disabled?: boolean | ((get: Get) => boolean);
69
+ /** A tooltip revealed by an ⓘ marker beside the label. */
70
+ hint?: string;
71
+ /** `false` leaves this control out of what `opts.persist` saves, so it opens on its schema
72
+ * value every load (presets, undo and `toJSON()` still carry it). On a control, not a
73
+ * plain folder object, where `persist` is a child. */
74
+ persist?: boolean;
75
+ }
76
+
77
+ /** The verbose object forms a schema value can take: `{ type: "…", … }`. */
78
+ export type SchemaObject =
79
+ | { type: "slider"; value?: number; min?: number; max?: number; step?: number; soft?: boolean; unit?: string;
80
+ /** Draws the readout from the value ("7:30 AM" for minutes of the day), in place of its
81
+ * digits; `unit` still follows it. Display only, like `unit`: the param stays the number. */
82
+ format?: (value: number) => string;
83
+ /** Reads a typed readout back into a value, so the inline editor shows and takes the
84
+ * formatted text (without one, it edits the digits). Text it can't read (it throws, or
85
+ * returns anything but a finite number) leaves the value where it was. */
86
+ parse?: (text: string) => number }
87
+ | { type: "number"; value?: number; min?: number; max?: number; step?: number; soft?: boolean; unit?: string }
88
+ | { type: "checkbox"; value?: boolean }
89
+ | { type: "list"; options: readonly Option[]; value?: string | number }
90
+ | { type: "radiogrid" | "segmented"; options: readonly Option[]; value?: string | number; cols?: number }
91
+ | { type: "color"; value?: string }
92
+ | { type: "text"; value?: string; rows?: number; placeholder?: string }
93
+ | { type: "interval"; value?: readonly [number, number]; min?: number; max?: number; step?: number }
94
+ | { type: "spring"; mode?: "time" | "physics"; visualDuration?: number; bounce?: number; stiffness?: number; damping?: number; mass?: number; value?: { mode?: "time" | "physics"; visualDuration?: number; bounce?: number; stiffness?: number; damping?: number; mass?: number } }
95
+ | { type: "cubicbezier"; value?: readonly [number, number, number, number] }
96
+ | ({ type: "shadow"; value?: ShadowInput | string } & ShadowInput)
97
+ | { type: "sides"; value?: SidesInput; corners?: boolean; unit?: string; step?: number; min?: number; max?: number; top?: number; right?: number; bottom?: number; left?: number }
98
+ | ({ type: "motion"; value?: MotionInput } & MotionInput)
99
+ | { type: "point"; components: ReadonlyArray<{ key: string; label?: string; value?: number; min?: number; max?: number; step?: number }>; value?: { readonly [key: string]: number }; pad?: boolean; invertY?: boolean }
100
+ | { type: "point"; value: { readonly [key: string]: number }; components?: undefined; pad?: boolean; invertY?: boolean } // the value names its components
101
+ | { type: "gradient"; value?: GradientValue | ReadonlyArray<GradientStop | readonly [string, number]>; stops?: ReadonlyArray<GradientStop | readonly [string, number]>; interpolation?: GradientInterpolation; easing?: GradientEasing }
102
+ | { type: "plot"; expr?: string; fn?: (x: number) => number; xMin?: number; xMax?: number; yMin?: number; yMax?: number; samples?: number; editable?: boolean; min?: number; max?: number }
103
+ | { type: "fpsgraph" }
104
+ | { type: "monitor"; get?: () => number | string; value?: number | string; graph?: boolean; view?: "graph" | "text"; min?: number; max?: number; interval?: number; rows?: number; decimals?: number }
105
+ | { type: "image"; value?: string;
106
+ /** Supplied images to pick from, as URLs or `{ value, label }`. The control becomes a row naming the chosen
107
+ * image that opens a pane of tiles with the upload below them, and the param is the chosen URL or the upload's data URL. */
108
+ options?: ReadonlyArray<string | { value: string; label?: string }>;
109
+ /** Tiles per row in the gallery, 1 to 6 (by default one per option for two or three, 2 for one or four, then 3). */
110
+ cols?: number;
111
+ /** The largest upload taken, in bytes (10 MB unless set); a bigger file is refused with a note. */
112
+ maxSize?: number;
113
+ /** An upload longer than this on its long side, in px, is drawn down to it before it becomes the param, and one
114
+ * heavier than 1.5 MB is redrawn at its size, so a phone photo can't fill storage (2560 unless set; `false` takes
115
+ * uploads as they are). A JPEG comes out as a JPEG at
116
+ * `quality`; anything else as a PNG when that is 1.5 MB or less (a screenshot, not a photo), else as a JPEG, or a WebP
117
+ * when it has transparency. A GIF, an SVG, an animated WebP or APNG, a light picture within `fit`, or one the redraw wouldn't make smaller is kept as it is. */
118
+ fit?: number | false;
119
+ /** The JPEG or WebP quality of a shrunk upload, 0 to 1 (0.85 unless set). */
120
+ quality?: number;
121
+ /** `false` leaves the upload button out of a gallery: the options are the whole choice. */
122
+ upload?: boolean;
123
+ /** `false` leaves Clear out of a gallery, and Delete no longer empties it: for a choice that must keep a value. */
124
+ clear?: boolean }
125
+ | { type: "button"; action: () => void; label?: string }
126
+ | { type: "buttongroup"; buttons: Record<string, () => void> | ReadonlyArray<{ label: string; action: () => void }> }
127
+ | { type: "separator" }
128
+ | { type: "tabs"; pages: { readonly [title: string]: Schema } };
129
+
130
+ /** A schema value — a shorthand, a verbose `{ type }` object, or a nested folder. */
131
+ export type SchemaValue = SchemaValueOf<SchemaNest>;
132
+ // `F` is the nested-folder branch: strict for a schema annotated `Schema`, loose for what tweaks() infers from (HeldSchema).
133
+ type SchemaValueOf<F> =
134
+ | number // bare number → slider (0–1 for a value ≤ 1, else 0–3×value; a negative spans that either side of zero)
135
+ | boolean // → checkbox
136
+ | string // → text input; a hex or color-function string → color picker
137
+ | readonly [number] | readonly [number, number] | readonly [number, number, number] | readonly [number, number, number, number] // [value, min?, max?, step?] → slider
138
+ | readonly [readonly [number, number], number, number] | readonly [readonly [number, number], number, number, number] // [[lo,hi], min, max, step?] → interval
139
+ | readonly number[] | readonly (number | readonly number[])[] // either of the above, widened by a schema held in a variable
140
+ | readonly Option[] // a list of options → list (dropdown)
141
+ | { options: readonly Option[]; value?: string | number } // a list with a chosen default
142
+ | { action: () => void; label?: string } // → button
143
+ | (SchemaObject & ControlOptions) // verbose form (+ render/disabled/hint)
144
+ | F // nested object → folder
145
+ | SchemaFolder; // a nested folder with its own render / disabled predicate
146
+
147
+ /** The object you hand to `tweaks(name, schema, opts?)`. No value is a function, so a verbose
148
+ * form's callbacks (`format`, `fn`) take their parameter types from that form alone. */
149
+ export interface Schema { readonly [key: string]: SchemaValue; }
150
+ // A folder's string `type` names a control (a known name with none of its fields is still a folder), so a name no control
151
+ // has can't pass as a folder: `const s: Schema = { a: { type: "slidr", value: 1 } }` compiled that way.
152
+ type SchemaNest = Schema & { readonly type?: SchemaObject["type"] | Exclude<SchemaValue, string> };
153
+ /** What tweaks() and mountControl() infer from: `Schema` with a folder's `type` left open, since a schema held in a
154
+ * variable widens a verbose form's `type` to `string`; their own check then rules on the names it can still see. */
155
+ export interface HeldSchema { readonly [key: string]: HeldValue; }
156
+ /** A single value as mountControl() infers it; see `HeldSchema`. */
157
+ export type HeldValue = SchemaValueOf<HeldSchema>;
158
+ /** A nested folder that carries its own `render` / `disabled` predicate or `hint`: chrome, not
159
+ * children. Its index admits the predicate, so it gives up a child named `type`: that sets it
160
+ * apart from a verbose form, whose callbacks would otherwise be typed against the predicate too. */
161
+ export interface SchemaFolder {
162
+ readonly type?: undefined;
163
+ readonly render?: (get: Get) => boolean;
164
+ readonly disabled?: boolean | ((get: Get) => boolean);
165
+ readonly hint?: string;
166
+ readonly [key: string]: SchemaValue | ((get: Get) => boolean) | undefined;
167
+ }
168
+
169
+ // ── Params inferred from a schema ──
170
+ // tweaks() takes its schema as a const type parameter, so each value keeps its literal shape and
171
+ // these types read the control it builds the way schema.ts does, in the same order.
172
+
173
+ /** A spring control's value: the physics, plus the time pair and mode when tuned by feel. */
174
+ export interface SpringValue { stiffness: number; damping: number; mass: number; visualDuration?: number; bounce?: number; mode?: "time" | "physics" }
175
+
176
+ type OptionValue<O> = O extends string | number ? O : O extends { readonly value: infer V extends string | number } ? V : never;
177
+ // A list's param: one of its options, or a `value` off the list, which the control keeps as given until a pick.
178
+ type ChoiceValue<V> = (V extends { readonly options: readonly (infer O)[] } ? OptionValue<O> : never) | (V extends { readonly value: infer D extends string | number } ? D : never);
179
+ type Alnum = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
180
+ type Dashed<S extends string, Acc extends string = "", Run = false> =
181
+ S extends `${infer C}${infer R}` ? C extends Alnum ? Dashed<R, `${Acc}${C}`, false> : Run extends true ? Dashed<R, Acc, true> : Dashed<R, `${Acc}-`, true> : Acc;
182
+ type TrimDash<S extends string> = S extends `-${infer R}` ? TrimDash<R> : S extends `${infer R}-` ? TrimDash<R> : S;
183
+ /** A tab title's key under `params`: lower-case, each run of other characters a `-`, trimmed
184
+ * (`"Layout & Grid"` → `"layout-grid"`). Two titles that slug alike are told apart at runtime only. */
185
+ export type TabKey<T extends string> = string extends T ? string : TrimDash<Dashed<Lowercase<T>>> extends infer K extends string ? (K extends "" ? "tab" : K) : never;
186
+ // True when two page titles slug to one key: the runtime suffixes the later one (`a`, `a-2`) in an
187
+ // order a type can't follow, so those pages read as the loose Params rather than merge into one.
188
+ type TabClash<P> = true extends { [T in keyof P & string]: TabKey<T> extends { [U in Exclude<keyof P & string, T>]: TabKey<U> }[Exclude<keyof P & string, T>] ? true : false }[keyof P & string] ? true : false;
189
+
190
+ type Valueless = "button" | "buttongroup" | "separator" | "fpsgraph" | "monitor";
191
+ // The field each falsy-capable form hinges on (schema.ts REQUIRED): without it the object isn't
192
+ // that control but a shorthand or a folder holding a child named `type`.
193
+ type Hinge = { list: "options"; radiogrid: "options"; segmented: "options"; point: "components" | "value"; tabs: "pages" };
194
+ // A hinge field there but unusable skips the control at runtime, so it puts nothing on params.
195
+ type TypedValue<V> =
196
+ V extends { readonly type: "slider" | "number" } ? number
197
+ : V extends { readonly type: "checkbox" } ? boolean
198
+ : V extends { readonly type: "list" | "radiogrid" | "segmented" } ? (V extends { readonly options: readonly unknown[] } ? ChoiceValue<V> : never)
199
+ : V extends { readonly type: "color" | "text" | "image" | "plot" } ? string
200
+ : V extends { readonly type: "interval" } ? [number, number]
201
+ : V extends { readonly type: "cubicbezier" } ? [number, number, number, number]
202
+ : V extends { readonly type: "spring" } ? SpringValue
203
+ : V extends { readonly type: "motion" } ? MotionValue
204
+ : V extends { readonly type: "shadow" } ? ShadowValue
205
+ : V extends { readonly type: "sides" } ? SidesValue
206
+ : V extends { readonly type: "gradient" } ? GradientValue
207
+ : V extends { readonly type: "point" } ? (V extends { readonly components: readonly [] } ? never : V extends { readonly components: readonly (infer C)[] } ? { [K in C extends { readonly key: infer Key extends string } ? Key : never]: number }
208
+ : V extends { readonly value: infer P extends object } ? (keyof P extends never ? never : { -readonly [K in keyof P & string]: number }) : never)
209
+ : V extends { readonly type: "tabs" } ? (V extends { readonly pages: infer P extends object } ? (keyof P extends never ? never : TabClash<P> extends true ? Record<string, Params> : { [T in keyof P & string as TabKey<T>]: ParamsOf<P[T]> }) : never)
210
+ : unknown;
211
+ /** The value a schema entry puts on `params`. */
212
+ export type ValueOf<V> =
213
+ V extends { readonly type: infer T } ? (
214
+ string extends T ? any // a `type` widened by a schema held in a variable: the control can't be known
215
+ : T extends SchemaObject["type"] ? (T extends keyof Hinge ? ([Extract<Hinge[T], keyof V>] extends [never] ? ShorthandValue<V> : VerboseValue<V, T>) : VerboseValue<V, T>)
216
+ : ShorthandValue<V>)
217
+ : ShorthandValue<V>;
218
+ type VerboseValue<V, T> = T extends Valueless ? never : TypedValue<V>;
219
+ type ShorthandValue<V> =
220
+ V extends readonly [readonly [number, number], ...unknown[]] ? [number, number]
221
+ : V extends readonly [number] | readonly [number, number] | readonly [number, number, number] | readonly [number, number, number, number] ? number
222
+ : V extends readonly [number, ...number[]] ? V[number] // five numbers or more are a list of them
223
+ : V extends readonly [] ? undefined // an empty list, with no choice to hold
224
+ : V extends readonly number[] ? number // a tuple widened by a schema held in a variable: a slider, or a list of numbers
225
+ : V extends readonly (number | readonly number[])[] ? [number, number]
226
+ : V extends number ? number
227
+ : V extends boolean ? boolean
228
+ : V extends string ? string
229
+ : V extends readonly (infer O)[] ? OptionValue<O>
230
+ : V extends { readonly action: (...a: any[]) => any } ? never
231
+ : V extends { readonly options: readonly unknown[] } ? ChoiceValue<V>
232
+ : V extends object ? Entries<V, true>
233
+ : unknown;
234
+ // Keys the runtime skips at every level, since they would write through to Object.prototype.
235
+ type Reserved = "__proto__" | "constructor" | "prototype";
236
+ // A nested folder's render / disabled / hint are its chrome, not children; at the root and on a
237
+ // tab page they are controls like any other key.
238
+ type FolderChrome<V, K> = K extends "disabled" | "hint" ? true : K extends "render" ? (V extends (...a: any[]) => any ? true : false) : false;
239
+ type Entries<S, Nested extends boolean> = string extends keyof S ? Params
240
+ : { -readonly [K in keyof S & string as K extends Reserved ? never : ValueOf<S[K]> extends never ? never : Nested extends true ? (FolderChrome<S[K], K> extends true ? never : K) : K]: ValueOf<S[K]> };
241
+ /** The live values a schema builds, nested like the schema; a plain `Schema` gives the loose `Params`. */
242
+ export type ParamsOf<S> = Entries<S, false>;
243
+
244
+ // ── Type names checked at the call ──
245
+ // Schema's folder branch takes any object, so `{ type: "slidr", value: 1 }` fit it and compiled. tweaks() and mountControl()
246
+ // intersect their argument with these, which keep a known name, a widened `string` (a schema held in a variable: the
247
+ // runtime decides), and every shorthand as they are, and turn an unknown name into the list of known ones, so it fails
248
+ // right where it's written. A known name without its hinge field is a folder at runtime and isn't checked further; with it,
249
+ // or for a name with no hinge, the runtime builds that control, so its fields must fit the form: `{ type: "image",
250
+ // options: [1] }` read as a folder of a text and a slider otherwise, and compiled.
251
+ type CheckValue<V> =
252
+ V extends { readonly type: infer T } ? (string extends T ? V : T extends SchemaObject["type"] ? (V extends { readonly type: "tabs"; readonly pages: infer P } ? { readonly pages: { [K in keyof P]: KnownTypes<P[K]> } }
253
+ : T extends keyof Hinge ? ([Extract<Hinge[T], keyof V>] extends [never] ? unknown : FitsForm<V, T>) : FitsForm<V, T>) : { readonly type: NotAControlType })
254
+ : V extends readonly unknown[] | ((...a: any[]) => any) ? unknown
255
+ : V extends object ? KnownTypes<V>
256
+ : unknown;
257
+ // The verbose form a known name builds (an alias finds the member that lists it), and V held to it when it doesn't fit.
258
+ type FormOf<O, T> = O extends { readonly type: infer N } ? (T extends N ? O & ControlOptions : never) : never;
259
+ type FitsForm<V, T> = V extends FormOf<SchemaObject, T> ? unknown : FormOf<SchemaObject, T>;
260
+ // A brand, not the list of names: a literal intersected with the list collapses the whole entry to `never`, and the error
261
+ // read "not assignable to type 'never'" on every key. The brand keeps the entry and puts its text in the error.
262
+ type NotAControlType = { readonly "names no control: see the type list in AGENTS.md": true };
263
+ /** A schema whose every `type` names a control (`tweaks()` checks it; `{ type: "slidr" }` is an error). */
264
+ export type KnownTypes<S> = { [K in keyof S]: CheckValue<S[K]> };
265
+ /** One schema value whose `type` names a control (`mountControl()` checks it). */
266
+ export type KnownType<V> = CheckValue<V>;
267
+
268
+ /** A theme — token overrides applied over the default monochrome look. Every key is
269
+ * optional, so a partial theme only moves what it names; unset tokens keep their default.
270
+ * Keys are the `--tw-*` custom properties themselves. Apply at build via
271
+ * `tweaks(name, schema, { theme })`, or live via `panel.setTheme(theme)`. The kit's whole
272
+ * appearance runs on these custom properties — this is the public theming surface. */
273
+ export interface Theme {
274
+ /** Brand accent — slider fills, focus rings, active highlights (default: neutral, no accent). */
275
+ "--tw-accent"?: string;
276
+ /** Text drawn on the accent (active segment / radio label); the primary text tone unless set, so set it with a light accent. */
277
+ "--tw-on-accent"?: string;
278
+ /** Panel background, reused for recessed wells. */
279
+ "--tw-base"?: string;
280
+ /** Popover / dropdown background. */
281
+ "--tw-dropdown-bg"?: string;
282
+ /** Control surface and its hover / active steps. */
283
+ "--tw-surface"?: string; "--tw-surface-hover"?: string; "--tw-surface-active"?: string;
284
+ /** Hairline border and its hover step. */
285
+ "--tw-border"?: string; "--tw-border-hover"?: string;
286
+ /** Text-selection highlight wash. */
287
+ "--tw-selection"?: string;
288
+ /** Text tones — panel title, section heading, primary value text, control label, and the muted / faint / focus tones. */
289
+ "--tw-text-root"?: string; "--tw-text-section"?: string; "--tw-text-primary"?: string; "--tw-text-label"?: string;
290
+ "--tw-text-secondary"?: string; "--tw-text-tertiary"?: string; "--tw-text-focus"?: string;
291
+ /** Copy-confirmation accent, and the invalid-state accent (the plot's bad-expression cue). */
292
+ "--tw-success"?: string; "--tw-danger"?: string;
293
+ /** Popover shadow, the panel's container elevation, and its lifted (floating / dragging) variant. */
294
+ "--tw-shadow-dropdown"?: string; "--tw-shadow-panel"?: string; "--tw-shadow-panel-lifted"?: string;
295
+ /** Font stack, and the monospace stack (the plot's expression field). */
296
+ "--tw-font-sans"?: string; "--tw-font-mono"?: string;
297
+ /** Control corner radius, row height and the panel's width (256px by default), as CSS lengths. */
298
+ "--tw-radius"?: string; "--tw-row-height"?: string; "--tw-width"?: string;
299
+ /** Any other token. */
300
+ [token: `--${string}`]: string | undefined;
301
+ }
302
+
303
+ /** Options for a programmatic write — `set()`, `setMany()`, `fromJSON()`. */
304
+ export interface SetOptions {
305
+ /** Apply without calling your `on()` listeners: for mirroring a value the host already
306
+ * acted on (another tab, its own state), so the change doesn't echo back. Conditional
307
+ * controls, persistence and undo still follow it: it's an undo step like any other. */
308
+ silent?: boolean;
309
+ }
310
+
311
+ /** Where a floating panel starts: `x` / `y` are its insets in px (16 by default) from the
312
+ * viewport's top-left. The far edge keeps a gap of `y` when the height caps; CSS
313
+ * `--tw-float-gap` overrides it. */
314
+ export interface FloatingPlacement { x?: number; y?: number }
315
+
316
+ /** Panel options (third arg to `tweaks()`). */
317
+ export interface TweaksOptions {
318
+ /** Theme overrides as `--tw-*` tokens; `null` / omitted = default monochrome. */
319
+ theme?: Theme | null;
320
+ /** Persist values to localStorage + enable presets — a key, or `true` to key by name. Uploaded images go to
321
+ * IndexedDB under the same key, and come back by `ready`. */
322
+ persist?: string | boolean;
323
+ /** With `persist`, `false` keeps saving values but leaves the Presets menu out of the
324
+ * toolbar (the presets API still works). On by default. */
325
+ presets?: boolean;
326
+ /** Start the panel already floated (fixed-positioned): `true` → 16px from the top-left, or a
327
+ * placement `{ x, y }`. Its height caps at the window and it scrolls, header and all. */
328
+ floating?: boolean | FloatingPlacement;
329
+ /** Drag the header to reposition the panel — on by default. An inline panel lifts into a
330
+ * floating layer on first drag; set `false` to pin it in place. */
331
+ draggable?: boolean;
332
+ /** Set `false` for a bare panel (no copy/reset/presets toolbar). */
333
+ toolbar?: boolean;
334
+ /** Enable debounced undo/redo (⌘Z / ⇧⌘Z while the panel is hovered/focused). */
335
+ undo?: boolean;
336
+ /** Custom reset handler (e.g. restore real app defaults + rebuild). */
337
+ onReset?: () => void;
338
+ }
339
+
340
+ /** The live values bag, loosely typed — `params[key]` for each control. A schema literal passed
341
+ * to `tweaks()` gets its exact shape instead (`ParamsOf`). */
342
+ export type Params = Record<string, any>;
343
+ /** The keys `set()` and `setMany()` take: a typed panel's controls by dotted path (`size`, `card.size`), so a typo is a
344
+ * compile error; any string for a panel built from a plain `Schema`. An object-valued control's own fields read as
345
+ * paths too (`drop.blur`): the types can't tell its value from a folder, so they allow what the runtime refuses. Past ten
346
+ * folders deep the rest of a path is any string, since spelling every one out overran tsc's depth limit. */
347
+ export type PanelPath<P> = PathsOf<P, []>;
348
+ type PathsOf<P, D extends unknown[]> = string extends keyof P ? string : D["length"] extends 10 ? string
349
+ : { [K in keyof P & string]: K | (P[K] extends readonly unknown[] ? never : P[K] extends object ? `${K}.${PathsOf<P[K], [...D, 0]>}` : never) }[keyof P & string];
350
+
351
+ /** The whole-panel state `toJSON()` returns / `fromJSON()` accepts: every control value
352
+ * (nested to mirror folders/tabs) plus UI state — which folders are collapsed and which
353
+ * tab is active. JSON-safe; persist it however you like. Both `values` and `ui` are
354
+ * optional on the way in, so `fromJSON({ values })` restores values only and
355
+ * `fromJSON({ ui })` restores chrome only. (Panel collapse + floating position are not
356
+ * captured — position has its own `opts.persist` channel.) */
357
+ export interface PanelState {
358
+ /** Every control value, **nested** to mirror folders/tabs (e.g. `{ shadow: { radius: 16 } }`)
359
+ * — not the dotted form `set()`/`setMany()` take. `fromJSON` matches by that nested path and
360
+ * skips keys with no matching control. Normally you pass back what `toJSON()` produced. */
361
+ values?: Record<string, any>;
362
+ ui?: {
363
+ /** Folder collapse state → `true` when collapsed. Keys are the folder's path with each
364
+ * segment JSON-pointer-escaped (`.`→`~0`, `~`→`~1`) then joined by `.` — injective, so a
365
+ * literal dot in a key can't collide with the nesting separator. For plain keys that's
366
+ * the dotted path, so `fromJSON({ ui: { folders: { shadow: true } } })` starts one closed. */
367
+ folders?: Record<string, boolean>;
368
+ /** Active tab → the active page's key, under the tabs control's path (escaped as above). */
369
+ tabs?: Record<string, string | null>;
370
+ };
371
+ }
372
+
373
+ /** The panel object `tweaks()` returns; `P` is its values' shape. A bare `Panel` takes any panel, typed or not (its
374
+ * default is `any`, not `Params`: `set()`'s typed paths would make a typed panel unassignable to `Panel<Params>`). */
375
+ export interface Panel<P = any> {
376
+ /** The panel root element — append it to the DOM. */
377
+ el: HTMLElement;
378
+ /** Live values, updated in place as controls change. */
379
+ params: P;
380
+ /** Resolves once any lazily-loaded controls have hydrated and, for a panel that persists an image control, its
381
+ * stored images have loaded (resolves immediately when there's neither). */
382
+ ready: Promise<Panel<P>>;
383
+ /** Subscribe to changes; `last` is the changed control's dotted path (`size`, `card.size`), as `set()` and `changes()`
384
+ * name it, or undefined when many changed at once (a restore, `reset()`, `fromJSON`, `setMany`). */
385
+ on(fn: (params: P, last?: string) => void): () => void;
386
+ /** Set a control's value programmatically. Nested controls take a dotted path
387
+ * ("folder.child", "tabs.page.child"); at runtime a bare key also reaches a nested control
388
+ * when it's unambiguous, though a typed panel's types take the full path. A key matching
389
+ * no control, or a folder or tab, is refused with a console warning. */
390
+ set(key: PanelPath<P>, value: unknown, options?: SetOptions): void;
391
+ /** Set several controls at once — a flat map of keys (dotted for nested controls), e.g.
392
+ * `setMany({ "shadow.radius": 28, blur: 48 })`. Resolves each key like `set()`, then fires
393
+ * listeners + persists ONCE for the whole batch, where N separate `set()` calls would fire
394
+ * N times. Unknown / ambiguous / reserved keys warn and are skipped. */
395
+ setMany(values: { readonly [K in PanelPath<P>]?: unknown }, options?: SetOptions): void;
396
+ /** Reset every control to its default (or run `opts.onReset`). */
397
+ reset(): void;
398
+ /** Serialize the whole panel — every control value plus UI state (collapsed folders,
399
+ * active tabs) — to a plain JSON-safe object, independent of localStorage/presets. Hand
400
+ * it to your own storage: a file, a query string / share link, a server. `JSON.stringify`
401
+ * on the panel calls this too. Most meaningful once `ready` (lazy controls have built). */
402
+ toJSON(): PanelState;
403
+ /** The values that differ from their defaults, keyed by dotted path — `setMany()`'s shape
404
+ * (`{ "shadow.radius": 28, blur: 48 }`), the hand-off for baking tuned values into source.
405
+ * ⇧-click on the toolbar's copy button copies this instead of the full snapshot. Empty
406
+ * before `ready`. */
407
+ changes(): Record<string, unknown>;
408
+ /** Restore a panel from a `toJSON()` object: values are applied where their path still
409
+ * exists (missing ones skipped, like loading a preset), then folder/tab UI state. Fires
410
+ * listeners once. Both halves are optional — pass `{ values }` to restore values only, or
411
+ * `{ ui }` to restore chrome only. The value restore is undoable (one history step); the
412
+ * UI restore is intentionally outside undo, so a Cmd-Z reverts values without thrashing
413
+ * folders/tabs. */
414
+ fromJSON(state: PanelState, options?: SetOptions): void;
415
+ /** Re-theme the panel live; `null` reverts to the default. */
416
+ setTheme(theme?: Theme | null): void;
417
+ /** Retitle the panel live — the header follows. The name it was built
418
+ * with stays its storage key (`persist: true`), so saved values and presets keep their
419
+ * home across a rename. */
420
+ setName(name: string): void;
421
+ /** Save the current values as a named preset (needs `opts.persist`). False when there is nowhere to save it, or storage refused it. */
422
+ savePreset(name: string): boolean;
423
+ /** Load a named preset. */
424
+ loadPreset(name: string): boolean;
425
+ /** Delete a named preset. */
426
+ deletePreset(name: string): void;
427
+ /** List saved preset names. */
428
+ presets(): string[];
429
+ /** Undo / redo (need `opts.undo`). */
430
+ undo(): void;
431
+ redo(): void;
432
+ /** Tear the panel down — remove it from the DOM, release every global listener, close any open popover; the API goes inert. */
433
+ destroy(): void;
434
+ }
435
+
436
+ /** A built control's handle (internal-ish, but used across modules). */
437
+ export interface Control {
438
+ el: HTMLElement;
439
+ set(value: unknown): void;
440
+ get(): unknown;
441
+ /** Release any loop the control owns (the monitor's poll, the FPS graph's rAF). Only
442
+ * the live controls supply it; the panel calls it from `destroy()`. */
443
+ destroy?(): void;
444
+ /** Show a short message on the control for a moment. Only the image control supplies it; the panel calls it when
445
+ * storage refuses the control's value. */
446
+ note?(message: string): void;
447
+ /** The hard range set() fits a number into, as the control resolved it (derived bounds too); a point's per component.
448
+ * Only the numeric controls supply it; the panel's clamp notice reads it. */
449
+ range?: [number, number] | Record<string, [number, number]>;
450
+ }
451
+
452
+ /** Options for `mountControl(host, value, opts?)` — one control, no panel. */
453
+ export interface MountOptions {
454
+ /** The key passed to `onChange` and used to derive the label when none is given (default `"value"`). */
455
+ key?: string;
456
+ /** The label shown on the control; `""` shows none (the host page already has one). Wins over
457
+ * the title-cased key and over a verbose form's own `label`. */
458
+ label?: string;
459
+ /** Fired with the new value whenever the control changes — by hand or through `set()`. */
460
+ onChange?: (value: unknown, key: string) => void;
461
+ }
462
+
463
+ /** The handle `mountControl()` returns. */
464
+ export interface MountedControl {
465
+ /** The wrapper (`.tw-control`) the control lives in, already appended to the host and
466
+ * stable from creation — it carries the kit's tokens, so the control renders outside a
467
+ * panel. On the code-split build a lazily-loaded control fills it in once `ready` resolves. */
468
+ el: HTMLElement;
469
+ /** Apply a value — the control updates AND `onChange` fires (when the value actually
470
+ * changed). Before `ready`, the value is queued and applied once the control builds. */
471
+ set(value: unknown): void;
472
+ /** The current value (the queued or schema value before `ready`; `undefined` for a
473
+ * valueless control such as a button). */
474
+ get(): unknown;
475
+ /** Remove the control from the host and release anything it owns. */
476
+ destroy(): void;
477
+ /** Resolves with this handle once the control has built — immediately on the single-file build. */
478
+ ready: Promise<MountedControl>;
479
+ }
480
+
481
+ /** The notations the color picker edits in (its mode dropdown). The emitted value follows
482
+ * the mode: `hex` gives `#RRGGBB`, the wide-gamut modes give a CSS color like `oklch(…)`. */
483
+ export type ColorMode = "hex" | "srgb" | "css" | "hsl" | "hwb" | "oklch" | "oklab" | "lch" | "lab" | "p3" | "rec2020";
484
+
485
+ /** Options for `createColorPicker(opts?)` — the picker body alone, no trigger row. */
486
+ export interface ColorPickerOptions {
487
+ /** The color it opens on — any CSS color string (default `#7c5cff`). */
488
+ value?: string;
489
+ /** The notation it opens in (default `oklch`). */
490
+ mode?: ColorMode;
491
+ /** Fired with the new color string whenever it changes — by hand or through `set()`. */
492
+ onChange?: (value: string) => void;
493
+ }
494
+
495
+ /** The handle `createColorPicker()` returns. Bring your own trigger: append `el` to a popover
496
+ * or a panel of your own; the kit owns the editor surface and the gamut math. */
497
+ export interface ColorPicker {
498
+ /** The picker's root (`.tw-color-picker`), stable from creation; it carries the kit's tokens,
499
+ * so the picker renders anywhere. On the code-split build the editor fills it in once the
500
+ * color chunk has loaded. */
501
+ el: HTMLElement;
502
+ /** Apply a color — the picker updates AND `onChange` fires (when the color actually changed). */
503
+ set(value: string): void;
504
+ /** The current color, serialized in the picker's mode. */
505
+ get(): string;
506
+ /** The current notation. */
507
+ mode(): ColorMode;
508
+ /** Switch notation — formatting only, nothing fires. */
509
+ setMode(mode: ColorMode): void;
510
+ /** Re-rasterize and re-place the thumbs. It reflows itself a frame after creation; call
511
+ * this when you show it later (a popover opening) or after it resizes. */
512
+ reflow(): void;
513
+ /** Remove the picker from the DOM. */
514
+ destroy(): void;
515
+ /** Resolves with this handle once the editor has built — immediately on the single-file build. */
516
+ ready: Promise<ColorPicker>;
517
+ }
518
+
519
+ /** `detail` of the `tw:change` event a `[data-tw]` host dispatches (bubbling) whenever its
520
+ * control changes — `key` is the host's `data-key` (or its label), `value` the new value,
521
+ * also written to the host's `data-value`. */
522
+ export interface TwChangeDetail { key: string; value: unknown; }
523
+
524
+ declare global {
525
+ interface HTMLElementEventMap { "tw:change": CustomEvent<TwChangeDetail>; }
526
+ }