@tribe-nest/forge 3.24.0 → 3.26.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.
@@ -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
+ }