@spicetify/kit 0.1.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 (115) hide show
  1. package/bin/spicetify-kit.js +4 -0
  2. package/dist/build.js +335 -0
  3. package/dist/check.js +284 -0
  4. package/dist/classmap.js +187 -0
  5. package/dist/cli.js +53 -0
  6. package/dist/create.js +429 -0
  7. package/dist/dev.js +100 -0
  8. package/dist/from-theme.js +96 -0
  9. package/dist/install.js +60 -0
  10. package/dist/launch.js +122 -0
  11. package/dist/pack.js +31 -0
  12. package/dist/push.js +211 -0
  13. package/dist/vault.js +108 -0
  14. package/package.json +49 -0
  15. package/vendor/classmaps/1020096/classmap-19f856aefd5.json +109 -0
  16. package/vendor/shims/chunks.d.ts +10 -0
  17. package/vendor/shims/remote-modules.d.ts +6 -0
  18. package/vendor/shims/spicetify.d.ts +2437 -0
  19. package/vendor/stdlib/deps.ts +24 -0
  20. package/vendor/stdlib/index.ts +24 -0
  21. package/vendor/stdlib/lib/modal.tsx +128 -0
  22. package/vendor/stdlib/lib/popover.ts +84 -0
  23. package/vendor/stdlib/lib/primitives-classes.ts +66 -0
  24. package/vendor/stdlib/lib/primitives-vanilla.ts +277 -0
  25. package/vendor/stdlib/lib/primitives.tsx +314 -0
  26. package/vendor/stdlib/load.ts +17 -0
  27. package/vendor/stdlib/metadata.json +18 -0
  28. package/vendor/stdlib/mixin.ts +32 -0
  29. package/vendor/stdlib/mod.ts +26 -0
  30. package/vendor/stdlib/src/chunks.d.ts +10 -0
  31. package/vendor/stdlib/src/client.ts +114 -0
  32. package/vendor/stdlib/src/createIconComponent.tsx +29 -0
  33. package/vendor/stdlib/src/events.mix.ts +55 -0
  34. package/vendor/stdlib/src/events.ts +58 -0
  35. package/vendor/stdlib/src/expose/GraphQL.ts +40 -0
  36. package/vendor/stdlib/src/expose/Platform.ts +48 -0
  37. package/vendor/stdlib/src/expose/React.ts +95 -0
  38. package/vendor/stdlib/src/expose/ReactFlipToolkitSpring.ts +27 -0
  39. package/vendor/stdlib/src/expose/ReduxStore.ts +39 -0
  40. package/vendor/stdlib/src/expose/SettingsSection.ts +60 -0
  41. package/vendor/stdlib/src/expose/Snackbar.ts +34 -0
  42. package/vendor/stdlib/src/expose/Tippy.ts +26 -0
  43. package/vendor/stdlib/src/expose/enqueueImageSnackbar.ts +25 -0
  44. package/vendor/stdlib/src/expose/index.ts +14 -0
  45. package/vendor/stdlib/src/expose/jsx-runtime.ts +46 -0
  46. package/vendor/stdlib/src/expose/react-dom-shim.ts +50 -0
  47. package/vendor/stdlib/src/expose/react-shim.ts +116 -0
  48. package/vendor/stdlib/src/logger.ts +35 -0
  49. package/vendor/stdlib/src/playbar-compat.tsx +136 -0
  50. package/vendor/stdlib/src/registers/index.ts +176 -0
  51. package/vendor/stdlib/src/registers/menu.ts +160 -0
  52. package/vendor/stdlib/src/registers/mount.ts +307 -0
  53. package/vendor/stdlib/src/registers/nativeAnchors.ts +49 -0
  54. package/vendor/stdlib/src/registers/navlink.tsx +165 -0
  55. package/vendor/stdlib/src/registers/order.ts +22 -0
  56. package/vendor/stdlib/src/registers/panel.ts +168 -0
  57. package/vendor/stdlib/src/registers/playbarButton.tsx +93 -0
  58. package/vendor/stdlib/src/registers/playbarWidget.tsx +71 -0
  59. package/vendor/stdlib/src/registers/registry.ts +12 -0
  60. package/vendor/stdlib/src/registers/root.ts +101 -0
  61. package/vendor/stdlib/src/registers/route.ts +123 -0
  62. package/vendor/stdlib/src/registers/settingsRow.tsx +56 -0
  63. package/vendor/stdlib/src/registers/settingsSection.ts +59 -0
  64. package/vendor/stdlib/src/registers/topbarLeftButton.tsx +86 -0
  65. package/vendor/stdlib/src/registers/topbarRightButton.tsx +87 -0
  66. package/vendor/stdlib/src/storage.ts +163 -0
  67. package/vendor/stdlib/src/util.ts +62 -0
  68. package/vendor/stdlib/src/utils/index.ts +16 -0
  69. package/vendor/stdlib/src/webpack/ClassNames.gen.ts +10 -0
  70. package/vendor/stdlib/src/webpack/ClassNames.ts +6 -0
  71. package/vendor/stdlib/src/webpack/ClassNames.xpui.ts +16 -0
  72. package/vendor/stdlib/src/webpack/ComponentLibrary.gen.ts +10 -0
  73. package/vendor/stdlib/src/webpack/ComponentLibrary.ts +6 -0
  74. package/vendor/stdlib/src/webpack/ComponentLibrary.xpui.ts +18 -0
  75. package/vendor/stdlib/src/webpack/FilterContext.gen.ts +10 -0
  76. package/vendor/stdlib/src/webpack/FilterContext.ts +6 -0
  77. package/vendor/stdlib/src/webpack/FilterContext.xpui.ts +10 -0
  78. package/vendor/stdlib/src/webpack/Mousetrap.gen.ts +10 -0
  79. package/vendor/stdlib/src/webpack/Mousetrap.ts +6 -0
  80. package/vendor/stdlib/src/webpack/Mousetrap.xpui.ts +12 -0
  81. package/vendor/stdlib/src/webpack/React.gen.ts +14 -0
  82. package/vendor/stdlib/src/webpack/React.ts +6 -0
  83. package/vendor/stdlib/src/webpack/React.xpui.ts +15 -0
  84. package/vendor/stdlib/src/webpack/ReactComponents.desktop.ts +17 -0
  85. package/vendor/stdlib/src/webpack/ReactComponents.gen.ts +74 -0
  86. package/vendor/stdlib/src/webpack/ReactComponents.panel.ts +19 -0
  87. package/vendor/stdlib/src/webpack/ReactComponents.ts +50 -0
  88. package/vendor/stdlib/src/webpack/ReactComponents.xpui.ts +164 -0
  89. package/vendor/stdlib/src/webpack/ReactFlipToolkit.gen.ts +12 -0
  90. package/vendor/stdlib/src/webpack/ReactFlipToolkit.ts +6 -0
  91. package/vendor/stdlib/src/webpack/ReactFlipToolkit.xpui.ts +14 -0
  92. package/vendor/stdlib/src/webpack/ReactHooks.fullscreen.ts +15 -0
  93. package/vendor/stdlib/src/webpack/ReactHooks.gen.ts +32 -0
  94. package/vendor/stdlib/src/webpack/ReactHooks.ts +6 -0
  95. package/vendor/stdlib/src/webpack/ReactHooks.xpui.ts +38 -0
  96. package/vendor/stdlib/src/webpack/ReactQuery.gen.ts +26 -0
  97. package/vendor/stdlib/src/webpack/ReactQuery.ts +6 -0
  98. package/vendor/stdlib/src/webpack/ReactQuery.xpui.ts +46 -0
  99. package/vendor/stdlib/src/webpack/ReactRouter.gen.ts +12 -0
  100. package/vendor/stdlib/src/webpack/ReactRouter.ts +6 -0
  101. package/vendor/stdlib/src/webpack/ReactRouter.xpui.ts +22 -0
  102. package/vendor/stdlib/src/webpack/Snackbar.gen.ts +12 -0
  103. package/vendor/stdlib/src/webpack/Snackbar.ts +6 -0
  104. package/vendor/stdlib/src/webpack/Snackbar.xpui.ts +27 -0
  105. package/vendor/stdlib/src/webpack/URI.gen.ts +26 -0
  106. package/vendor/stdlib/src/webpack/URI.ts +82 -0
  107. package/vendor/stdlib/src/webpack/URI.xpui.ts +111 -0
  108. package/vendor/stdlib/src/webpack/capture-readiness.ts +49 -0
  109. package/vendor/stdlib/src/webpack/index.ts +186 -0
  110. package/vendor/stdlib/src/webpack/misc.gen.ts +16 -0
  111. package/vendor/stdlib/src/webpack/misc.ts +6 -0
  112. package/vendor/stdlib/src/webpack/misc.xpui.ts +27 -0
  113. package/vendor/stdlib/src/wpunpk.mix.ts +104 -0
  114. package/vendor/stdlib/src/wpunpk.ts +36 -0
  115. package/vendor/stdlib/vendor/rxjs.d.ts +5 -0
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Copyright (C) 2024 Delusoire
3
+ * Copyright (C) 2026 Afonso Jorge Ramos
4
+ * SPDX-License-Identifier: GPL-3.0-or-later
5
+ */
6
+
7
+ // Prebundled vendor copy (one self-contained file; vendor/rxjs.d.ts carries
8
+ // the types), so stdlib boots without the network. The esm.sh imports this
9
+ // replaces were static, which put esm.sh in every client's boot path and
10
+ // made an offline boot fail the whole module graph. A bare "rxjs" import is
11
+ // no good either: the tree build preserves modules, so it splats the package
12
+ // into dist as dozens of files under the resolver's store path.
13
+ export { BehaviorSubject, Subject, Subscription } from "./vendor/rxjs.js";
14
+
15
+ // The one remaining lodash consumer is startCase; a local implementation
16
+ // beats shipping lodash for it. Splits camelCase and non-alphanumeric
17
+ // boundaries, capitalizes each word ("miserly-magenta" -> "Miserly Magenta").
18
+ export const startCase = (value: string): string =>
19
+ value
20
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
21
+ .split(/[^a-zA-Z0-9]+/)
22
+ .filter(Boolean)
23
+ .map((word) => word[0].toUpperCase() + word.slice(1))
24
+ .join(" ");
@@ -0,0 +1,24 @@
1
+ /*
2
+ * Copyright (C) 2024 Delusoire
3
+ * Copyright (C) 2026 Afonso Jorge Ramos
4
+ * SPDX-License-Identifier: GPL-3.0-or-later
5
+ */
6
+
7
+ import type { Transformer } from "./mixin.ts";
8
+
9
+ // Lifecycle contract for the spicetify v3 modular loader (no /hooks).
10
+ export async function mixin(transformer: Transformer, ctx: { spotifyVersion: string }) {
11
+ return (await import("./mixin.js")).default(transformer, ctx);
12
+ }
13
+
14
+ // The loader runs module lifecycles sequentially in dependency order. Holding
15
+ // stdlib's preload until its webpack analysis settles prevents every later UI
16
+ // module from observing half-populated React/component exports.
17
+ export async function preload() {
18
+ const { waitForWebpackCapture } = await import("./src/webpack/index.js");
19
+ await waitForWebpackCapture();
20
+ }
21
+
22
+ export async function load(ctx: { spotifyVersion: string }) {
23
+ return (await import("./load.js")).default(ctx);
24
+ }
@@ -0,0 +1,128 @@
1
+ /*
2
+ * Copyright (C) 2024 Delusoire
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ import { React } from "../src/expose/React.ts";
7
+ import { Locale } from "../src/webpack/misc.ts";
8
+
9
+ import RootRegistry from "../src/registers/root.ts";
10
+ const [RootChildrenRegistry] = RootRegistry;
11
+ import { createIconComponent } from "../src/createIconComponent.tsx";
12
+
13
+ let ref: React.ReactElement | undefined = undefined;
14
+
15
+ export function display({ title, content, isLarge }: { title: string; content: React.ReactElement; isLarge: boolean }) {
16
+ hide();
17
+
18
+ RootChildrenRegistry.add(
19
+ (ref = (
20
+ <PopupModal contentLabel={title} children={content} isEmbedWidgetGeneratorOrTrackCreditsModal={isLarge} />
21
+ )),
22
+ );
23
+ }
24
+
25
+ export function hide() {
26
+ if (ref) {
27
+ RootChildrenRegistry.delete(ref);
28
+ ref = undefined;
29
+ }
30
+ }
31
+
32
+ interface PopupModalProps {
33
+ contentLabel: string;
34
+ children: React.ReactNode;
35
+ isEmbedWidgetGeneratorOrTrackCreditsModal: boolean;
36
+ }
37
+
38
+ // Self-contained modal shell: client modal components (GenericModal, UI.*)
39
+ // require the client tree's contexts, which anchors don't have. The shell
40
+ // owns the backdrop and dismissal; the MAP-classed chrome keeps native
41
+ // styling.
42
+ const ModalShell = (props: { contentLabel: string; children: React.ReactNode }) => {
43
+ React.useEffect(() => {
44
+ const onKey = (e: KeyboardEvent) => {
45
+ if (e.key === "Escape") hide();
46
+ };
47
+ document.addEventListener("keydown", onKey);
48
+ return () => document.removeEventListener("keydown", onKey);
49
+ }, []);
50
+
51
+ return (
52
+ <div
53
+ role="dialog"
54
+ aria-label={props.contentLabel}
55
+ style={{
56
+ position: "fixed",
57
+ inset: 0,
58
+ zIndex: 100,
59
+ display: "flex",
60
+ alignItems: "center",
61
+ justifyContent: "center",
62
+ background: "rgba(0, 0, 0, 0.7)",
63
+ }}
64
+ onClick={(e) => {
65
+ if (e.target === e.currentTarget) hide();
66
+ }}
67
+ >
68
+ <div
69
+ style={{
70
+ background: "var(--spice-card, var(--background-elevated-base, #282828))",
71
+ color: "var(--spice-text, var(--text-base, #fff))",
72
+ borderRadius: "8px",
73
+ maxHeight: "80vh",
74
+ maxWidth: "90vw",
75
+ overflow: "auto",
76
+ }}
77
+ >
78
+ {props.children}
79
+ </div>
80
+ </div>
81
+ );
82
+ };
83
+
84
+ const closeIcon =
85
+ "<path d='M2.47 2.47a.75.75 0 0 1 1.06 0L8 6.94l4.47-4.47a.75.75 0 1 1 1.06 1.06L9.06 8l4.47 4.47a.75.75 0 1 1-1.06 1.06L8 9.06l-4.47 4.47a.75.75 0 0 1-1.06-1.06L6.94 8 2.47 3.53a.75.75 0 0 1 0-1.06Z'/>";
86
+
87
+ const PopupModal = (props: PopupModalProps) => {
88
+ if (props.isEmbedWidgetGeneratorOrTrackCreditsModal) {
89
+ return (
90
+ <ModalShell contentLabel={props.contentLabel}>
91
+ <div className={MAP.modal.widget_generator.container} style={{ overflow: "scroll", width: "60vw" }}>
92
+ <div className={MAP.modal.widget_generator.header.container}>
93
+ <h1 className="encore-text encore-text-title-small">{props.contentLabel}</h1>
94
+ <button className={MAP.modal.widget_generator.header.close} onClick={hide}>
95
+ {createIconComponent({
96
+ icon: closeIcon,
97
+ "aria-label": Locale.get("close"),
98
+ })}
99
+ </button>
100
+ </div>
101
+ <div className={MAP.modal.widget_generator.content.container}>{props.children}</div>
102
+ </div>
103
+ </ModalShell>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <ModalShell contentLabel={props.contentLabel}>
109
+ <div className={MAP.modal.track_credits.container}>
110
+ <div className={MAP.modal.track_credits.header.container}>
111
+ <h1 className="encore-text encore-text-title-medium">{props.contentLabel}</h1>
112
+ <button
113
+ className={MAP.modal.track_credits.header.close}
114
+ aria-label={Locale.get("close")}
115
+ onClick={hide}
116
+ >
117
+ {createIconComponent({
118
+ icon: closeIcon,
119
+ "aria-label": Locale.get("close"),
120
+ iconSize: 18,
121
+ })}
122
+ </button>
123
+ </div>
124
+ <div className={MAP.modal.track_credits.content.container}>{props.children}</div>
125
+ </div>
126
+ </ModalShell>
127
+ );
128
+ };
@@ -0,0 +1,84 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ // Anchored popover: positions content in a floating panel under a trigger
7
+ // element (topbar buttons, playbar buttons, ...) and owns dismissal —
8
+ // click-outside, Escape, and reposition on resize. Content can be a React
9
+ // node (rendered with the client React) or a plain HTMLElement, so vanilla
10
+ // modules can use it without touching React.
11
+
12
+ import { React, ReactDOM } from "../src/expose/React.ts";
13
+
14
+ export interface PopoverHandle {
15
+ close: () => void;
16
+ host: HTMLElement;
17
+ }
18
+
19
+ export interface PopoverOptions {
20
+ anchor: HTMLElement;
21
+ content: HTMLElement | React.ReactNode;
22
+ onClose?: () => void;
23
+ gap?: number;
24
+ }
25
+
26
+ export function openPopover({ anchor, content, onClose, gap = 8 }: PopoverOptions): PopoverHandle {
27
+ const host = document.createElement("div");
28
+ host.className = "spicetify-popover";
29
+ Object.assign(host.style, {
30
+ position: "fixed",
31
+ zIndex: "1000",
32
+ background: "var(--spice-card, var(--background-elevated-base, #282828))",
33
+ color: "var(--spice-text, var(--text-base, #fff))",
34
+ borderRadius: "8px",
35
+ boxShadow: "0 16px 24px rgba(0, 0, 0, 0.3), 0 6px 8px rgba(0, 0, 0, 0.2)",
36
+ maxHeight: "80vh",
37
+ overflow: "auto",
38
+ });
39
+
40
+ const place = () => {
41
+ const rect = anchor.getBoundingClientRect();
42
+ host.style.top = `${Math.round(Math.min(rect.bottom + gap, window.innerHeight - 64))}px`;
43
+ const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - host.offsetWidth - 8));
44
+ host.style.left = `${Math.round(left)}px`;
45
+ };
46
+
47
+ let root: { unmount: () => void } | undefined;
48
+ if (content instanceof HTMLElement) {
49
+ host.appendChild(content);
50
+ } else {
51
+ const createRoot = (ReactDOM as any).createRoot;
52
+ root = createRoot(host);
53
+ (root as any).render(React.createElement(React.Fragment, null, content as any));
54
+ }
55
+
56
+ let closed = false;
57
+ const close = () => {
58
+ if (closed) return;
59
+ closed = true;
60
+ window.removeEventListener("resize", place);
61
+ document.removeEventListener("mousedown", onDocDown, true);
62
+ document.removeEventListener("keydown", onKey, true);
63
+ root?.unmount();
64
+ host.remove();
65
+ onClose?.();
66
+ };
67
+ const onDocDown = (e: MouseEvent) => {
68
+ const target = e.target as Node;
69
+ if (!host.contains(target) && !anchor.contains(target)) close();
70
+ };
71
+ const onKey = (e: KeyboardEvent) => {
72
+ if (e.key === "Escape") close();
73
+ };
74
+
75
+ document.body.appendChild(host);
76
+ place();
77
+ // A second pass once layout has produced the real width, for clamping.
78
+ requestAnimationFrame(place);
79
+ window.addEventListener("resize", place);
80
+ document.addEventListener("mousedown", onDocDown, true);
81
+ document.addEventListener("keydown", onKey, true);
82
+
83
+ return { close, host };
84
+ }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ /**
7
+ * The single source of truth for the module chrome-class contract. Both
8
+ * the vanilla kit (ui.ts) and the React primitives (primitives.tsx) apply these,
9
+ * so the two tiers can never drift on which class means which primitive.
10
+ * The classes themselves live in stdlib's stylesheet (index.scss).
11
+ */
12
+
13
+ export type ButtonVariant = "primary" | "secondary" | "danger";
14
+ export type BadgeTone = "neutral" | "ok" | "bad";
15
+
16
+ export const buttonClass = (variant: ButtonVariant = "primary"): string =>
17
+ variant === "primary" ? "spicetify-button" : `spicetify-button spicetify-button--${variant}`;
18
+
19
+ export const badgeClass = (tone: BadgeTone = "neutral"): string =>
20
+ tone === "neutral" ? "spicetify-badge" : `spicetify-badge spicetify-badge--${tone}`;
21
+
22
+ export const chipClass = (active: boolean): string =>
23
+ active ? "spicetify-chip spicetify-chip--active" : "spicetify-chip";
24
+
25
+ export const ICON_BUTTON_CLASS = "spicetify-button-circle";
26
+ // A row in one of Spotify's own context menus. Unlike the spicetify-*
27
+ // classes above (ours, styled in index.scss), this is Spotify's stable
28
+ // semantic class — the kit owns it in this one place so module code never
29
+ // has to name a client class to get a native-looking menu item.
30
+ export const MENU_ITEM_CLASS = "main-contextMenu-menuItemButton";
31
+ export const SELECT_CLASS = "spicetify-select";
32
+ export const SEARCHBAR_CLASS = "spicetify-searchbar";
33
+ export const CARD_CLASS = "spicetify-card";
34
+ export const SCRIM_CLASS = "spicetify-scrim";
35
+ export const DIALOG_CLASS = "spicetify-dialog";
36
+ export const DIALOG_HEADER_CLASS = "spicetify-dialog-header";
37
+ export const DIALOG_BODY_CLASS = "spicetify-dialog-body";
38
+
39
+ // The client's own settings chrome. These names exist because the CLI's
40
+ // css-map recreates them, so a module row is styled by Spotify's own CSS
41
+ // instead of approximating it.
42
+ export const SETTINGS_SECTION_CLASS = "x-settings-section";
43
+ export const SETTINGS_ROW_CLASS = "x-settings-row";
44
+ export const SETTINGS_ROW_LABEL_CLASS = "x-settings-firstColumn";
45
+ export const SETTINGS_ROW_CONTROL_CLASS = "x-settings-secondColumn";
46
+ export const TOGGLE_CLASSES = {
47
+ wrapper: "x-toggle-wrapper",
48
+ input: "x-toggle-input",
49
+ indicatorWrapper: "x-toggle-indicatorWrapper",
50
+ indicator: "x-toggle-indicator",
51
+ } as const;
52
+
53
+ /** @deprecated Use TOGGLE_CLASSES with the native switch structure. */
54
+ export const TOGGLE_CLASS = "spicetify-toggle";
55
+
56
+ export function activateToggleOnKeyDown(event: {
57
+ key: string;
58
+ currentTarget: { click: () => void };
59
+ preventDefault: () => void;
60
+ stopPropagation: () => void;
61
+ }): void {
62
+ if (event.key !== " " && event.key !== "Enter") return;
63
+ event.preventDefault();
64
+ event.stopPropagation();
65
+ event.currentTarget.click();
66
+ }
@@ -0,0 +1,277 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ /**
7
+ * A vanilla, React-free component kit for module UIs. Every primitive is
8
+ * a plain function returning a typed HTMLElement, built on the `h()`
9
+ * hyperscript, and applies the shared `.spicetify-*` chrome classes from
10
+ * stdlib's stylesheet. Because it touches nothing but `document`, it is
11
+ * safe on standalone module surfaces (anchors, body dialogs, popovers)
12
+ * where the client React instance and client webpack components are
13
+ * unavailable — the same tier-2 role the chrome classes already fill,
14
+ * now as named, composable primitives.
15
+ *
16
+ * Inside a client React tree, prefer the real client components via
17
+ * stdlib's ComponentLibrary; this kit is for module-owned DOM.
18
+ */
19
+
20
+ import {
21
+ badgeClass,
22
+ buttonClass,
23
+ type BadgeTone,
24
+ type ButtonVariant,
25
+ CARD_CLASS,
26
+ chipClass,
27
+ DIALOG_BODY_CLASS,
28
+ DIALOG_CLASS,
29
+ DIALOG_HEADER_CLASS,
30
+ ICON_BUTTON_CLASS,
31
+ SCRIM_CLASS,
32
+ SEARCHBAR_CLASS,
33
+ SELECT_CLASS,
34
+ } from "./primitives-classes.ts";
35
+
36
+ export type { BadgeTone, ButtonVariant } from "./primitives-classes.ts";
37
+
38
+ // Typed event handlers: only real DOM events are accepted, so a
39
+ // misspelled `onClik` is a type error rather than a silently-attached
40
+ // listener for a "clik" event.
41
+ type EventHandlers = {
42
+ [E in keyof GlobalEventHandlersEventMap as `on${Capitalize<E>}`]?: (event: GlobalEventHandlersEventMap[E]) => void;
43
+ };
44
+
45
+ // Props for h(): className/textContent/style, dataset, aria-* attributes,
46
+ // on<Event> listeners, and any writable property of the element itself
47
+ // (value, disabled, placeholder, type, ...), so consumers never cast.
48
+ export type Props<K extends keyof HTMLElementTagNameMap> = Partial<
49
+ Omit<HTMLElementTagNameMap[K], `on${string}` | "style" | "children" | "dataset">
50
+ > &
51
+ EventHandlers & {
52
+ className?: string;
53
+ dataset?: Record<string, string>;
54
+ style?: Partial<CSSStyleDeclaration>;
55
+ [aria: `aria-${string}`]: string | undefined;
56
+ };
57
+
58
+ export type Child = string | Node | false | null | undefined | Child[];
59
+
60
+ const appendChild = (parent: HTMLElement, child: Child): void => {
61
+ if (child === false || child === null || child === undefined) return;
62
+ if (Array.isArray(child)) {
63
+ for (const c of child) appendChild(parent, c);
64
+ return;
65
+ }
66
+ parent.append(child);
67
+ };
68
+
69
+ // h - typed hyperscript. The generalized, exported successor to the
70
+ // store's local el(); assigns known element properties directly (so no
71
+ // `as HTMLXElement` casts), maps on<Event> keys to addEventListener, and
72
+ // flattens truthy children.
73
+ export function h<K extends keyof HTMLElementTagNameMap>(
74
+ tag: K,
75
+ props?: Props<K> | null,
76
+ ...children: Child[]
77
+ ): HTMLElementTagNameMap[K] {
78
+ const node = document.createElement(tag);
79
+ if (props) {
80
+ for (const [key, value] of Object.entries(props)) {
81
+ if (value === undefined) continue;
82
+ if (key === "dataset") {
83
+ Object.assign(node.dataset, value);
84
+ } else if (key === "style") {
85
+ Object.assign(node.style, value);
86
+ } else if (key.startsWith("aria-")) {
87
+ node.setAttribute(key, value as string);
88
+ } else if (/^on[A-Z]/.test(key) && typeof value === "function") {
89
+ node.addEventListener(key.slice(2).toLowerCase(), value as EventListener);
90
+ } else {
91
+ (node as Record<string, unknown>)[key] = value;
92
+ }
93
+ }
94
+ }
95
+ for (const child of children) appendChild(node, child);
96
+ return node;
97
+ }
98
+
99
+ // ---------- buttons ----------
100
+
101
+ export function Button(props: {
102
+ label: string;
103
+ variant?: ButtonVariant;
104
+ onClick: () => void;
105
+ disabled?: boolean;
106
+ icon?: Node;
107
+ }): HTMLButtonElement {
108
+ return h(
109
+ "button",
110
+ { type: "button", className: buttonClass(props.variant), disabled: props.disabled, onClick: props.onClick },
111
+ props.icon,
112
+ props.label,
113
+ );
114
+ }
115
+
116
+ // The circular icon button Spotify uses for modal close (Credits, etc).
117
+ export function IconButton(props: { glyph: string; ariaLabel: string; onClick: () => void }): HTMLButtonElement {
118
+ return h("button", {
119
+ type: "button",
120
+ className: ICON_BUTTON_CLASS,
121
+ "aria-label": props.ariaLabel,
122
+ textContent: props.glyph,
123
+ onClick: props.onClick,
124
+ });
125
+ }
126
+
127
+ // ---------- inputs ----------
128
+
129
+ export function Select<T extends string>(props: {
130
+ options: ReadonlyArray<{ value: T; label: string }>;
131
+ value: T;
132
+ onChange: (value: T) => void;
133
+ }): HTMLSelectElement {
134
+ const select = h("select", {
135
+ className: SELECT_CLASS,
136
+ onChange: () => props.onChange(select.value as T),
137
+ });
138
+ for (const option of props.options) {
139
+ select.append(
140
+ h("option", { value: option.value, textContent: option.label, selected: option.value === props.value }),
141
+ );
142
+ }
143
+ select.value = props.value;
144
+ return select;
145
+ }
146
+
147
+ export function TextInput(props: {
148
+ placeholder?: string;
149
+ value?: string;
150
+ onInput?: (value: string) => void;
151
+ disabled?: boolean;
152
+ }): HTMLInputElement {
153
+ const input = h("input", {
154
+ type: "text",
155
+ className: SEARCHBAR_CLASS,
156
+ placeholder: props.placeholder,
157
+ value: props.value,
158
+ disabled: props.disabled,
159
+ onInput: props.onInput ? () => props.onInput!(input.value) : undefined,
160
+ });
161
+ return input;
162
+ }
163
+
164
+ export function Textarea(props: {
165
+ placeholder?: string;
166
+ value?: string;
167
+ onInput?: (value: string) => void;
168
+ }): HTMLTextAreaElement {
169
+ const textarea = h("textarea", {
170
+ placeholder: props.placeholder,
171
+ value: props.value,
172
+ onInput: props.onInput ? () => props.onInput!(textarea.value) : undefined,
173
+ });
174
+ return textarea;
175
+ }
176
+
177
+ // ---------- badges, chips, cards ----------
178
+
179
+ export function Badge(props: { text: string; tone?: BadgeTone }): HTMLSpanElement {
180
+ return h("span", { className: badgeClass(props.tone), textContent: props.text });
181
+ }
182
+
183
+ export function Chip(props: { label: string; active: boolean; onClick: () => void }): HTMLButtonElement {
184
+ return h("button", {
185
+ type: "button",
186
+ className: chipClass(props.active),
187
+ textContent: props.label,
188
+ onClick: props.onClick,
189
+ });
190
+ }
191
+
192
+ // A plain elevated container; slot layout is the consumer's own concern.
193
+ export function Card(props: { children: Child }): HTMLElement {
194
+ return h("article", { className: CARD_CLASS }, props.children);
195
+ }
196
+
197
+ // ---------- two-step confirm ----------
198
+
199
+ // ConfirmButton encapsulates the arm-then-confirm pattern: the first
200
+ // click swaps the label and arms a revert timer; a second click within
201
+ // the window confirms. Timers are injectable so the window is
202
+ // deterministic under test.
203
+ export function ConfirmButton(props: {
204
+ label: string;
205
+ confirmLabel: string;
206
+ onConfirm: () => void;
207
+ variant?: ButtonVariant;
208
+ windowMs?: number;
209
+ setTimer?: (fn: () => void, ms: number) => number;
210
+ clearTimer?: (handle: number) => void;
211
+ }): HTMLButtonElement {
212
+ const setTimer = props.setTimer ?? ((fn, ms) => setTimeout(fn, ms) as unknown as number);
213
+ const clearTimer = props.clearTimer ?? ((handle) => clearTimeout(handle));
214
+ let armed: number | null = null;
215
+ const disarm = () => {
216
+ if (armed !== null) clearTimer(armed);
217
+ armed = null;
218
+ button.textContent = props.label;
219
+ };
220
+ const button = h("button", {
221
+ type: "button",
222
+ className: buttonClass(props.variant),
223
+ textContent: props.label,
224
+ onClick: () => {
225
+ if (armed !== null) {
226
+ disarm();
227
+ props.onConfirm();
228
+ return;
229
+ }
230
+ button.textContent = props.confirmLabel;
231
+ armed = setTimer(disarm, props.windowMs ?? 4000);
232
+ },
233
+ });
234
+ return button;
235
+ }
236
+
237
+ // ---------- dialog ----------
238
+
239
+ export interface DialogHandle {
240
+ body: HTMLElement;
241
+ close: () => void;
242
+ }
243
+
244
+ // openDialog mounts a scrim + dialog (with a title and circular close),
245
+ // appends it to document.body, and returns the body plus a close(). The
246
+ // backdrop, close button, and programmatic close() all run one teardown,
247
+ // so onClose fires exactly once on any dismissal path (consumers use it
248
+ // to drop their own bookkeeping, e.g. a dispose registry).
249
+ export function openDialog(props: { title: string; children: Child; onClose?: () => void }): DialogHandle {
250
+ const body = h("div", { className: DIALOG_BODY_CLASS }, props.children);
251
+ let closed = false;
252
+ const close = () => {
253
+ if (closed) return;
254
+ closed = true;
255
+ scrim.remove();
256
+ props.onClose?.();
257
+ };
258
+ const header = h(
259
+ "div",
260
+ { className: DIALOG_HEADER_CLASS },
261
+ h("h2", { textContent: props.title }),
262
+ IconButton({ glyph: "×", ariaLabel: "Close", onClick: close }),
263
+ );
264
+ const dialog = h("div", { className: DIALOG_CLASS }, header, body);
265
+ const scrim = h(
266
+ "div",
267
+ {
268
+ className: SCRIM_CLASS,
269
+ onClick: (e) => {
270
+ if (e.target === scrim) close();
271
+ },
272
+ },
273
+ dialog,
274
+ );
275
+ document.body.append(scrim);
276
+ return { body, close };
277
+ }