@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,307 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ // v3 registers mount without source transforms: instead of patching xpui to
7
+ // call __renderX(), a registry renders into a DOM anchor. The exposed React
8
+ // forwards to the client's own instance, so client components, stdlib
9
+ // chrome, and module components all share one hook dispatcher.
10
+
11
+ import { React, ReactDOM } from "../expose/React.ts";
12
+ import { warn } from "../logger.ts";
13
+ import { byOrder } from "./order.ts";
14
+ import type { Registry } from "./registry.ts";
15
+
16
+ export interface AnchorSpec {
17
+ className: string;
18
+ registry: Registry<any>;
19
+ setRefresh: (cb: (() => void) | undefined) => void;
20
+ // Position for the anchor; null means the client has not rendered the
21
+ // slot yet and the mount should wait for it.
22
+ findSlot: () => { parent: Element; before?: Node | null } | null;
23
+ // Custom rendering over the registry items (e.g. route matching);
24
+ // defaults to rendering every item.
25
+ renderItems?: (items: any[]) => unknown;
26
+ // The anchor host's display. Defaults to "contents" so the host is
27
+ // layout-transparent and its items participate directly in the client
28
+ // slot's layout. Set to a box value (e.g. "flex") when the items need
29
+ // their own layout context instead of inheriting the slot's — so the
30
+ // anchor counts as a single item in the slot and spaces its own children.
31
+ hostDisplay?: string;
32
+ }
33
+
34
+ // One broken registered node must not take down the others — and a crash
35
+ // must be visible, never a silent blank (a session's worth of “Spotify looks
36
+ // broken” reports traced back to exactly that). The fallback uses host
37
+ // elements only: when the crash IS a broken component surface, any component
38
+ // rendered here would die the same way. Route-overlay items get a full card;
39
+ // bar and menu items get a compact marker that keeps their layout.
40
+ export const createItemBoundary = (R: any, label: string) => {
41
+ const isPage = label.includes("route");
42
+ const describe = (error: unknown) => {
43
+ // Never throws: this runs inside getDerivedStateFromError, and a throw
44
+ // here unmounts the whole anchor root — the exact blank this boundary
45
+ // exists to prevent. Exotic errors (null-prototype throws, throwing
46
+ // toString/getters) get the fixed string instead.
47
+ try {
48
+ const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
49
+ return text.length > 300 ? `${text.slice(0, 300)}…` : text;
50
+ } catch {
51
+ return "(unprintable error)";
52
+ }
53
+ };
54
+ return class ItemBoundary extends R.Component {
55
+ state = { failed: false, message: "" };
56
+ static getDerivedStateFromError(error: unknown) {
57
+ return { failed: true, message: describe(error) };
58
+ }
59
+ componentDidCatch(error: unknown) {
60
+ console.error(`[stdlib] registered ${label} item crashed:`, error);
61
+ }
62
+ render() {
63
+ const { failed, message } = this.state as { failed: boolean; message: string };
64
+ if (!failed) return (this.props as { children?: unknown }).children;
65
+ if (!isPage) {
66
+ return R.createElement(
67
+ "span",
68
+ {
69
+ title: `A Spicetify module item crashed here: ${message}`,
70
+ style: { opacity: 0.6, padding: "0 4px", cursor: "help" },
71
+ },
72
+ "⚠",
73
+ );
74
+ }
75
+ return R.createElement(
76
+ "div",
77
+ {
78
+ style: {
79
+ maxWidth: "560px",
80
+ margin: "48px auto",
81
+ padding: "24px",
82
+ borderRadius: "8px",
83
+ background: "var(--spice-card, #282828)",
84
+ color: "var(--spice-text, #fff)",
85
+ fontSize: "14px",
86
+ lineHeight: 1.5,
87
+ },
88
+ },
89
+ R.createElement("h2", { style: { margin: "0 0 8px", fontSize: "18px" } }, "This module page crashed"),
90
+ R.createElement(
91
+ "p",
92
+ { style: { margin: "0 0 12px", opacity: 0.8 } },
93
+ "The rest of the client keeps working. Reloading usually recovers it; if it keeps crashing, disable the module from the Spicetify Manager.",
94
+ ),
95
+ R.createElement(
96
+ "code",
97
+ {
98
+ style: {
99
+ display: "block",
100
+ padding: "8px",
101
+ background: "rgba(0,0,0,.3)",
102
+ borderRadius: "4px",
103
+ marginBottom: "16px",
104
+ userSelect: "text",
105
+ },
106
+ },
107
+ message,
108
+ ),
109
+ R.createElement(
110
+ "button",
111
+ {
112
+ onClick: () => location.reload(),
113
+ style: {
114
+ padding: "8px 16px",
115
+ borderRadius: "500px",
116
+ border: "none",
117
+ cursor: "pointer",
118
+ background: "var(--spice-button, #1ed760)",
119
+ color: "var(--spice-main, #121212)",
120
+ fontWeight: 700,
121
+ },
122
+ },
123
+ "Reload Spotify",
124
+ ),
125
+ );
126
+ }
127
+ };
128
+ };
129
+
130
+ // The top bar doubles as the window drag region; like the client's own
131
+ // buttons, anchor children must opt out or physical clicks drag the window
132
+ // instead of reaching them (synthetic and CDP clicks bypass this, so only
133
+ // real mice notice).
134
+ const ensureAnchorStyle = () => {
135
+ if (document.getElementById("spicetify-anchor-style")) return;
136
+ const style = document.createElement("style");
137
+ style.id = "spicetify-anchor-style";
138
+ style.textContent = "[data-spicetify-anchor] > * { -webkit-app-region: no-drag; app-region: no-drag; }";
139
+ document.head.appendChild(style);
140
+ };
141
+
142
+ // One shared observer serves every anchor: the client mutates the DOM
143
+ // constantly, and per-anchor subtree observers multiply that cost. Each
144
+ // tick is one isConnected check per pending or placed anchor.
145
+ type WatchedAnchor = { host: HTMLElement; place: () => boolean; started: boolean; start: () => void };
146
+ const watched: WatchedAnchor[] = [];
147
+ let bodyObserver: MutationObserver | undefined;
148
+ const watchAnchor = (anchor: WatchedAnchor) => {
149
+ watched.push(anchor);
150
+ bodyObserver ??= (() => {
151
+ const observer = new MutationObserver(() => {
152
+ for (const a of watched) {
153
+ if (a.host.isConnected) continue;
154
+ if (a.place() && !a.started) {
155
+ a.started = true;
156
+ a.start();
157
+ }
158
+ }
159
+ });
160
+ observer.observe(document.body, { childList: true, subtree: true });
161
+ return observer;
162
+ })();
163
+ };
164
+
165
+ export function mountRegistryAnchor(spec: AnchorSpec): void {
166
+ // Transform experiments render through the injected __renderX() calls;
167
+ // an anchor would double-render.
168
+ if ((globalThis as never as Record<string, unknown>).__SPICETIFY_APPLY_TRANSFORMS__) return;
169
+ if (typeof document === "undefined") return;
170
+
171
+ void CHUNKS.xpui.promise.then(() => {
172
+ const R = React as any;
173
+ const createRoot = (ReactDOM as any).createRoot;
174
+ if (typeof createRoot !== "function") {
175
+ warn("[stdlib] cannot mount register anchor (no createRoot):", spec.className);
176
+ return;
177
+ }
178
+
179
+ const host = document.createElement("span");
180
+ host.className = spec.className;
181
+ host.style.display = spec.hostDisplay ?? "contents";
182
+ host.dataset.spicetifyAnchor = "";
183
+ ensureAnchorStyle();
184
+
185
+ const place = () => {
186
+ const slot = spec.findSlot();
187
+ if (!slot) return false;
188
+ slot.parent.insertBefore(host, slot.before ?? null);
189
+ return true;
190
+ };
191
+
192
+ const start = () => {
193
+ const root = createRoot(host);
194
+ const ItemBoundary = createItemBoundary(R, spec.className);
195
+ const Wrapper = () => {
196
+ const [, refresh] = R.useReducer((n: number) => n + 1, 0);
197
+ R.useEffect(() => {
198
+ spec.setRefresh(refresh);
199
+ return () => spec.setRefresh(undefined);
200
+ }, []);
201
+ if (spec.renderItems) {
202
+ return R.createElement(ItemBoundary, null, spec.renderItems(spec.registry.all()));
203
+ }
204
+ return R.createElement(
205
+ R.Fragment,
206
+ null,
207
+ ...byOrder(spec.registry.all()).map((item: unknown, i: number) =>
208
+ R.createElement(ItemBoundary, { key: i }, item),
209
+ ),
210
+ );
211
+ };
212
+ root.render(R.createElement(Wrapper));
213
+ };
214
+
215
+ const anchor: WatchedAnchor = { host, place, started: false, start };
216
+ if (place()) {
217
+ anchor.started = true;
218
+ start();
219
+ }
220
+ // The shared observer both waits for a not-yet-rendered slot and
221
+ // re-places the host whenever client reconciliation drops it; the
222
+ // root stays attached to the node, so rendering survives.
223
+ watchAnchor(anchor);
224
+ });
225
+ }
226
+
227
+ // Mount a single element next to a specific client element (rather than into a
228
+ // fixed slot). Used by placeButton's `near` anchoring: the host is inserted
229
+ // before/after the resolved target and re-placed on client re-renders. If the
230
+ // target never appears within giveUpMs, onGiveUp() runs so the caller can fall
231
+ // back to ordinary placement — the button is never silently lost.
232
+ export interface AdjacentSpec {
233
+ className: string;
234
+ element: React.ReactNode;
235
+ findTarget: () => Element | null;
236
+ side: "before" | "after";
237
+ giveUpMs: number;
238
+ onGiveUp: () => void;
239
+ }
240
+
241
+ export function mountAdjacent(spec: AdjacentSpec): { remove: () => void } {
242
+ let removed = false;
243
+ let root: { render?: (node: unknown) => void; unmount?: () => void } | undefined;
244
+ const host = document.createElement("span");
245
+ host.className = spec.className;
246
+ host.style.display = "contents";
247
+ host.dataset.spicetifyAnchor = "";
248
+
249
+ const place = (): boolean => {
250
+ const target = spec.findTarget();
251
+ if (!target?.parentElement) return false;
252
+ const before = spec.side === "before" ? target : target.nextSibling;
253
+ target.parentElement.insertBefore(host, before);
254
+ return true;
255
+ };
256
+
257
+ const remove = () => {
258
+ removed = true;
259
+ root?.unmount?.();
260
+ host.remove();
261
+ };
262
+
263
+ // Transform experiments render through injected __renderX() calls, and there
264
+ // is no DOM off the client; in both cases fall back to ordinary placement.
265
+ if (
266
+ (globalThis as never as Record<string, unknown>).__SPICETIFY_APPLY_TRANSFORMS__ ||
267
+ typeof document === "undefined"
268
+ ) {
269
+ spec.onGiveUp();
270
+ return { remove: () => {} };
271
+ }
272
+
273
+ void CHUNKS.xpui.promise.then(() => {
274
+ if (removed) return;
275
+ const R = React as any;
276
+ const createRoot = (ReactDOM as any).createRoot;
277
+ if (typeof createRoot !== "function") {
278
+ spec.onGiveUp();
279
+ return;
280
+ }
281
+ const start = () => {
282
+ ensureAnchorStyle();
283
+ root = createRoot(host);
284
+ const ItemBoundary = createItemBoundary(R, spec.className);
285
+ root!.render?.(R.createElement(ItemBoundary, null, spec.element));
286
+ };
287
+
288
+ const deadline = Date.now() + spec.giveUpMs;
289
+ const attempt = () => {
290
+ if (removed) return;
291
+ if (place()) {
292
+ start();
293
+ // Reuse the shared observer for re-placement only (already started).
294
+ watchAnchor({ host, place, started: true, start: () => {} });
295
+ return;
296
+ }
297
+ if (Date.now() > deadline) {
298
+ spec.onGiveUp();
299
+ return;
300
+ }
301
+ setTimeout(attempt, 150);
302
+ };
303
+ attempt();
304
+ });
305
+
306
+ return { remove };
307
+ }
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ // Semantic names for the client's own buttons, owned and maintained here so
7
+ // modules never hard-code fragile selectors. A module anchors to a stable
8
+ // name (e.g. "playbar:queue"); stdlib maps it to the current client's
9
+ // selectors. When Spotify moves a button, only this map changes — modules keep
10
+ // working, and placeButton falls back to order-placement if a name can't be
11
+ // resolved (so a stale map degrades, it never hides the button).
12
+ //
13
+ // Only locale-independent selectors are offered. The playbar controls carry
14
+ // stable data-testid attributes; the top-bar actions only expose localized
15
+ // aria-labels, so no top-bar anchors are published here.
16
+
17
+ export type NativeAnchor =
18
+ | "playbar:lyrics"
19
+ | "playbar:queue"
20
+ | "playbar:mute"
21
+ | "playbar:miniplayer"
22
+ | "playbar:fullscreen";
23
+
24
+ // Candidate selectors per anchor, newest first; the first match in the DOM
25
+ // wins. Add older-client fallbacks to the array rather than branching.
26
+ export const NATIVE_ANCHOR_SELECTORS: Record<NativeAnchor, string[]> = {
27
+ "playbar:lyrics": ['[data-testid="lyrics-button"]'],
28
+ "playbar:queue": ['[data-testid="control-button-queue"]'],
29
+ "playbar:mute": ['[data-testid="volume-bar-toggle-mute-button"]'],
30
+ "playbar:miniplayer": ['[data-testid="pip-toggle-button"]'],
31
+ "playbar:fullscreen": ['[data-testid="fullscreen-mode-button"]'],
32
+ };
33
+
34
+ export const isNativeAnchor = (name: string): name is NativeAnchor => Object.hasOwn(NATIVE_ANCHOR_SELECTORS, name);
35
+
36
+ // Resolve an anchor name to the live client element, or null if none of its
37
+ // selectors match (unknown name, or the client no longer renders it).
38
+ export const resolveNativeAnchor = (
39
+ name: NativeAnchor,
40
+ root: Pick<Document, "querySelector"> = document,
41
+ ): Element | null => {
42
+ const selectors = NATIVE_ANCHOR_SELECTORS[name];
43
+ if (!selectors) return null;
44
+ for (const selector of selectors) {
45
+ const el = root.querySelector(selector);
46
+ if (el) return el;
47
+ }
48
+ return null;
49
+ };
@@ -0,0 +1,165 @@
1
+ /*
2
+ * Copyright (C) 2024 Delusoire
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ import { React } from "../expose/React.ts";
7
+ import { createIconComponent } from "../createIconComponent.tsx";
8
+ import { transformer } from "../../mixin.ts";
9
+ import { Platform } from "../expose/Platform.ts";
10
+ import { warn } from "../logger.ts";
11
+ import { classnames } from "../webpack/ClassNames.ts";
12
+ import { ScrollableContainer, Tooltip } from "../webpack/ReactComponents.ts";
13
+ import { UI } from "../webpack/ComponentLibrary.ts";
14
+ import { mountRegistryAnchor } from "./mount.ts";
15
+ import { Registry } from "./registry.ts";
16
+
17
+ const registry = new (class extends Registry<React.ReactNode> {
18
+ override add(value: React.ReactNode): this {
19
+ refresh?.();
20
+ return super.add(value);
21
+ }
22
+
23
+ override delete(value: React.ReactNode): boolean {
24
+ refresh?.();
25
+ return super.delete(value);
26
+ }
27
+ })();
28
+ export default registry;
29
+
30
+ let refresh: React.DispatchWithoutAction | undefined;
31
+
32
+ declare global {
33
+ var __renderNavLinks: () => React.ReactNode;
34
+ }
35
+
36
+ globalThis.__renderNavLinks = () =>
37
+ React.createElement(() => {
38
+ [, refresh] = React.useReducer((n) => n + 1, 0);
39
+
40
+ if (!ScrollableContainer) {
41
+ return;
42
+ }
43
+
44
+ return (
45
+ <ScrollableContainer className="custom-navlinks-scrollable_container" onlyHorizontalWheel>
46
+ {registry.all()}
47
+ </ScrollableContainer>
48
+ );
49
+ });
50
+ transformer(
51
+ (emit) => (str) => {
52
+ emit();
53
+
54
+ str = str.replace(
55
+ /{(?=[^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*(?<=[,{])"data-testid":"global-nav-bar")([^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*(?<=[,{]))children:([^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*)(,[^{}]*(?:{[^{}]*(?:{[^{}]*(?:{[^{}]*}[^{}]*)*}[^{}]*)*}[^{}]*)*|)}/,
56
+ "{$1children:(function(children){const p=children[0].props;p.children=[p.children,__renderNavLinks()].flat();return children})($2)$3}",
57
+ );
58
+
59
+ return str;
60
+ },
61
+ {
62
+ glob: /^\/xpui\.js/,
63
+ },
64
+ );
65
+ transformer(
66
+ (emit) => (str) => {
67
+ emit();
68
+
69
+ str = str.replace('["","/","/home/",', '["","/","/home/","/bespoke/*",');
70
+
71
+ return str;
72
+ },
73
+ {
74
+ glob: /^\/dwp\-top\-bar\.js/,
75
+ },
76
+ );
77
+
78
+ mountRegistryAnchor({
79
+ className: "spicetify-navlinks-anchor",
80
+ registry,
81
+ setRefresh: (cb) => {
82
+ refresh = cb;
83
+ },
84
+ findSlot: () => {
85
+ const wrapper = document.querySelector(".main-globalNav-historyButtonsWrapper");
86
+ return wrapper ? { parent: wrapper } : null;
87
+ },
88
+ // A real flex box, not the default display:contents. The history-buttons
89
+ // wrapper spaces its children with a wide gap meant to separate button
90
+ // groups; as one box the anchor takes that gap once (separating our links
91
+ // from the back/forward group), while the nav links inside space
92
+ // themselves with the client's own 8px navlink margins.
93
+ hostDisplay: "flex",
94
+ });
95
+
96
+ // Registered elements are frozen in the registry, so parent refreshes
97
+ // bail out on identical children; any registered component that renders
98
+ // route-dependent state must subscribe to history itself.
99
+ export const useHistoryRefresh = (): void => {
100
+ const [, force] = React.useReducer((n: number) => n + 1, 0);
101
+ React.useEffect(() => {
102
+ try {
103
+ return Platform.getHistory().listen(() => force()) as (() => void) | undefined;
104
+ } catch (e) {
105
+ warn("[stdlib] cannot follow history:", e);
106
+ return undefined;
107
+ }
108
+ }, []);
109
+ };
110
+
111
+ export type NavLinkProps = {
112
+ localizedApp: string;
113
+ appRoutePath: string;
114
+ /** Inner SVG markup drawn on the stdlib 16-grid (upscaled to the client's 24px nav glyph). */
115
+ icon: string;
116
+ /** Filled variant shown while the route is active; same 16-grid contract. */
117
+ activeIcon: string;
118
+ };
119
+ export const NavLink: React.FC<NavLinkProps> = (props) => {
120
+ useHistoryRefresh();
121
+ const isActive = Platform.getHistory().location.pathname?.startsWith(props.appRoutePath);
122
+ const createIcon = () =>
123
+ // Icons are authored on the stdlib 16-grid; upscale to the client's
124
+ // 24px nav glyph size so they fill the circle like Home's.
125
+ createIconComponent({ icon: isActive ? props.activeIcon : props.icon, iconSize: 16, realIconSize: 24 });
126
+
127
+ return (
128
+ <_NavLinkGlobal
129
+ localizedApp={props.localizedApp}
130
+ appRoutePath={props.appRoutePath}
131
+ createIcon={createIcon}
132
+ isActive={isActive}
133
+ />
134
+ );
135
+ };
136
+
137
+ interface NavLinkFactoryProps {
138
+ localizedApp: string;
139
+ appRoutePath: string;
140
+ createIcon: () => React.ReactNode;
141
+ isActive: boolean;
142
+ }
143
+
144
+ const _NavLinkGlobal: React.FC<NavLinkFactoryProps> = (props) => {
145
+ return (
146
+ <div className="inline-flex">
147
+ <Tooltip label={props.localizedApp}>
148
+ <UI.ButtonTertiary
149
+ iconOnly={props.createIcon}
150
+ className={classnames(
151
+ // The circular chrome the client paints on its own nav links
152
+ // (Home); without it the button renders bare on the bar.
153
+ "main-globalNav-navLink",
154
+ "main-globalNav-link-icon link-subtle",
155
+ {
156
+ "main-globalNav-navLinkActive": props.isActive,
157
+ },
158
+ )}
159
+ aria-label={props.localizedApp}
160
+ onClick={() => Platform.getHistory().push(props.appRoutePath)}
161
+ />
162
+ </Tooltip>
163
+ </div>
164
+ );
165
+ };
@@ -0,0 +1,22 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ // Pure ordering used by the register mount render. Kept free of any client
7
+ // (React/DOM) imports so it can be unit-tested in Node.
8
+
9
+ interface Orderable {
10
+ props?: { order?: number };
11
+ }
12
+
13
+ // Stable ascending sort by an optional numeric `order` prop carried on a
14
+ // registered element (lower renders earlier). Items without one are treated
15
+ // as order 0 and keep their registration order; equal orders fall back to it
16
+ // too. This backs registrar.placeButton(..., { order }); plain register()
17
+ // items are all order-0 and so keep insertion order, unchanged.
18
+ export const byOrder = <T extends Orderable>(items: T[]): T[] =>
19
+ items
20
+ .map((item, i) => [item, i] as const)
21
+ .sort(([a, ai], [b, bi]) => (a?.props?.order ?? 0) - (b?.props?.order ?? 0) || ai - bi)
22
+ .map(([item]) => item);