@tribe-nest/forge 3.24.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.24.0",
3
+ "version": "3.25.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -39,6 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/react": "^19.1.2",
42
+ "simple-icons": "^16.28.0",
42
43
  "typescript": "~5.8.3"
43
44
  },
44
45
  "license": "ISC"
@@ -1893,6 +1893,11 @@ export interface IMusicLinkDestination {
1893
1893
 
1894
1894
  /** Per-link presentation, layered over the site theme by `MusicLinkPage`. */
1895
1895
  export interface IMusicLinkTheme {
1896
+ /**
1897
+ * Which of the five layouts to render. Composition, not colour: the tokens
1898
+ * below tune whichever one is chosen.
1899
+ */
1900
+ style?: "classic" | "immersive" | "split" | "minimal" | "compact";
1896
1901
  mode?: "light" | "dark";
1897
1902
  background?: string;
1898
1903
  text?: string;
@@ -1911,6 +1916,8 @@ export interface IMusicLink {
1911
1916
  artistName: string | null;
1912
1917
  description: string | null;
1913
1918
  artworkUrl: string | null;
1919
+ /** 30-second preview, played over the artwork. Apple's, which does not expire. */
1920
+ previewUrl: string | null;
1914
1921
  releaseDate: string | null;
1915
1922
  theme: IMusicLinkTheme | null;
1916
1923
  destinations: IMusicLinkDestination[];
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { CSSProperties } from "react";
3
3
  import type { IMusicLink } from "../../types/models";
4
4
  import { useMusicLink } from "../../data/queries/useMusicLink";
@@ -7,6 +7,8 @@ import { usePageMetaPixel } from "../analytics/PageMetaPixel";
7
7
  import { PageMetaPixel } from "../analytics/PageMetaPixel";
8
8
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
9
  import { PageActions } from "./PageActions";
10
+ import { DEFAULT_MUSIC_LINK_STYLE, MusicLinkLayout, type LayoutParts, type MusicLinkStyle } from "./musicLinkStyles";
11
+ import { useCookieConsent } from "../headless/consent/useCookieConsent";
10
12
 
11
13
  /**
12
14
  * How each streaming service is presented.
@@ -14,13 +16,15 @@ import { PageActions } from "./PageActions";
14
16
  * `color` is the service's own brand colour, used for the badge, and `label` is
15
17
  * the name a fan recognises rather than the API key.
16
18
  *
17
- * There is deliberately no logo artwork here. Reproducing a dozen trademarked
18
- * marks from memory gets some of them subtly wrong, and every one of these
19
- * services publishes brand guidelines with rules about colour, clear space and
20
- * permitted alterations. A wrong Spotify logo on an artist's own domain is the
21
- * artist's problem, not ours to create. Drop real SVGs in behind `icon` when
22
- * the assets have been taken from each service's brand kit; the layout already
23
- * reserves the space.
19
+ * Marks and brand hex come from `musicServiceIcons.ts`, generated from
20
+ * simple-icons rather than drawn by hand: reproducing a dozen trademarked logos
21
+ * from memory gets several subtly wrong, and a wrong Spotify mark on an artist's
22
+ * own domain is the artist's problem. Four services (Amazon Music, Boomplay,
23
+ * Anghami, Audius) have no entry there and keep the initial badge; adding them
24
+ * means finding an officially published SVG, not guessing one.
25
+ *
26
+ * `color` below is only the fallback for those four. Anything with a real icon
27
+ * uses the official hex from the generated file.
24
28
  */
25
29
  const SERVICES: Record<string, { label: string; color: string; action?: string }> = {
26
30
  spotify: { label: "Spotify", color: "#1DB954", action: "Play" },
@@ -42,6 +46,20 @@ const SERVICES: Record<string, { label: string; color: string; action?: string }
42
46
  tiktok: { label: "TikTok", color: "#000000", action: "Open" },
43
47
  };
44
48
 
49
+ /**
50
+ * Below `CookieConsent`, which is fixed at 70.
51
+ *
52
+ * This page covers the viewport, so anything the shell renders underneath is
53
+ * hidden. The consent banner must NOT be: this page fires a Meta Pixel, so
54
+ * hiding the control that governs it would mean running marketing tracking
55
+ * with the consent UI painted over. The gap is deliberate, and a spec asserts
56
+ * the ordering so a future bump here cannot quietly close it.
57
+ */
58
+ const OVERLAY_Z_INDEX = 60;
59
+
60
+ /** TribeNest's hosted policy, matching what `CookieConsent` falls back to. */
61
+ const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
62
+
45
63
  const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
46
64
 
47
65
  export interface MusicLinkPageProps {
@@ -51,6 +69,13 @@ export interface MusicLinkPageProps {
51
69
  initialLink?: IMusicLink | null;
52
70
  /** Heading above the service list. */
53
71
  listHeading?: string;
72
+ /** The site's own privacy policy. Falls back to TribeNest's hosted one. */
73
+ privacyHref?: string;
74
+ /** The "Powered by TribeNest" credit. */
75
+ showPoweredBy?: boolean;
76
+ /** Accessible names for the preview control. */
77
+ playLabel?: string;
78
+ pauseLabel?: string;
54
79
  /** Shown when the slug resolves to nothing. */
55
80
  notFoundMessage?: string;
56
81
  className?: string;
@@ -86,6 +111,10 @@ export function MusicLinkPage({
86
111
  slug,
87
112
  initialLink,
88
113
  listHeading = "Listen on",
114
+ privacyHref,
115
+ showPoweredBy = true,
116
+ playLabel = "Play a preview",
117
+ pauseLabel = "Pause the preview",
89
118
  notFoundMessage = "This link is no longer available.",
90
119
  className,
91
120
  style,
@@ -127,9 +156,14 @@ export function MusicLinkPage({
127
156
  };
128
157
  }, [siteTokens, link?.theme]);
129
158
 
159
+ // Falls back rather than throwing on an unknown value: a style saved by a
160
+ // newer admin than the site's Forge must render as something, and Classic is
161
+ // the safe something.
162
+ const activeStyle: MusicLinkStyle = (link?.theme?.style as MusicLinkStyle) ?? DEFAULT_MUSIC_LINK_STYLE;
163
+
130
164
  const pixelId = link?.metaPixelId ?? "";
131
165
  const { fire } = usePageMetaPixel(pixelId);
132
- const { trackBeacon } = useTrackEvent();
166
+ const { track, trackBeacon } = useTrackEvent();
133
167
 
134
168
  // Lock the page behind the overlay. Without this the tenant's own page keeps
135
169
  // scrolling underneath on iOS, which reads as a rendering bug.
@@ -142,6 +176,41 @@ export function MusicLinkPage({
142
176
  };
143
177
  }, []);
144
178
 
179
+ const audioRef = useRef<HTMLAudioElement | null>(null);
180
+ const [playing, setPlaying] = useState(false);
181
+
182
+ // Stop the preview when the page goes away. Without this a fan who taps play
183
+ // and then taps Spotify leaves audio running underneath the store they just
184
+ // opened, which on mobile is both confusing and hard to stop.
185
+ useEffect(() => {
186
+ return () => {
187
+ audioRef.current?.pause();
188
+ };
189
+ }, []);
190
+
191
+ const togglePreview = () => {
192
+ const audio = audioRef.current;
193
+ if (!audio) return;
194
+ if (audio.paused) {
195
+ // A tap IS the user gesture mobile autoplay policy requires, so this is
196
+ // allowed where an autoplaying preview would be blocked. `catch` because
197
+ // it still rejects on some in-app browsers and a rejected promise here
198
+ // must not surface as an error to a fan.
199
+ void audio
200
+ .play()
201
+ .then(() => setPlaying(true))
202
+ .catch(() => setPlaying(false));
203
+ } else {
204
+ audio.pause();
205
+ setPlaying(false);
206
+ }
207
+ if (!audio.paused)
208
+ track("preview_play", {
209
+ pathname: typeof window !== "undefined" ? window.location.pathname : undefined,
210
+ slug: link?.slug,
211
+ });
212
+ };
213
+
145
214
  const destinations = useMemo(() => link?.destinations ?? [], [link]);
146
215
 
147
216
  const onDestinationClick = (platform: string, url: string) => {
@@ -170,146 +239,201 @@ export function MusicLinkPage({
170
239
  );
171
240
  }
172
241
 
242
+ const previewButton = (size: number) =>
243
+ link.previewUrl ? (
244
+ <button
245
+ type="button"
246
+ onClick={togglePreview}
247
+ aria-label={playing ? pauseLabel : playLabel}
248
+ data-track-skip=""
249
+ style={{
250
+ width: size,
251
+ height: size,
252
+ borderRadius: 999,
253
+ border: "none",
254
+ cursor: "pointer",
255
+ background: "rgba(0,0,0,0.55)",
256
+ backdropFilter: "blur(4px)",
257
+ color: "#ffffff",
258
+ display: "flex",
259
+ alignItems: "center",
260
+ justifyContent: "center",
261
+ padding: 0,
262
+ flex: "none",
263
+ }}
264
+ >
265
+ <svg
266
+ viewBox="0 0 24 24"
267
+ width={Math.round(size * 0.44)}
268
+ height={Math.round(size * 0.44)}
269
+ fill="currentColor"
270
+ aria-hidden="true"
271
+ >
272
+ {playing ? (
273
+ <path d="M6 5h4v14H6zM14 5h4v14h-4z" />
274
+ ) : (
275
+ <path d="M8 5.14v13.72a.5.5 0 0 0 .76.43l11.14-6.86a.5.5 0 0 0 0-.86L8.76 4.71a.5.5 0 0 0-.76.43z" />
276
+ )}
277
+ </svg>
278
+ </button>
279
+ ) : null;
280
+
281
+ const artwork = (size: number) =>
282
+ link.artworkUrl ? (
283
+ <div style={{ position: "relative", lineHeight: 0 }}>
284
+ <img
285
+ src={link.artworkUrl}
286
+ alt={link.title}
287
+ width={size}
288
+ height={size}
289
+ style={{
290
+ width: size,
291
+ height: size,
292
+ maxWidth: "70vw",
293
+ maxHeight: "70vw",
294
+ objectFit: "cover",
295
+ borderRadius: t.cornerRadius,
296
+ boxShadow: "0 18px 50px rgba(0,0,0,0.45)",
297
+ }}
298
+ />
299
+
300
+ {/*
301
+ The preview, over the cover. Only when there IS one: a play button that
302
+ does nothing is worse than no play button, and plenty of releases
303
+ resolve without a preview. Scaled with the artwork so it stays
304
+ proportionate on the styles that shrink the cover.
305
+ */}
306
+ {size >= 110 ? (
307
+ <div
308
+ style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}
309
+ >
310
+ {previewButton(Math.round(size * 0.25))}
311
+ </div>
312
+ ) : null}
313
+ </div>
314
+ ) : null;
315
+
316
+ const parts: LayoutParts = {
317
+ t,
318
+ link: {
319
+ title: link.title,
320
+ artistName: link.artistName,
321
+ description: link.description,
322
+ artworkUrl: link.artworkUrl,
323
+ },
324
+ destinations,
325
+ metaFor: serviceMeta,
326
+ onDestinationClick,
327
+ artwork,
328
+ previewButton: previewButton(72),
329
+ listHeading,
330
+ pageActions: (
331
+ <div style={{ width: "100%", marginTop: 28 }}>
332
+ <PageActions pageType="music_link" entityId={link.id} />
333
+ </div>
334
+ ),
335
+ footer: <SmartLinkFooter t={t} privacyHref={privacyHref} showPoweredBy={showPoweredBy} />,
336
+ };
337
+
338
+ // The immersive style uses the cover AS the page, so its backdrop is opaque
339
+ // rather than the faint blur the others sit on.
340
+ const immersive = activeStyle === "immersive";
341
+
173
342
  return (
174
343
  <Overlay
175
344
  t={t}
176
345
  className={className}
177
346
  style={style}
178
347
  artworkUrl={link.theme?.artworkBackdrop === false ? null : link.artworkUrl}
348
+ backdropOpacity={immersive ? 0.62 : 0.35}
349
+ scrim={immersive}
179
350
  >
180
351
  {pixelId ? <PageMetaPixel pixelId={pixelId} /> : null}
181
352
 
182
- <div style={{ width: "100%", maxWidth: 420, display: "flex", flexDirection: "column", alignItems: "center" }}>
183
- {link.artworkUrl ? (
184
- <img
185
- src={link.artworkUrl}
186
- alt={link.title}
187
- width={260}
188
- height={260}
189
- style={{
190
- width: 260,
191
- height: 260,
192
- maxWidth: "70vw",
193
- maxHeight: "70vw",
194
- objectFit: "cover",
195
- borderRadius: t.cornerRadius,
196
- boxShadow: "0 18px 50px rgba(0,0,0,0.45)",
197
- }}
198
- />
199
- ) : null}
353
+ {link.previewUrl ? (
354
+ <audio
355
+ ref={audioRef}
356
+ src={link.previewUrl}
357
+ preload="none"
358
+ onEnded={() => setPlaying(false)}
359
+ onPause={() => setPlaying(false)}
360
+ />
361
+ ) : null}
200
362
 
201
- <h1
202
- style={{
203
- margin: "24px 0 4px",
204
- fontSize: 24,
205
- lineHeight: 1.25,
206
- textAlign: "center",
207
- color: t.text,
208
- fontFamily: t.headingFontFamily || t.fontFamily,
209
- }}
210
- >
211
- {link.title}
212
- </h1>
363
+ <MusicLinkLayout style={activeStyle} parts={parts} />
364
+ </Overlay>
365
+ );
366
+ }
213
367
 
214
- {link.artistName ? (
215
- <p style={{ margin: 0, fontSize: 16, color: t.muted, fontFamily: t.fontFamily }}>{link.artistName}</p>
216
- ) : null}
368
+ function SmartLinkFooter({
369
+ t,
370
+ privacyHref,
371
+ showPoweredBy,
372
+ }: {
373
+ t: ReturnType<typeof useThemeTokens>;
374
+ privacyHref?: string;
375
+ showPoweredBy: boolean;
376
+ }) {
377
+ const { reopen } = useCookieConsent();
378
+ const href = privacyHref ?? TRIBENEST_PRIVACY_URL;
379
+ const external = !privacyHref;
217
380
 
218
- {link.description ? (
219
- <p
220
- style={{
221
- margin: "12px 0 0",
222
- fontSize: 14,
223
- textAlign: "center",
224
- color: t.muted,
225
- fontFamily: t.fontFamily,
226
- }}
227
- >
228
- {link.description}
229
- </p>
230
- ) : null}
381
+ const linkStyle: CSSProperties = {
382
+ color: t.muted,
383
+ textDecoration: "none",
384
+ borderBottom: `1px solid ${t.border}`,
385
+ background: "none",
386
+ border: "none",
387
+ borderBottomWidth: 1,
388
+ borderBottomStyle: "solid",
389
+ borderBottomColor: t.border,
390
+ padding: 0,
391
+ font: "inherit",
392
+ cursor: "pointer",
393
+ };
231
394
 
232
- {destinations.length > 0 ? (
233
- <p
234
- style={{
235
- margin: "28px 0 12px",
236
- fontSize: 12,
237
- letterSpacing: "0.08em",
238
- textTransform: "uppercase",
239
- color: t.muted,
240
- fontFamily: t.fontFamily,
241
- }}
242
- >
243
- {listHeading}
244
- </p>
245
- ) : null}
395
+ return (
396
+ <footer
397
+ style={{
398
+ marginTop: 36,
399
+ display: "flex",
400
+ flexWrap: "wrap",
401
+ alignItems: "center",
402
+ justifyContent: "center",
403
+ gap: 14,
404
+ fontSize: 12,
405
+ color: t.muted,
406
+ fontFamily: t.fontFamily,
407
+ }}
408
+ >
409
+ <a
410
+ href={href}
411
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
412
+ data-track-skip=""
413
+ style={linkStyle}
414
+ >
415
+ Privacy
416
+ </a>
246
417
 
247
- <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 10 }}>
248
- {destinations.map((destination) => {
249
- const meta = serviceMeta(destination.platform);
250
- return (
251
- <a
252
- key={destination.platform}
253
- href={destination.url}
254
- target="_blank"
255
- rel="noopener noreferrer"
256
- // Stands the global click listener down. It would otherwise
257
- // record a second, poorer event for this same tap, and its
258
- // payload would drive a duplicate CAPI Lead.
259
- data-track-skip=""
260
- data-track={`music-link-${destination.platform}`}
261
- onClick={() => onDestinationClick(destination.platform, destination.url)}
262
- style={{
263
- display: "flex",
264
- alignItems: "center",
265
- gap: 12,
266
- padding: "12px 14px",
267
- borderRadius: t.cornerRadius,
268
- background: t.surface,
269
- border: `1px solid ${t.border}`,
270
- color: t.text,
271
- textDecoration: "none",
272
- fontFamily: t.fontFamily,
273
- }}
274
- >
275
- <span
276
- aria-hidden="true"
277
- style={{
278
- width: 34,
279
- height: 34,
280
- flex: "0 0 34px",
281
- borderRadius: 999,
282
- background: meta.color,
283
- color: "#ffffff",
284
- display: "flex",
285
- alignItems: "center",
286
- justifyContent: "center",
287
- fontSize: 15,
288
- fontWeight: 700,
289
- }}
290
- >
291
- {meta.label.charAt(0)}
292
- </span>
293
- <span style={{ flex: 1, fontSize: 15, fontWeight: 600 }}>{meta.label}</span>
294
- <span style={{ fontSize: 13, fontWeight: 700, color: t.primary }}>{meta.action}</span>
295
- </a>
296
- );
297
- })}
298
- </div>
418
+ {/* Reopening the banner is the only way a visitor can change their mind
419
+ once a choice is stored, and the shell puts this in the footer we are
420
+ covering. */}
421
+ <button type="button" onClick={reopen} data-track-skip="" style={linkStyle}>
422
+ Cookie settings
423
+ </button>
299
424
 
300
- {/*
301
- The creator's own additions: a newsletter form, a vinyl pre-order, a
302
- lead magnet, a donation. This route is a platform-owned chassis file
303
- that is re-materialized from the starter on every build, so anything
304
- added to it IN CODE would be overwritten on the next deploy. Page
305
- actions are data instead, resolved per link with a per-page-type
306
- default, so the shell stays ours and the contents stay theirs.
307
- */}
308
- <div style={{ width: "100%", marginTop: 28 }}>
309
- <PageActions pageType="music_link" entityId={link.id} />
310
- </div>
311
- </div>
312
- </Overlay>
425
+ {showPoweredBy ? (
426
+ <a
427
+ href="https://www.tribenest.co"
428
+ target="_blank"
429
+ rel="noopener noreferrer"
430
+ data-track-skip=""
431
+ style={{ ...linkStyle, borderBottom: "none" }}
432
+ >
433
+ Powered by TribeNest
434
+ </a>
435
+ ) : null}
436
+ </footer>
313
437
  );
314
438
  }
315
439
 
@@ -319,12 +443,18 @@ function Overlay({
319
443
  className,
320
444
  style,
321
445
  children,
446
+ backdropOpacity = 0.35,
447
+ scrim = false,
322
448
  }: {
323
449
  t: ReturnType<typeof useThemeTokens>;
324
450
  artworkUrl?: string | null;
325
451
  className?: string;
326
452
  style?: CSSProperties;
327
453
  children: React.ReactNode;
454
+ /** How strongly the cover shows through. Immersive leans on it; the rest hint. */
455
+ backdropOpacity?: number;
456
+ /** A dark gradient over the cover, so light type stays legible on any artwork. */
457
+ scrim?: boolean;
328
458
  }) {
329
459
  return (
330
460
  <div
@@ -332,7 +462,7 @@ function Overlay({
332
462
  style={{
333
463
  position: "fixed",
334
464
  inset: 0,
335
- zIndex: 60,
465
+ zIndex: OVERLAY_Z_INDEX,
336
466
  overflowY: "auto",
337
467
  background: t.background,
338
468
  display: "flex",
@@ -354,12 +484,35 @@ function Overlay({
354
484
  backgroundPosition: "center",
355
485
  filter: "blur(48px) saturate(1.4)",
356
486
  transform: "scale(1.2)",
357
- opacity: 0.35,
487
+ opacity: backdropOpacity,
488
+ pointerEvents: "none",
489
+ }}
490
+ />
491
+ ) : null}
492
+ {/* Artwork is uncontrollable input: a pale cover would leave white type on
493
+ white. The scrim guarantees a dark ground under the immersive style. */}
494
+ {artworkUrl && scrim ? (
495
+ <div
496
+ aria-hidden="true"
497
+ style={{
498
+ position: "absolute",
499
+ inset: 0,
500
+ background: "linear-gradient(180deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.78) 65%, rgba(0,0,0,0.9) 100%)",
358
501
  pointerEvents: "none",
359
502
  }}
360
503
  />
361
504
  ) : null}
362
- <div style={{ position: "relative", width: "100%", display: "flex", justifyContent: "center" }}>{children}</div>
505
+ <div
506
+ style={{
507
+ position: "relative",
508
+ width: "100%",
509
+ display: "flex",
510
+ justifyContent: "center",
511
+ minHeight: scrim ? "100%" : undefined,
512
+ }}
513
+ >
514
+ {children}
515
+ </div>
363
516
  </div>
364
517
  );
365
518
  }
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const read = (p: string) => readFileSync(join(__dirname, "..", p), "utf8");
6
+
7
+ /**
8
+ * `MusicLinkPage` covers the viewport, so everything the app shell renders
9
+ * underneath it is hidden. These assertions pin the two things that must NOT be.
10
+ *
11
+ * Source assertions rather than a render test on purpose: what is being checked
12
+ * is a relationship between two components' stacking, which a render of either
13
+ * one alone cannot see, and which a person editing one of them would have no
14
+ * reason to think about.
15
+ */
16
+ describe("music link page chrome", () => {
17
+ const page = read("MusicLinkPage.tsx");
18
+ const consent = read("CookieConsent.tsx");
19
+
20
+ const zIndexOf = (src: string, pattern: RegExp) => {
21
+ const match = pattern.exec(src);
22
+ if (!match) throw new Error("no z-index found");
23
+ return Number(match[1]);
24
+ };
25
+
26
+ it("stays below the cookie banner", () => {
27
+ // The page fires a Meta Pixel. Painting over the consent UI would mean
28
+ // running marketing tracking with its only control hidden, which is a
29
+ // compliance problem rather than a layout one.
30
+ const overlay = zIndexOf(page, /const OVERLAY_Z_INDEX = (\d+);/);
31
+ const banner = zIndexOf(consent, /zIndex:\s*(\d+)/);
32
+
33
+ expect(overlay).toBeLessThan(banner);
34
+ });
35
+
36
+ it("carries its own privacy and cookie-settings links", () => {
37
+ // The site footer that normally holds these is covered by the overlay, so
38
+ // the page has to supply them itself or a visitor has no way to read the
39
+ // policy or withdraw consent from the page that tracks them.
40
+ expect(page).toContain("Cookie settings");
41
+ expect(page).toContain("Privacy");
42
+ expect(page).toContain("useCookieConsent");
43
+ });
44
+
45
+ it("credits the platform", () => {
46
+ expect(page).toContain("Powered by TribeNest");
47
+ });
48
+ });
@@ -0,0 +1,475 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import type { IMusicLinkDestination } from "../../types/models";
3
+ import { MUSIC_SERVICE_ICONS } from "./musicServiceIcons";
4
+ import { readableBrandColor } from "../../utils/contrast";
5
+ import type { useThemeTokens } from "../theme/ForgeThemeProvider";
6
+
7
+ export type MusicLinkStyle = "classic" | "immersive" | "split" | "minimal" | "compact";
8
+
9
+ export const MUSIC_LINK_STYLES: MusicLinkStyle[] = ["classic", "immersive", "split", "minimal", "compact"];
10
+
11
+ export const DEFAULT_MUSIC_LINK_STYLE: MusicLinkStyle = "classic";
12
+
13
+ /**
14
+ * The five presentations a release page can take.
15
+ *
16
+ * These are LAYOUTS, not palettes. Colour is already tunable per link through
17
+ * the theme tokens, and swapping colours never stops two releases looking like
18
+ * the same page. What makes an artist's link feel like theirs is composition:
19
+ * where the cover sits, how big the title is, whether the services read as a
20
+ * list or as a row of marks.
21
+ *
22
+ * Kept to five deliberately. Every one has to be maintained against both themes,
23
+ * both contrast fallbacks, the preview button, page actions and the footer, so a
24
+ * sixth is a real cost rather than another entry in a config file.
25
+ */
26
+ export const MUSIC_LINK_STYLE_META: Record<MusicLinkStyle, { label: string; description: string }> = {
27
+ classic: {
28
+ label: "Classic",
29
+ description: "Centred cover, then one clearly labelled row per service. The safest choice and the default.",
30
+ },
31
+ immersive: {
32
+ label: "Immersive",
33
+ description: "The cover fills the screen behind frosted buttons. Best with strong, high-contrast artwork.",
34
+ },
35
+ split: {
36
+ label: "Split",
37
+ description: "Cover on one side, everything else on the other. Widescreen and editorial; stacks on a phone.",
38
+ },
39
+ minimal: {
40
+ label: "Minimal",
41
+ description: "The title is the artwork. A small thumbnail and services as plain marks, nothing else.",
42
+ },
43
+ compact: {
44
+ label: "Compact",
45
+ description: "A round cover over brand-coloured buttons. Reads fastest, and closest to a link-in-bio.",
46
+ },
47
+ };
48
+
49
+ export type ThemeTokens = ReturnType<typeof useThemeTokens>;
50
+
51
+ export type ServiceMeta = { label: string; color: string; action?: string };
52
+
53
+ export type LayoutParts = {
54
+ t: ThemeTokens;
55
+ link: {
56
+ title: string;
57
+ artistName: string | null;
58
+ description: string | null;
59
+ artworkUrl: string | null;
60
+ };
61
+ destinations: IMusicLinkDestination[];
62
+ metaFor: (platform: string) => ServiceMeta;
63
+ onDestinationClick: (platform: string, url: string) => void;
64
+ /** The cover plus its preview button, already assembled. */
65
+ artwork: (size: number) => ReactNode;
66
+ /**
67
+ * The preview control on its own, for layouts that do not show a cover to put
68
+ * it on. Immersive is the case: the artwork IS the page, so a second copy of
69
+ * it in the middle would be the thing that made it look like Classic with a
70
+ * wallpaper.
71
+ */
72
+ previewButton: ReactNode;
73
+ footer: ReactNode;
74
+ pageActions: ReactNode;
75
+ listHeading: string;
76
+ };
77
+
78
+ /* ────────────────────────────── shared pieces ────────────────────────────── */
79
+
80
+ /**
81
+ * A service's mark, filled with the brand hex only where that survives the
82
+ * surface it sits on. Tidal and TikTok are pure black and vanish on a dark
83
+ * ground, so an unconditional brand fill ships an invisible logo.
84
+ */
85
+ export function ServiceMark({
86
+ platform,
87
+ meta,
88
+ surface,
89
+ text,
90
+ size = 26,
91
+ }: {
92
+ platform: string;
93
+ meta: ServiceMeta;
94
+ surface: string;
95
+ text: string;
96
+ size?: number;
97
+ }) {
98
+ const icon = MUSIC_SERVICE_ICONS[platform];
99
+ const box: CSSProperties = {
100
+ width: size + 8,
101
+ height: size + 8,
102
+ flex: `0 0 ${size + 8}px`,
103
+ display: "flex",
104
+ alignItems: "center",
105
+ justifyContent: "center",
106
+ };
107
+
108
+ if (icon) {
109
+ return (
110
+ <span aria-hidden="true" style={box}>
111
+ <svg
112
+ viewBox="0 0 24 24"
113
+ width={size}
114
+ height={size}
115
+ fill={readableBrandColor(icon.hex, surface, text)}
116
+ role="presentation"
117
+ >
118
+ <path d={icon.path} />
119
+ </svg>
120
+ </span>
121
+ );
122
+ }
123
+
124
+ return (
125
+ <span
126
+ aria-hidden="true"
127
+ style={{
128
+ ...box,
129
+ borderRadius: 999,
130
+ background: meta.color,
131
+ color: "#ffffff",
132
+ fontSize: size * 0.58,
133
+ fontWeight: 700,
134
+ }}
135
+ >
136
+ {meta.label.charAt(0)}
137
+ </span>
138
+ );
139
+ }
140
+
141
+ const anchorProps = (platform: string, url: string, onClick: LayoutParts["onDestinationClick"]) => ({
142
+ href: url,
143
+ target: "_blank" as const,
144
+ rel: "noopener noreferrer",
145
+ // Stands the global click listener down: this element reports its own event
146
+ // with a shared id, and a second, poorer event would double-count the tap.
147
+ "data-track-skip": "",
148
+ "data-track": `music-link-${platform}`,
149
+ onClick: () => onClick(platform, url),
150
+ });
151
+
152
+ /** Mark, name, action word. The most legible arrangement, and the default. */
153
+ function ServiceRows({ parts, surface }: { parts: LayoutParts; surface: string }) {
154
+ const { t, destinations, metaFor, onDestinationClick } = parts;
155
+ return (
156
+ <div className="tn-ml-rows" style={{ width: "100%", display: "flex", flexDirection: "column", gap: 10 }}>
157
+ {destinations.map((d) => {
158
+ const meta = metaFor(d.platform);
159
+ return (
160
+ <a
161
+ key={d.platform}
162
+ {...anchorProps(d.platform, d.url, onDestinationClick)}
163
+ style={{
164
+ display: "flex",
165
+ alignItems: "center",
166
+ gap: 12,
167
+ padding: "12px 14px",
168
+ borderRadius: t.cornerRadius,
169
+ background: surface,
170
+ border: `1px solid ${t.border}`,
171
+ color: t.text,
172
+ textDecoration: "none",
173
+ fontFamily: t.fontFamily,
174
+ }}
175
+ >
176
+ <ServiceMark platform={d.platform} meta={meta} surface={surface} text={t.text} />
177
+ <span style={{ flex: 1, fontSize: 15, fontWeight: 600 }}>{meta.label}</span>
178
+ <span style={{ fontSize: 13, fontWeight: 700, color: t.primary }}>{meta.action}</span>
179
+ </a>
180
+ );
181
+ })}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ /** Icon-only marks in a wrapped row. No labels: the logos ARE the labels. */
187
+ function ServiceMarks({ parts }: { parts: LayoutParts }) {
188
+ const { t, destinations, metaFor, onDestinationClick } = parts;
189
+ return (
190
+ <div style={{ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: 14 }}>
191
+ {destinations.map((d) => {
192
+ const meta = metaFor(d.platform);
193
+ return (
194
+ <a
195
+ key={d.platform}
196
+ {...anchorProps(d.platform, d.url, onDestinationClick)}
197
+ aria-label={meta.label}
198
+ title={meta.label}
199
+ style={{
200
+ width: 54,
201
+ height: 54,
202
+ borderRadius: 999,
203
+ border: `1px solid ${t.border}`,
204
+ background: t.surface,
205
+ display: "flex",
206
+ alignItems: "center",
207
+ justifyContent: "center",
208
+ textDecoration: "none",
209
+ }}
210
+ >
211
+ <ServiceMark platform={d.platform} meta={meta} surface={t.surface} text={t.text} size={24} />
212
+ </a>
213
+ );
214
+ })}
215
+ </div>
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Full-width pills filled with the service's own colour.
221
+ *
222
+ * The label is white or near-black depending on the fill, so a pale brand
223
+ * (Audiomack's orange, Spotify's green) does not end up as white-on-yellow.
224
+ */
225
+ function ServicePills({ parts }: { parts: LayoutParts }) {
226
+ const { t, destinations, metaFor, onDestinationClick } = parts;
227
+ return (
228
+ <div style={{ width: "100%", display: "flex", flexDirection: "column", gap: 10 }}>
229
+ {destinations.map((d) => {
230
+ const meta = metaFor(d.platform);
231
+ const fill = MUSIC_SERVICE_ICONS[d.platform]?.hex ?? meta.color;
232
+ const label = readableBrandColor("#FFFFFF", fill, "#111113", 3);
233
+ return (
234
+ <a
235
+ key={d.platform}
236
+ {...anchorProps(d.platform, d.url, onDestinationClick)}
237
+ style={{
238
+ display: "flex",
239
+ alignItems: "center",
240
+ justifyContent: "center",
241
+ gap: 10,
242
+ padding: "14px 18px",
243
+ borderRadius: 999,
244
+ background: fill,
245
+ color: label,
246
+ textDecoration: "none",
247
+ fontFamily: t.fontFamily,
248
+ fontSize: 15,
249
+ fontWeight: 700,
250
+ }}
251
+ >
252
+ <ServiceMark platform={d.platform} meta={meta} surface={fill} text={label} size={20} />
253
+ {meta.label}
254
+ </a>
255
+ );
256
+ })}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ const column = (maxWidth: number): CSSProperties => ({
262
+ width: "100%",
263
+ maxWidth,
264
+ display: "flex",
265
+ flexDirection: "column",
266
+ alignItems: "center",
267
+ });
268
+
269
+ /* ──────────────────────────────── layouts ───────────────────────────────── */
270
+
271
+ function Heading({ parts, align, size }: { parts: LayoutParts; align: "center" | "left"; size: number }) {
272
+ const { t, link } = parts;
273
+ return (
274
+ <>
275
+ <h1
276
+ style={{
277
+ margin: "24px 0 4px",
278
+ fontSize: size,
279
+ lineHeight: 1.12,
280
+ letterSpacing: size > 34 ? "-0.03em" : "-0.01em",
281
+ textAlign: align,
282
+ color: t.text,
283
+ fontFamily: t.headingFontFamily || t.fontFamily,
284
+ textWrap: "balance",
285
+ }}
286
+ >
287
+ {link.title}
288
+ </h1>
289
+ {link.artistName ? (
290
+ <p style={{ margin: 0, fontSize: 16, color: t.muted, fontFamily: t.fontFamily, textAlign: align }}>
291
+ {link.artistName}
292
+ </p>
293
+ ) : null}
294
+ {link.description ? (
295
+ <p style={{ margin: "12px 0 0", fontSize: 14, textAlign: align, color: t.muted, fontFamily: t.fontFamily }}>
296
+ {link.description}
297
+ </p>
298
+ ) : null}
299
+ </>
300
+ );
301
+ }
302
+
303
+ function ListHeading({ parts }: { parts: LayoutParts }) {
304
+ const { t, listHeading, destinations } = parts;
305
+ if (!destinations.length) return null;
306
+ return (
307
+ <p
308
+ style={{
309
+ margin: "28px 0 12px",
310
+ fontSize: 12,
311
+ letterSpacing: "0.08em",
312
+ textTransform: "uppercase",
313
+ color: t.muted,
314
+ fontFamily: t.fontFamily,
315
+ }}
316
+ >
317
+ {listHeading}
318
+ </p>
319
+ );
320
+ }
321
+
322
+ function ClassicLayout({ parts }: { parts: LayoutParts }) {
323
+ return (
324
+ <div style={column(420)}>
325
+ {parts.artwork(260)}
326
+ <Heading parts={parts} align="center" size={24} />
327
+ <ListHeading parts={parts} />
328
+ <ServiceRows parts={parts} surface={parts.t.surface} />
329
+ {parts.pageActions}
330
+ {parts.footer}
331
+ </div>
332
+ );
333
+ }
334
+
335
+ /**
336
+ * The cover as the page. Buttons are frosted glass over it, and the type is
337
+ * always light, because the ground here is the artwork rather than the theme.
338
+ */
339
+ function ImmersiveLayout({ parts }: { parts: LayoutParts }) {
340
+ const glass = "rgba(255,255,255,0.14)";
341
+ const t = { ...parts.t, text: "#ffffff", muted: "rgba(255,255,255,0.72)", border: "rgba(255,255,255,0.22)" };
342
+ const scoped: LayoutParts = { ...parts, t };
343
+ return (
344
+ <div style={{ ...column(430), justifyContent: "flex-end", minHeight: "100%", paddingTop: 40 }}>
345
+ {/*
346
+ Deliberately NO cover here. The artwork is already the whole background,
347
+ and repeating it as a square in the middle is exactly what made this read
348
+ as Classic with a wallpaper rather than its own thing. What sits in that
349
+ space instead is the preview control, which has nothing to sit on
350
+ otherwise.
351
+ */}
352
+ {parts.previewButton}
353
+
354
+ <h1
355
+ style={{
356
+ margin: "26px 0 6px",
357
+ fontSize: 40,
358
+ lineHeight: 1.04,
359
+ letterSpacing: "-0.035em",
360
+ textAlign: "center",
361
+ color: "#fff",
362
+ fontFamily: t.headingFontFamily || t.fontFamily,
363
+ textWrap: "balance",
364
+ textShadow: "0 2px 24px rgba(0,0,0,0.5)",
365
+ }}
366
+ >
367
+ {parts.link.title}
368
+ </h1>
369
+ {parts.link.artistName ? (
370
+ <p
371
+ style={{
372
+ margin: 0,
373
+ fontSize: 13,
374
+ letterSpacing: "0.16em",
375
+ textTransform: "uppercase",
376
+ color: "rgba(255,255,255,0.75)",
377
+ fontFamily: t.fontFamily,
378
+ }}
379
+ >
380
+ {parts.link.artistName}
381
+ </p>
382
+ ) : null}
383
+
384
+ <div style={{ marginTop: 26, width: "100%" }}>
385
+ <ServiceRows parts={scoped} surface={glass} />
386
+ </div>
387
+ {parts.pageActions}
388
+ {parts.footer}
389
+ </div>
390
+ );
391
+ }
392
+
393
+ /*
394
+ * Split needs to be recognisable on a phone too.
395
+ *
396
+ * The first version only diverged above 860px, which meant that on the screen
397
+ * most fans actually use it WAS Classic. Left-aligned type and a two-column
398
+ * service grid give it an identity from 430px up, and the side-by-side
399
+ * arrangement is then the widescreen bonus rather than the whole idea.
400
+ */
401
+ const SPLIT_CSS = `
402
+ .tn-ml-split .tn-ml-split-text { text-align: left; align-items: flex-start; }
403
+ .tn-ml-split .tn-ml-split-text h1,
404
+ .tn-ml-split .tn-ml-split-text p { text-align: left; }
405
+ @media (min-width: 430px) {
406
+ .tn-ml-split .tn-ml-rows { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
407
+ }
408
+ @media (min-width: 860px) {
409
+ .tn-ml-split { display: grid; grid-template-columns: minmax(0,290px) minmax(0,400px); gap: 46px;
410
+ align-items: center; max-width: 780px; }
411
+ .tn-ml-split .tn-ml-split-art { justify-content: flex-start; }
412
+ }`;
413
+
414
+ function SplitLayout({ parts }: { parts: LayoutParts }) {
415
+ return (
416
+ <>
417
+ <style>{SPLIT_CSS}</style>
418
+ <div className="tn-ml-split" style={column(430)}>
419
+ <div className="tn-ml-split-art" style={{ display: "flex", justifyContent: "center", width: "100%" }}>
420
+ {parts.artwork(250)}
421
+ </div>
422
+ <div className="tn-ml-split-text" style={{ width: "100%", display: "flex", flexDirection: "column" }}>
423
+ <Heading parts={parts} align="left" size={27} />
424
+ <ListHeading parts={parts} />
425
+ <ServiceRows parts={parts} surface={parts.t.surface} />
426
+ {parts.pageActions}
427
+ {parts.footer}
428
+ </div>
429
+ </div>
430
+ </>
431
+ );
432
+ }
433
+
434
+ /** Type as the hero. The cover shrinks to a credit, and the marks stand alone. */
435
+ function MinimalLayout({ parts }: { parts: LayoutParts }) {
436
+ return (
437
+ <div style={column(460)}>
438
+ {parts.artwork(76)}
439
+ <Heading parts={parts} align="center" size={44} />
440
+ <div style={{ marginTop: 30 }}>
441
+ <ServiceMarks parts={parts} />
442
+ </div>
443
+ {parts.pageActions}
444
+ {parts.footer}
445
+ </div>
446
+ );
447
+ }
448
+
449
+ /** Round cover, brand-filled pills. The fastest to scan. */
450
+ function CompactLayout({ parts }: { parts: LayoutParts }) {
451
+ return (
452
+ <div style={column(380)}>
453
+ <div style={{ borderRadius: 999, overflow: "hidden", lineHeight: 0 }}>{parts.artwork(132)}</div>
454
+ <Heading parts={parts} align="center" size={22} />
455
+ <div style={{ marginTop: 22, width: "100%" }}>
456
+ <ServicePills parts={parts} />
457
+ </div>
458
+ {parts.pageActions}
459
+ {parts.footer}
460
+ </div>
461
+ );
462
+ }
463
+
464
+ const LAYOUTS: Record<MusicLinkStyle, (props: { parts: LayoutParts }) => ReactNode> = {
465
+ classic: ClassicLayout,
466
+ immersive: ImmersiveLayout,
467
+ split: SplitLayout,
468
+ minimal: MinimalLayout,
469
+ compact: CompactLayout,
470
+ };
471
+
472
+ export function MusicLinkLayout({ style, parts }: { style: MusicLinkStyle; parts: LayoutParts }) {
473
+ const Layout = LAYOUTS[style] ?? ClassicLayout;
474
+ return <Layout parts={parts} />;
475
+ }
@@ -0,0 +1,26 @@
1
+ // GENERATED by scripts/generateMusicServiceIcons.mjs. Do not edit by hand.
2
+ //
3
+ // Brand marks and colours from simple-icons. The icons are trademarks of their
4
+ // respective owners and each publishes brand guidelines covering colour and
5
+ // clear space; this renders them unmodified, at their own hex, on a plain
6
+ // ground, which is what those guidelines ask for.
7
+ //
8
+ // Platforms without an entry (amazonMusic, boomplay, anghami, audius) fall back to a
9
+ // brand-coloured initial badge.
10
+ export type MusicServiceIcon = { path: string; hex: string };
11
+
12
+ export const MUSIC_SERVICE_ICONS: Record<string, MusicServiceIcon> = {
13
+ spotify: { path: "M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z", hex: "#1ED760" },
14
+ appleMusic: { path: "M23.994 6.124a9.23 9.23 0 00-.24-2.19c-.317-1.31-1.062-2.31-2.18-3.043a5.022 5.022 0 00-1.877-.726 10.496 10.496 0 00-1.564-.15c-.04-.003-.083-.01-.124-.013H5.986c-.152.01-.303.017-.455.026-.747.043-1.49.123-2.193.4-1.336.53-2.3 1.452-2.865 2.78-.192.448-.292.925-.363 1.408-.056.392-.088.785-.1 1.18 0 .032-.007.062-.01.093v12.223c.01.14.017.283.027.424.05.815.154 1.624.497 2.373.65 1.42 1.738 2.353 3.234 2.801.42.127.856.187 1.293.228.555.053 1.11.06 1.667.06h11.03a12.5 12.5 0 001.57-.1c.822-.106 1.596-.35 2.295-.81a5.046 5.046 0 001.88-2.207c.186-.42.293-.87.37-1.324.113-.675.138-1.358.137-2.04-.002-3.8 0-7.595-.003-11.393zm-6.423 3.99v5.712c0 .417-.058.827-.244 1.206-.29.59-.76.962-1.388 1.14-.35.1-.706.157-1.07.173-.95.045-1.773-.6-1.943-1.536a1.88 1.88 0 011.038-2.022c.323-.16.67-.25 1.018-.324.378-.082.758-.153 1.134-.24.274-.063.457-.23.51-.516a.904.904 0 00.02-.193c0-1.815 0-3.63-.002-5.443a.725.725 0 00-.026-.185c-.04-.15-.15-.243-.304-.234-.16.01-.318.035-.475.066-.76.15-1.52.303-2.28.456l-2.325.47-1.374.278c-.016.003-.032.01-.048.013-.277.077-.377.203-.39.49-.002.042 0 .086 0 .13-.002 2.602 0 5.204-.003 7.805 0 .42-.047.836-.215 1.227-.278.64-.77 1.04-1.434 1.233-.35.1-.71.16-1.075.172-.96.036-1.755-.6-1.92-1.544-.14-.812.23-1.685 1.154-2.075.357-.15.73-.232 1.108-.31.287-.06.575-.116.86-.177.383-.083.583-.323.6-.714v-.15c0-2.96 0-5.922.002-8.882 0-.123.013-.25.042-.37.07-.285.273-.448.546-.518.255-.066.515-.112.774-.165.733-.15 1.466-.296 2.2-.444l2.27-.46c.67-.134 1.34-.27 2.01-.403.22-.043.442-.088.663-.106.31-.025.523.17.554.482.008.073.012.148.012.223.002 1.91.002 3.822 0 5.732z", hex: "#FA243C" },
15
+ itunes: { path: "M11.977 23.999c-2.483 0-4.898-.777-6.954-2.262a11.928 11.928 0 01-4.814-7.806A11.954 11.954 0 012.3 4.994 11.85 11.85 0 0110.08.159a11.831 11.831 0 018.896 2.104 11.933 11.933 0 014.815 7.807 11.958 11.958 0 01-2.091 8.937 11.855 11.855 0 01-7.78 4.835 12.17 12.17 0 01-1.943.157zm-6.474-2.926a11.022 11.022 0 008.284 1.96 11.044 11.044 0 007.246-4.504c3.583-5.003 2.445-12.003-2.538-15.603a11.022 11.022 0 00-8.284-1.96A11.046 11.046 0 002.966 5.47C-.618 10.474.521 17.473 5.503 21.073zm10.606-3.552a2.08 2.08 0 001.458-1.468l.062-.216.008-5.786c.006-4.334 0-5.814-.024-5.895a.535.535 0 00-.118-.214.514.514 0 00-.276-.073c-.073 0-.325.035-.56.078-1.041.19-7.176 1.411-7.281 1.45a.786.786 0 00-.399.354l-.065.128s-.031 9.07-.078 9.172a.7.7 0 01-.376.35 9.425 9.425 0 01-.609.137c-1.231.245-1.688.421-2.075.801-.22.216-.382.51-.453.82-.067.294-.045.736.051 1.005.1.281.262.521.473.71.192.148.419.258.674.324.563.144 1.618-.016 2.158-.328a2.36 2.36 0 00.667-.629c.06-.089.15-.268.2-.399.176-.456.181-8.581.204-8.683a.44.44 0 01.32-.344c.147-.04 6.055-1.207 6.222-1.23.146-.02.284.027.36.12a.29.29 0 01.109.096c.048.07.051.213.058 2.785.008 2.96.012 2.892-.149 3.079-.117.136-.263.189-.864.31-.914.188-1.226.276-1.576.447-.437.213-.679.446-.867.835a1.58 1.58 0 00-.182.754c.001.49.169.871.55 1.245.035.034.069.066.104.097.192.148.387.238.633.294.37.082 1.124.025 1.641-.126z", hex: "#FB5BC5" },
16
+ youtube: { path: "M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z", hex: "#FF0000" },
17
+ youtubeMusic: { path: "M12 0C5.376 0 0 5.376 0 12s5.376 12 12 12 12-5.376 12-12S18.624 0 12 0zm0 19.104c-3.924 0-7.104-3.18-7.104-7.104S8.076 4.896 12 4.896s7.104 3.18 7.104 7.104-3.18 7.104-7.104 7.104zm0-13.332c-3.432 0-6.228 2.796-6.228 6.228S8.568 18.228 12 18.228s6.228-2.796 6.228-6.228S15.432 5.772 12 5.772zM9.684 15.54V8.46L15.816 12l-6.132 3.54z", hex: "#FF0000" },
18
+ deezer: { path: "M.693 10.024c.381 0 .693-1.256.693-2.807 0-1.55-.312-2.807-.693-2.807C.312 4.41 0 5.666 0 7.217s.312 2.808.693 2.808ZM21.038 1.56c-.364 0-.684.805-.91 2.096C19.765 1.446 19.184 0 18.526 0c-.78 0-1.464 2.036-1.784 5-.312-2.158-.788-3.536-1.325-3.536-.745 0-1.386 2.704-1.62 6.472-.442-1.932-1.083-3.145-1.793-3.145s-1.35 1.213-1.793 3.145c-.242-3.76-.874-6.463-1.628-6.463-.537 0-1.013 1.378-1.325 3.535C6.938 2.036 6.262 0 5.474 0c-.658 0-1.247 1.447-1.602 3.665-.217-1.291-.546-2.105-.91-2.105-.675 0-1.221 2.807-1.221 6.272 0 3.466.546 6.273 1.221 6.273.277 0 .537-.476.736-1.273.32 2.928.996 4.938 1.776 4.938.606 0 1.143-1.204 1.507-3.11.251 3.622.875 6.195 1.602 6.195.46 0 .875-1.023 1.187-2.677C10.142 21.6 11 24 12.004 24c1.005 0 1.863-2.4 2.235-5.822.312 1.654.727 2.677 1.186 2.677.728 0 1.352-2.573 1.603-6.195.364 1.906.9 3.11 1.507 3.11.78 0 1.455-2.01 1.775-4.938.208.797.46 1.273.737 1.273.675 0 1.22-2.807 1.22-6.273-.008-3.457-.553-6.272-1.23-6.272ZM23.307 10.024c.381 0 .693-1.256.693-2.807 0-1.55-.312-2.807-.693-2.807-.381 0-.693 1.256-.693 2.807s.312 2.808.693 2.808Z", hex: "#A238FF" },
19
+ tidal: { path: "M12.012 3.992L8.008 7.996 4.004 3.992 0 7.996 4.004 12l4.004-4.004L12.012 12l-4.004 4.004 4.004 4.004 4.004-4.004L12.012 12l4.004-4.004-4.004-4.004zM16.042 7.996l3.979-3.979L24 7.996l-3.979 3.979z", hex: "#000000" },
20
+ soundcloud: { path: "M23.999 14.165c-.052 1.796-1.612 3.169-3.4 3.169h-8.18a.68.68 0 0 1-.675-.683V7.862a.747.747 0 0 1 .452-.724s.75-.513 2.333-.513a5.364 5.364 0 0 1 2.763.755 5.433 5.433 0 0 1 2.57 3.54c.282-.08.574-.121.868-.12.884 0 1.73.358 2.347.992s.948 1.49.922 2.373ZM10.721 8.421c.247 2.98.427 5.697 0 8.672a.264.264 0 0 1-.53 0c-.395-2.946-.22-5.718 0-8.672a.264.264 0 0 1 .53 0ZM9.072 9.448c.285 2.659.37 4.986-.006 7.655a.277.277 0 0 1-.55 0c-.331-2.63-.256-5.02 0-7.655a.277.277 0 0 1 .556 0Zm-1.663-.257c.27 2.726.39 5.171 0 7.904a.266.266 0 0 1-.532 0c-.38-2.69-.257-5.21 0-7.904a.266.266 0 0 1 .532 0Zm-1.647.77a26.108 26.108 0 0 1-.008 7.147.272.272 0 0 1-.542 0 27.955 27.955 0 0 1 0-7.147.275.275 0 0 1 .55 0Zm-1.67 1.769c.421 1.865.228 3.5-.029 5.388a.257.257 0 0 1-.514 0c-.21-1.858-.398-3.549 0-5.389a.272.272 0 0 1 .543 0Zm-1.655-.273c.388 1.897.26 3.508-.01 5.412-.026.28-.514.283-.54 0-.244-1.878-.347-3.54-.01-5.412a.283.283 0 0 1 .56 0Zm-1.668.911c.4 1.268.257 2.292-.026 3.572a.257.257 0 0 1-.514 0c-.241-1.262-.354-2.312-.023-3.572a.283.283 0 0 1 .563 0Z", hex: "#FF5500" },
21
+ bandcamp: { path: "M0 18.75l7.437-13.5H24l-7.438 13.5H0z", hex: "#408294" },
22
+ audiomack: { path: "M.331 11.378s.5418-.089.765.1439c.2234.2332.077.7156-.2195.7237-.2965.01-.5705.063-.765-.1439-.1946-.2066-.1424-.6218.2195-.7237m5.881 3.2925c-.0522.01-.1075-.018-.164-.059-.3884-.5413-.5287-2.3923-.707-2.5025-.185-.1144-.8545 1.0255-2.1862.903-.5569-.051-1.1236-.4121-1.4573-.662.031-.4206.0364-1.4027.8659-1.0833.5038.1939 1.3667.7266 2.1245-.23.8378-1.0579 1.2999-.7506 1.577-.5206.2771.23.0925 1.4259.5058 1.0916.4133-.3343 2.082-2.4103 2.082-2.4103s1.292-1.303 1.4898.067c.1979 1.3698 1.0403 2.8877 1.2635 2.8445.2234-.043 2.8223-5.3253 3.1945-5.666.3722-.3409 1.6252-.2961 1.5657.5781-.0596.8742-.1871 6.308-.1871 6.308s-.147 1.5311.0924.7128c.0992-.3392.206-.6453.3392-1.0024.6414-2.0534 1.734-5.5613 2.2784-7.3688.1252-.4325.233-.8037.3166-1.0891l.0001-.0008a3.5925 3.5925 0 0 1 .0973-.3305c.0455-.1532.0763-.2546.0858-.2813.0243-.068.0925-.1192.1884-.157.0962-.061.1995-.064.3165-.067.3021-.027.6907.012 1.0401.1119.1018 0 .2125.037.3172.1118v.0001s.0063 0 .0151.01c.0023 0 .0048 0 .0073.01.0219.015.0573.045.0983.095.0012 0 .0025 0 .004.01.017.021.0341.045.0515.073.1952.2863.315.814.1948 1.7498-.2996 2.3354-.5316 7.1397-.5316 7.1397s-.0461.2298.4353-.782c.0167-.035.0383-.066.058-.098.026-.017.0552-.042.0913-.085.2974-.3546 1.0968-.5629 1.6512-.5586.2336.028.4293.087.5462.1609.2188.333.0897 1.562.0897 1.562-.4612.043-1.3403.2908-1.6519.3366-.3118.046-.7852 2.0699-1.4433 1.8629-.6581-.2069-2.1246-1.1268-2.1246-1.2533 0-.1102.1152-1.4546.1453-1.8016.0022-.024.004-.046.0058-.068a.152.152 0 0 1 .0014-.014l-.0002.0003c.0213-.2733.0023-.3927-.1239-.1199-.1086.2346-.581 1.7359-1.1078 3.3709-.0556.1429-1.0511 3.1558-1.1818 3.5231-.156.4261-.287.7523-.3776.921-.1378.1867-.3234.3036-.5826.2252-.6465-.1954-1.4654-1.0889-1.473-1.3106-.0155-1.2503.0608-7.973-.2423-7.4127-.311.5744-2.73 4.5608-2.73 4.5608-.0405.01-.0705.01-.1062.01-.1712-.019-.4366-.074-.51-.2384-.004-.01-.0094-.018-.0129-.028-.0035-.01-.0075-.022-.0135-.04-.0329-.1097-.0463-.2289-.0753-.3265-.1082-.3652-.2813-.8886-.463-1.421-.2784-.9079-.5654-1.8366-.6127-1.9391-.0923-.2007-.2268-.116-.3475-.0002-.54.458-1.6868 2.4793-2.7225 2.5898", hex: "#FFA200" },
23
+ pandora: { path: "M1.882 0v24H8.32a1.085 1.085 0 001.085-1.085v-4.61h1.612c7.88 0 11.103-4.442 11.103-9.636C22.119 2.257 17.247 0 12.662 0H1.882Z", hex: "#224099" },
24
+ napster: { path: "M20.495 4.543c-.22 0-.438 0-.657.03h-.031l-.032-.03C17.93 2.21 15.02.798 12.015.798c-3.004 0-5.945 1.412-7.76 3.775l-.031.03h-.031c-.22-.03-.439-.03-.658-.03H2.16v2.394l-.032.03A3.628 3.628 0 0 0 0 10.282c0 1.412.844 2.701 2.127 3.284h.063v.062c.031 5.278 4.443 9.575 9.825 9.575s9.794-4.297 9.825-9.575v-.062l.032-.03C23.155 12.92 24 11.632 24 10.25c0-1.412-.845-2.7-2.128-3.284l-.032-.03V4.542zM6.821 5.34a7.443 7.443 0 0 1 5.194-2.117c1.94 0 3.818.767 5.195 2.117l.062.062-.094.061c-.375.215-.72.43-1.064.675l-.03.031-.032-.03c-.908-.461-2.347-.983-4.005-.983s-3.098.522-4.006.982l-.03.031-.032-.03a10.22 10.22 0 0 0-1.064-.676l-.188-.061zm12.329 8.195c0 3.866-3.223 7.027-7.166 7.027-3.942 0-7.134-3.16-7.134-7.027V7.458l.094.03c1.252.308 2.44 1.658 2.722 1.996.313-.338 1.784-1.658 4.35-1.658 2.565 0 4.036 1.35 4.35 1.658.28-.338 1.438-1.688 2.721-1.995l.094-.031v6.077z", hex: "#2259FF" },
25
+ tiktok: { path: "M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z", hex: "#000000" },
26
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WCAG relative luminance and contrast ratio, for deciding whether a colour is
3
+ * actually visible on a given background.
4
+ *
5
+ * This exists because brand colours cannot be rendered blindly. Several music
6
+ * services use pure black as their mark (Tidal, TikTok), which is 19:1 on a
7
+ * white page and 1.17:1 on a dark one, meaning it does not render at all on a
8
+ * dark theme. Trusting the brand hex on a themeable page ships an invisible
9
+ * logo to whichever half of creators picked the other background.
10
+ */
11
+
12
+ const channel = (value: number) => (value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4));
13
+
14
+ /** WCAG relative luminance of a `#rgb` or `#rrggbb` colour. `null` if unparseable. */
15
+ export function relativeLuminance(hex: string): number | null {
16
+ const value = hex.trim().replace("#", "");
17
+ const full =
18
+ value.length === 3
19
+ ? value
20
+ .split("")
21
+ .map((c) => c + c)
22
+ .join("")
23
+ : value;
24
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
25
+
26
+ const [r, g, b] = [0, 2, 4].map((i) => channel(parseInt(full.slice(i, i + 2), 16) / 255));
27
+ return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
28
+ }
29
+
30
+ /** Contrast ratio between two colours, 1 to 21. `null` if either is unparseable. */
31
+ export function contrastRatio(a: string, b: string): number | null {
32
+ const la = relativeLuminance(a);
33
+ const lb = relativeLuminance(b);
34
+ if (la === null || lb === null) return null;
35
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
36
+ return (hi + 0.05) / (lo + 0.05);
37
+ }
38
+
39
+ /**
40
+ * The brand colour where it is legible, the fallback where it is not.
41
+ *
42
+ * The threshold is 2.0 rather than WCAG's 3:1 for non-text content, and that is
43
+ * a deliberate trade rather than an oversight. These are logo silhouettes whose
44
+ * SHAPE carries the recognition, and the brands themselves ship them at ratios
45
+ * below 3:1 (Spotify green on white is 1.76:1, and Spotify's own guidelines
46
+ * permit exactly that). Holding to 3:1 would repaint most of the marks a flat
47
+ * theme colour and throw away the recognition that makes them worth using.
48
+ *
49
+ * 2.0 catches only what genuinely disappears, which in practice is the pure
50
+ * black marks on a dark background. Those brands all publish a monochrome
51
+ * variant for dark surfaces, so swapping to the theme's own text colour is what
52
+ * their guidelines ask for anyway.
53
+ */
54
+ export function readableBrandColor(brand: string, background: string, fallback: string, min = 2.0): string {
55
+ const ratio = contrastRatio(brand, background);
56
+ // Unparseable means unknown, and an unknown colour is not worth risking an
57
+ // invisible logo over.
58
+ if (ratio === null) return fallback;
59
+ return ratio >= min ? brand : fallback;
60
+ }