@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 +2 -1
- package/src/types/models.ts +7 -0
- package/src/ui/styled/MusicLinkPage.tsx +290 -137
- package/src/ui/styled/_tests/musicLinkChrome.spec.ts +48 -0
- package/src/ui/styled/musicLinkStyles.tsx +475 -0
- package/src/ui/styled/musicServiceIcons.ts +26 -0
- package/src/utils/contrast.ts +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
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"
|
package/src/types/models.ts
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
+
}
|