summaryrefslogtreecommitdiff
path: root/src/components/jet/today-card
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/jet/today-card')
-rw-r--r--src/components/jet/today-card/TodayCard.svelte401
-rw-r--r--src/components/jet/today-card/TodayCardMedia.svelte49
-rw-r--r--src/components/jet/today-card/TodayCardOverlay.svelte48
-rw-r--r--src/components/jet/today-card/background-color-utils.ts54
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte78
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte62
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte41
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaList.svelte86
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaRiver.svelte78
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaVideo.svelte72
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte100
-rw-r--r--src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte42
12 files changed, 1111 insertions, 0 deletions
diff --git a/src/components/jet/today-card/TodayCard.svelte b/src/components/jet/today-card/TodayCard.svelte
new file mode 100644
index 0000000..84d760f
--- /dev/null
+++ b/src/components/jet/today-card/TodayCard.svelte
@@ -0,0 +1,401 @@
+<script lang="ts">
+ import type { TodayCard } from '@jet-app/app-store/api/models';
+
+ import Artwork, {
+ type Profile,
+ getNaturalProfile,
+ } from '~/components/Artwork.svelte';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import TodayCardMedia from '~/components/jet/today-card/TodayCardMedia.svelte';
+ import TodayCardOverlay from '~/components/jet/today-card/TodayCardOverlay.svelte';
+ import { isTodayCardMediaList } from '~/components/jet/today-card/media/TodayCardMediaList.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ import { colorAsString } from '~/utils/color';
+ import { bestBackgroundColor } from './background-color-utils';
+
+ export let card: TodayCard;
+
+ /**
+ * When set to `true`, this component will not enable the `clickAction` provided by the
+ * `card`
+ *
+ * This can be useful on the "story" page, where the card will link back to the page
+ * currently being viewed
+ */
+ export let suppressClickAction: boolean = false;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+
+ let useProtectionLayer: boolean;
+ let useBlurryProtectionLayer: boolean;
+ let useGradientProtectionLayer: boolean;
+ let useListStyle: boolean;
+ let accentColor: string;
+
+ $: ({
+ heading,
+ title,
+ inlineDescription,
+ titleArtwork,
+ overlay,
+ media,
+ editorialDisplayOptions,
+ style = 'light',
+ clickAction,
+ } = card);
+ $: action = suppressClickAction ? undefined : clickAction;
+
+ $: {
+ const isAppEvent = media?.kind === 'appEvent';
+ const isList = !!media && isTodayCardMediaList(media);
+
+ useListStyle = isList;
+ useProtectionLayer =
+ editorialDisplayOptions?.useTextProtectionColor ||
+ editorialDisplayOptions?.useMaterialBlur ||
+ false;
+ useBlurryProtectionLayer = useProtectionLayer && !isAppEvent && !isList;
+ useGradientProtectionLayer = useProtectionLayer && isAppEvent;
+ accentColor = colorAsString(bestBackgroundColor(card.media));
+ }
+</script>
+
+<!--
+ We don't wrap the entire card with an action if there is an `overlay`, since the overlay has
+ it's own link / action (and we don't want nesting `a` tags, of course).
+-->
+<LinkWrapper action={overlay || useListStyle ? null : action}>
+ <div
+ class="today-card"
+ class:light={style === 'light'}
+ class:dark={style === 'dark'}
+ class:white={style === 'white'}
+ class:list={useListStyle}
+ class:with-overlay={overlay}
+ style:--today-card-accent-color={accentColor}
+ >
+ {#if media && !useListStyle}
+ <TodayCardMedia {media} {artworkProfile} />
+ {/if}
+
+ <div class="wrapper">
+ <div
+ class="information-layer"
+ class:with-gradient={useGradientProtectionLayer}
+ class:with-action={!!action}
+ >
+ <LinkWrapper action={useListStyle ? null : action}>
+ <div class="content-container">
+ {#if useBlurryProtectionLayer}
+ <div class="protection-layer" />
+ {/if}
+
+ <div class="title-container">
+ {#if heading && !titleArtwork}
+ <p class="badge">
+ <LineClamp clamp={1}>
+ {heading}
+ </LineClamp>
+ </p>
+ {/if}
+
+ {#if titleArtwork}
+ <div class="title-artwork-container">
+ <Artwork
+ artwork={titleArtwork}
+ profile={getNaturalProfile(
+ titleArtwork,
+ )}
+ />
+ </div>
+ {/if}
+
+ {#if title && !titleArtwork}
+ <h3 class="title">
+ <LinkWrapper
+ action={useListStyle ? action : null}
+ >
+ {@html sanitizeHtml(title)}
+ </LinkWrapper>
+ </h3>
+ {/if}
+
+ {#if inlineDescription}
+ <LineClamp clamp={2}>
+ <p class="description">
+ {@html sanitizeHtml(inlineDescription)}
+ </p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+ </LinkWrapper>
+
+ {#if overlay}
+ <div
+ class="overlay"
+ class:blur-only={!useProtectionLayer}
+ class:dark={useProtectionLayer && style !== 'dark'}
+ class:light={useProtectionLayer && style === 'dark'}
+ >
+ <TodayCardOverlay
+ {overlay}
+ buttonVariant={useProtectionLayer
+ ? 'transparent'
+ : 'dark-gray'}
+ --text-color="var(--today-card-text-color)"
+ --text-accent-color="var(--today-card-text-accent-color)"
+ --text-accent-blend-mode="var(--today-card-text-accent-blend-mode)"
+ />
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ {#if media && useListStyle}
+ <TodayCardMedia {media} {artworkProfile} />
+ {/if}
+ </div>
+</LinkWrapper>
+
+<style lang="scss">
+ @property --gradient-color {
+ syntax: '<color>';
+ inherits: true;
+ initial-value: #000;
+ }
+
+ .today-card {
+ --today-card-gutter: 16px;
+ --today-card-border-radius: var(
+ --border-radius,
+ var(--global-border-radius-large)
+ );
+ --protection-layer-bottom-offset: 0px;
+ --gradient-color: var(--today-card-accent-color);
+ background-color: var(--today-card-accent-color);
+ position: relative;
+ display: flex;
+ align-items: end;
+ height: 100%;
+ overflow: hidden;
+ color: var(--today-card-text-color);
+ container-type: size;
+ container-name: today-card;
+ border-radius: var(--today-card-border-radius);
+ box-shadow: var(--shadow-small);
+ }
+
+ .today-card.with-overlay {
+ --protection-layer-bottom-offset: 80px;
+ }
+
+ .today-card.light,
+ .today-card.dark {
+ --today-card-text-color: rgb(255, 255, 255);
+ --today-card-text-accent-color: rgba(255, 255, 255, 0.56);
+ --today-card-text-accent-blend-mode: plus-lighter;
+ --today-card-background-tint-color: rgba(0, 0, 0, 0.18);
+ }
+
+ .today-card.white {
+ --today-card-text-color: var(--systemPrimary-onLight);
+ --today-card-text-accent-color: rgba(0, 0, 0, 0.56);
+ --today-card-background-tint-color: rgba(255, 255, 255, 0.33);
+ --today-card-text-accent-blend-mode: revert;
+ }
+
+ .today-card :global(.artwork-component) {
+ z-index: unset;
+ }
+
+ .wrapper {
+ position: absolute;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ }
+
+ .content-container {
+ position: relative;
+ }
+
+ .information-layer {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ align-self: flex-end;
+ width: 100%;
+ height: 100%;
+ border-radius: var(--today-card-border-radius);
+ overflow: hidden;
+ }
+
+ .information-layer > :global(a) {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ justify-content: end;
+ }
+
+ .information-layer.with-gradient {
+ // A smooth bottom-to-top gradient with an intermediate stop at 60% of the accent color's
+ // opacity to ease the hard transition.
+ --gradient-color-end-position: 22%;
+ --gradient-fade-end-position: 50%;
+ background: linear-gradient(
+ 0deg,
+ var(--gradient-color) var(--gradient-color-end-position),
+ color-mix(in srgb, var(--gradient-color) 60%, transparent)
+ calc(
+ (
+ var(--gradient-color-end-position) +
+ var(--gradient-fade-end-position)
+ ) / 2
+ ),
+ transparent var(--gradient-fade-end-position)
+ );
+ transition: --accent-color-end 500ms ease-out, --fade-end 350ms ease-out,
+ --gradient-color 350ms ease-out;
+ }
+
+ .information-layer.with-gradient.with-action:has(> a:hover) {
+ // Darkens the color used in the gradient on hover
+ --gradient-color: color-mix(
+ in srgb,
+ var(--today-card-accent-color) 93%,
+ black
+ );
+ }
+
+ @container today-card (aspect-ratio >= 16/9) {
+ .information-layer.with-gradient {
+ --accent-color-end: 30%;
+ }
+ }
+
+ .protection-layer {
+ --brightness: 0.95;
+ position: absolute;
+ width: 100%;
+ // On cards with overlays (app lockups at the bottom), we increase the height of the
+ // protection layer and shift it downward the same amount, so it is aligned to bottom
+ // of the overlay.
+ height: calc(100% + var(--protection-layer-bottom-offset) + 60px);
+ bottom: calc(-1 * var(--protection-layer-bottom-offset));
+ background: var(--today-card-background-tint-color);
+ backdrop-filter: blur(34px) brightness(var(--brightness)) saturate(1.6)
+ contrast(1.1);
+ mask-image: linear-gradient(
+ to top,
+ black 30%,
+ rgba(0, 0, 0, 0.75) 70%,
+ rgba(0, 0, 0, 0.4) 86%,
+ transparent 100%
+ );
+ transition: backdrop-filter 210ms ease-in;
+ }
+
+ .information-layer:has(> a:hover) .protection-layer {
+ --brightness: 0.88;
+ }
+
+ .badge {
+ font: var(--callout-emphasized);
+ margin-bottom: 4px;
+ mix-blend-mode: var(--today-card-text-accent-blend-mode);
+ color: var(--today-card-text-accent-color);
+ }
+
+ .title-container {
+ width: auto;
+ position: relative;
+ padding: 0 var(--today-card-gutter) var(--today-card-gutter);
+ }
+
+ @container today-card (orientation: landscape) {
+ .title-artwork-container {
+ width: 33%;
+ min-width: 200px;
+ max-width: 300px;
+ padding-bottom: 8px;
+ }
+ }
+
+ @container today-card (orientation: portrait) {
+ .title-artwork-container {
+ max-width: 75%;
+ padding-bottom: 8px;
+ }
+ }
+
+ .title {
+ font: var(--header-emphasized);
+ color: var(--today-card-text-color);
+ text-wrap: pretty;
+ }
+
+ .description {
+ font: var(--body);
+ padding-top: calc(var(--today-card-gutter) / 2);
+ mix-blend-mode: var(--today-card-text-accent-blend-mode);
+ color: var(--today-card-text-accent-color);
+ text-wrap: pretty;
+ z-index: 1;
+ position: relative;
+ }
+
+ .overlay {
+ z-index: 1;
+ position: relative;
+ padding: var(--today-card-gutter);
+ }
+
+ .overlay.blur-only {
+ backdrop-filter: blur(50px);
+ }
+
+ .overlay.light {
+ background-image: linear-gradient(rgba(225, 225, 225, 0.15) 0 0);
+ }
+
+ .overlay.dark {
+ background-image: linear-gradient(rgba(0, 0, 0, 0.15) 0 0);
+ }
+
+ .list {
+ background: var(--systemPrimary-onDark);
+ padding: var(--today-card-gutter) 0;
+ width: 100%;
+ flex-direction: column;
+
+ @media (prefers-color-scheme: dark) {
+ --title-color: var(--systemPrimary);
+ background: var(--systemQuaternary);
+
+ .title {
+ --today-card-text-color: var(--systemPrimary);
+ }
+
+ .badge {
+ --today-card-text-accent-color: var(--systemSecondary);
+ }
+ }
+ }
+
+ .list .wrapper {
+ position: relative;
+ height: auto;
+ width: 100%;
+ }
+
+ .list .information-layer {
+ padding-top: 0;
+ }
+</style>
diff --git a/src/components/jet/today-card/TodayCardMedia.svelte b/src/components/jet/today-card/TodayCardMedia.svelte
new file mode 100644
index 0000000..99f444f
--- /dev/null
+++ b/src/components/jet/today-card/TodayCardMedia.svelte
@@ -0,0 +1,49 @@
+<script lang="ts">
+ import type { TodayCardMedia } from '@jet-app/app-store/api/models';
+
+ import type { Profile } from '~/components/Artwork.svelte';
+ import TodayCardMediaAppEvent, {
+ isTodayCardMediaAppEvent,
+ } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte';
+ import TodayCardMediaAppIcon, {
+ isTodayCardMediAppIcon,
+ } from '~/components/jet/today-card/media/TodayCardMediaAppIcon.svelte';
+ import TodayCardMediaBrandedSingleApp, {
+ isTodayCardMediaBrandedSingleApp,
+ } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte';
+ import TodayCardMediaList, {
+ isTodayCardMediaList,
+ } from '~/components/jet/today-card/media/TodayCardMediaList.svelte';
+ import TodayCardMediaRiver, {
+ isTodayCardMediaRiver,
+ } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte';
+ import TodayCardMediaWithArtwork, {
+ isTodayCardMediaWithArtwork,
+ } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo, {
+ isTodayCardMediaVideo,
+ } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+
+ export let media: TodayCardMedia;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+</script>
+
+{#if isTodayCardMediaAppEvent(media)}
+ <TodayCardMediaAppEvent {media} {artworkProfile} />
+{:else if isTodayCardMediAppIcon(media)}
+ <TodayCardMediaAppIcon {media} />
+{:else if isTodayCardMediaBrandedSingleApp(media)}
+ <TodayCardMediaBrandedSingleApp {media} {artworkProfile} />
+{:else if isTodayCardMediaList(media)}
+ <TodayCardMediaList {media} />
+{:else if isTodayCardMediaWithArtwork(media)}
+ <TodayCardMediaWithArtwork {media} {artworkProfile} />
+{:else if isTodayCardMediaRiver(media)}
+ <TodayCardMediaRiver {media} />
+{:else if isTodayCardMediaVideo(media)}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+{/if}
diff --git a/src/components/jet/today-card/TodayCardOverlay.svelte b/src/components/jet/today-card/TodayCardOverlay.svelte
new file mode 100644
index 0000000..4e3c405
--- /dev/null
+++ b/src/components/jet/today-card/TodayCardOverlay.svelte
@@ -0,0 +1,48 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardOverlay,
+ TodayCardLockupOverlay,
+ } from '@jet-app/app-store/api/models';
+
+ export function isLockupOverlay(
+ overlay: TodayCardOverlay,
+ ): overlay is TodayCardLockupOverlay {
+ return overlay.kind === 'lockup';
+ }
+</script>
+
+<script lang="ts">
+ import TodayCardLockupListOverlay, {
+ isLockupListOverlay,
+ } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+
+ export let overlay: TodayCardOverlay;
+ export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'transparent';
+</script>
+
+{#if isLockupOverlay(overlay)}
+ <div class="small-lockup-item-config">
+ <SmallLockupItem
+ {buttonVariant}
+ item={overlay.lockup}
+ titleLineCount={1}
+ appIconProfile="app-icon"
+ />
+ </div>
+{:else if isLockupListOverlay(overlay)}
+ <TodayCardLockupListOverlay {overlay} />
+{/if}
+
+<style>
+ .small-lockup-item-config {
+ --title-color: var(--text-color, currentColor);
+ --subtitle-color: var(--text-accent-color, currentColor);
+ --linkColor: currentColor;
+ --eyebrow-color: var(--text-accent-color, currentColor);
+ --button-blend-mode: var(--text-accent-blend-mode);
+ --subtitle-blend-mode: var(--text-accent-blend-mode);
+ --eyebrow-blend-mode: var(--text-accent-blend-mode);
+ display: contents;
+ }
+</style>
diff --git a/src/components/jet/today-card/background-color-utils.ts b/src/components/jet/today-card/background-color-utils.ts
new file mode 100644
index 0000000..c2c0fe6
--- /dev/null
+++ b/src/components/jet/today-card/background-color-utils.ts
@@ -0,0 +1,54 @@
+import { type Optional, isSome } from '@jet/environment/types/optional';
+import type {
+ Color,
+ TodayCardMedia,
+ TodayCardMediaWithArtwork,
+} from '@jet-app/app-store/api/models';
+
+import { isTodayCardMediaBrandedSingleApp } from './media/TodayCardMediaBrandedSingleApp.svelte';
+import { isTodayCardMediaAppEvent } from './media/TodayCardMediaAppEvent.svelte';
+import { isTodayCardMediaWithArtwork } from './media/TodayCardMediaWithArtwork.svelte';
+
+const DEFAULT_COLOR: Color = {
+ type: 'named',
+ name: 'defaultBackground',
+};
+
+function getBackgroundFromMediaWithArtwork(
+ media: TodayCardMediaWithArtwork,
+): Optional<Color> {
+ return (
+ media.videos[0]?.preview.backgroundColor ??
+ media.artworks[0]?.backgroundColor
+ );
+}
+
+/**
+ * Onyx App Store alternative to the `bestBackgroundColor` method that exists on
+ * the {@linkcode TodayCardMedia} type
+ *
+ * This is necessary because the functions on those class instances are not
+ * carried over to the client when serializing the view-model, making them
+ * impossible to call in a consistent way from our codebase
+ */
+export function bestBackgroundColor(media: Optional<TodayCardMedia>): Color {
+ if (isSome(media)) {
+ if (isTodayCardMediaAppEvent(media)) {
+ return media.tintColor;
+ }
+
+ if (isTodayCardMediaBrandedSingleApp(media)) {
+ return (
+ getBackgroundFromMediaWithArtwork(media) ??
+ media.icon.backgroundColor ??
+ DEFAULT_COLOR
+ );
+ }
+
+ if (isTodayCardMediaWithArtwork(media)) {
+ return getBackgroundFromMediaWithArtwork(media) ?? DEFAULT_COLOR;
+ }
+ }
+
+ return DEFAULT_COLOR;
+}
diff --git a/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte
new file mode 100644
index 0000000..1faa933
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaAppEvent,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaAppEvent(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaAppEvent {
+ return media.kind === 'appEvent';
+ }
+</script>
+
+<script lang="ts">
+ import type { Profile } from '~/components/Artwork.svelte';
+ import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+
+ export let media: TodayCardMediaAppEvent;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+</script>
+
+<div class="event-container">
+ <span class="time-container">
+ <AppEventDate formattedDates={media.formattedDates} />
+ </span>
+
+ <div class="artwork-container">
+ {#if media.videos.length > 0}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+ {:else if media.artworks.length > 0}
+ <TodayCardMediaWithArtwork {media} {artworkProfile} />
+ {/if}
+ </div>
+</div>
+
+<style>
+ .event-container {
+ --today-card-border-width: 4px;
+ border: var(--today-card-border-width) solid
+ var(--today-card-accent-color);
+ border-radius: var(--today-card-border-radius);
+ position: relative;
+ aspect-ratio: 0.75;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ @container (orientation: landscape) {
+ .event-container {
+ aspect-ratio: 16/9;
+ }
+ }
+
+ .artwork-container {
+ height: 100%;
+ border-radius: calc(
+ var(--today-card-border-radius) - var(--today-card-border-width)
+ );
+ }
+
+ .time-container :global(time),
+ .time-container :global(span) {
+ background: var(--today-card-accent-color);
+ border-end-end-radius: var(--today-card-border-radius);
+ font: var(--headline);
+ padding: 6px 10px 6px 8px;
+ position: absolute;
+ top: 0;
+ z-index: 3;
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte
new file mode 100644
index 0000000..a6db985
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte
@@ -0,0 +1,62 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaAppIcon,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediAppIcon(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaAppIcon {
+ return media.kind === 'appIcon';
+ }
+</script>
+
+<script lang="ts">
+ import AppIcon from '~/components/AppIcon.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let media: TodayCardMediaAppIcon;
+
+ $: backgroundColor = media.icon.backgroundColor
+ ? colorAsString(media.icon.backgroundColor)
+ : null;
+</script>
+
+<div class="container" style:--background-color={backgroundColor}>
+ <div class="artwork-container">
+ <AppIcon
+ icon={media.icon}
+ profile="app-icon-xlarge"
+ fixedWidth={false}
+ />
+ </div>
+</div>
+
+<style>
+ .container {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: var(--background-color);
+ border-radius: var(--today-card-border-radius);
+ }
+
+ .artwork-container {
+ width: 50%;
+ height: 50%;
+ }
+
+ @container (orientation: landscape) {
+ .container {
+ align-items: start;
+ padding-top: 5%;
+ }
+
+ .artwork-container {
+ width: 30%;
+ height: 30%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte
new file mode 100644
index 0000000..dfdaa0f
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte
@@ -0,0 +1,41 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaBrandedSingleApp,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaBrandedSingleApp(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaBrandedSingleApp {
+ return media.kind === 'brandedSingleApp';
+ }
+</script>
+
+<script lang="ts">
+ import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+ import type { Profile } from '~/components/Artwork.svelte';
+
+ export let media: TodayCardMediaBrandedSingleApp;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+
+ // There is a small but non-zero set of old legacy Today Cards that can appear on the Today page,
+ // and those cards have their safe area on the left side of the artwork, rather than the center,
+ // like all the modern artwork. For those cases, we pin the artwork to the left edge of the card.
+ $: pinnedToLeft =
+ media.artworkLayoutsWithMetrics[0].ltr.collapsedLayoutInsets.left < 0;
+</script>
+
+{#if media.videos.length > 0}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+{:else if media.artworks.length > 0}
+ <TodayCardMediaWithArtwork
+ {media}
+ {artworkProfile}
+ pinArtworkToLeft={pinnedToLeft}
+ />
+{/if}
diff --git a/src/components/jet/today-card/media/TodayCardMediaList.svelte b/src/components/jet/today-card/media/TodayCardMediaList.svelte
new file mode 100644
index 0000000..00f8688
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaList.svelte
@@ -0,0 +1,86 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaList,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaList(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaList {
+ return media.kind === 'list';
+ }
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+
+ export let media: TodayCardMediaList;
+
+ let container: HTMLDivElement;
+ let fadeTop = '0%';
+ let fadeBottom = '0%';
+
+ function calculateFadeAmounts() {
+ const { scrollTop, scrollHeight, clientHeight } = container;
+
+ fadeTop = scrollTop > 0 ? '10%' : `${scrollTop}%`;
+ fadeBottom = scrollTop + clientHeight < scrollHeight - 1 ? '15%' : '0%';
+ }
+
+ onMount(() => {
+ calculateFadeAmounts();
+ container.addEventListener('scroll', calculateFadeAmounts);
+
+ return () =>
+ container.removeEventListener('scroll', calculateFadeAmounts);
+ });
+</script>
+
+<div
+ class="container"
+ style:--fade-top-size={fadeTop}
+ style:--fade-bottom-size={fadeBottom}
+ bind:this={container}
+>
+ <ul>
+ {#each media.lockups as item}
+ <li>
+ <SmallLockupItem {item} />
+ </li>
+ {/each}
+ </ul>
+</div>
+
+<style>
+ @property --fade-top-size {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 0%;
+ }
+
+ @property --fade-bottom-size {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 0%;
+ }
+
+ .container {
+ width: 100%;
+ overflow: scroll;
+ padding: 0 var(--today-card-gutter);
+ mask-image: linear-gradient(
+ to bottom,
+ transparent 0%,
+ black var(--fade-top-size),
+ black calc(100% - var(--fade-bottom-size)),
+ transparent 100%
+ );
+ transition: --fade-top-size 105ms cubic-bezier(0.5, 1, 0.89, 1),
+ --fade-bottom-size 420ms cubic-bezier(0.45, 0, 0.55, 1);
+ }
+
+ li {
+ margin-bottom: 16px;
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaRiver.svelte b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte
new file mode 100644
index 0000000..d3f9666
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaRiver,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaRiver(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaRiver {
+ return media.kind === 'river';
+ }
+</script>
+
+<script lang="ts">
+ import {
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import AppIconRiver from '~/components/AppIconRiver.svelte';
+
+ /**
+ * The actual properties of {@linkcode TodayCardMediaRiver} that are required
+ * to render this component
+ */
+ type TodayCardMediaRiverRequirements = Pick<TodayCardMediaRiver, 'lockups'>;
+
+ export let media: TodayCardMediaRiverRequirements;
+
+ $: icons = media.lockups.map((lockup) => lockup.icon);
+ $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
+ icons,
+ {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ },
+ );
+</script>
+
+<div class="container" style={backgroundGradientCssVars}>
+ {#if icons.length}
+ <AppIconRiver {icons} />
+ {/if}
+</div>
+
+<style>
+ .container {
+ --app-icon-river-icon-width: 96px;
+ height: 100%;
+ width: 100%;
+ padding-top: 10%;
+ overflow: hidden;
+ border-radius: var(--today-card-border-radius);
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) 20%,
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) 40%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 140% -50%,
+ var(--top-right, #000) 60%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) 50%,
+ transparent 100%
+ );
+
+ @media (--range-small-only) {
+ padding-top: 5%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaVideo.svelte b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte
new file mode 100644
index 0000000..f2524c6
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte
@@ -0,0 +1,72 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaVideo,
+ Video as VideoModel,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaVideo(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaVideo {
+ return (
+ media.kind === 'video' ||
+ (media.kind === 'artwork' && 'videos' in media)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import mediaQueries from '~/utils/media-queries';
+ import type { Profile as ArtworkProfile } from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let media: TodayCardMediaVideo;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: ArtworkProfile | undefined = undefined;
+
+ let videoToDisplay: VideoModel | undefined;
+ $: videoToDisplay = media.videos[0];
+
+ let profile: ArtworkProfile;
+ $: profile =
+ artworkProfile ??
+ ($mediaQueries === 'small' ? 'card' : 'card-horizontal');
+ $: backgroundColor = videoToDisplay?.preview.backgroundColor
+ ? colorAsString(videoToDisplay?.preview.backgroundColor)
+ : null;
+</script>
+
+{#if videoToDisplay}
+ <div class="video-wrapper" style:--background-color={backgroundColor}>
+ <Video
+ autoplay
+ loop
+ {profile}
+ useControls={false}
+ video={videoToDisplay}
+ />
+ </div>
+{/if}
+
+<style>
+ .video-wrapper {
+ background: black;
+ aspect-ratio: 3/4;
+ width: 100%;
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--today-card-border-radius);
+ background-color: var(--background-color);
+ }
+
+ @container (orientation: landscape) {
+ .video-wrapper {
+ aspect-ratio: 16/9;
+ height: 100%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte
new file mode 100644
index 0000000..e604708
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte
@@ -0,0 +1,100 @@
+<script lang="ts" context="module">
+ import type {
+ Artwork as ArtworkModel,
+ TodayCardMedia,
+ TodayCardMediaWithArtwork,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaWithArtwork(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaWithArtwork {
+ return (
+ media.kind === 'artwork' &&
+ 'artworks' in media &&
+ Array.isArray(media.artworks) &&
+ media.artworks.length > 0
+ );
+ }
+</script>
+
+<script lang="ts">
+ import Artwork, {
+ type Profile as ArtworkProfile,
+ } from '~/components/Artwork.svelte';
+
+ export let media: TodayCardMediaWithArtwork;
+
+ export let pinArtworkToLeft: boolean = false;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: ArtworkProfile | undefined = undefined;
+
+ let artworkToDisplay: ArtworkModel;
+ // Today Card artwork comes back from Jet with a width of 800px, even though the source artwork
+ // is _much_ larger. The shared `Artwork` component doesn't let us render an image beyond the
+ // artwork's `width` and `height` properties, and we absolutely need to render these images
+ // larger than 800px wide, so we are forcing these new upper bounds for the artworks dimensions.
+ // Eventually, we should rethink this and have the proper dimensions come back from Jet:
+ // rdar://148730199 (Bigger images for TodayCard)
+ $: artworkToDisplay = Object.assign({}, media.artworks[0], {
+ width: 3840,
+ height: 2160,
+ });
+</script>
+
+{#if artworkProfile}
+ <Artwork profile={artworkProfile} artwork={artworkToDisplay} />
+{:else}
+ <div class="wrapper">
+ <div class="artwork-container portrait">
+ <Artwork profile="card" artwork={artworkToDisplay} />
+ </div>
+
+ <div
+ class="artwork-container landscape"
+ class:pinned-to-left={pinArtworkToLeft}
+ >
+ <Artwork profile="card-horizontal" artwork={artworkToDisplay} />
+ </div>
+ </div>
+{/if}
+
+<style>
+ .wrapper,
+ .artwork-container {
+ height: 100%;
+ width: 100%;
+ }
+
+ .wrapper .artwork-container :global(.artwork-component),
+ .wrapper .artwork-container :global(img) {
+ object-fit: cover;
+ height: 100%;
+ }
+
+ .pinned-to-left {
+ --artwork-override-object-position: left;
+ }
+
+ @container (orientation: landscape) {
+ .artwork-container.landscape {
+ display: block;
+ }
+
+ .artwork-container.portrait {
+ display: none;
+ }
+ }
+
+ @container (orientation: portrait) {
+ .artwork-container.landscape {
+ display: none;
+ }
+
+ .artwork-container.portrait {
+ display: block;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte
new file mode 100644
index 0000000..1e7d297
--- /dev/null
+++ b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte
@@ -0,0 +1,42 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardOverlay,
+ TodayCardLockupListOverlay,
+ } from '@jet-app/app-store/api/models';
+
+ export function isLockupListOverlay(
+ overlay: TodayCardOverlay,
+ ): overlay is TodayCardLockupListOverlay {
+ return overlay.kind === 'lockupList';
+ }
+</script>
+
+<script lang="ts">
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let overlay: TodayCardLockupListOverlay;
+</script>
+
+<div class="lockup-list">
+ {#each overlay.lockups as lockup}
+ <LinkWrapper action={lockup.clickAction}>
+ <AppIcon icon={lockup.icon} />
+ </LinkWrapper>
+ {/each}
+</div>
+
+<style>
+ .lockup-list {
+ display: flex;
+ gap: 12px;
+
+ @media (--range-xsmall-only) and (--sidebar-visible) {
+ gap: 10px;
+ }
+
+ @media (--range-small-up) {
+ gap: 16px;
+ }
+ }
+</style>