summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src
init commit
Diffstat (limited to 'src')
-rw-r--r--src/App.svelte161
-rw-r--r--src/bootstrap.ts97
-rw-r--r--src/browser.ts100
-rw-r--r--src/components/AmbientBackgroundArtwork.svelte202
-rw-r--r--src/components/AppEventDate.svelte72
-rw-r--r--src/components/AppIcon.svelte131
-rw-r--r--src/components/AppIconRiver.svelte92
-rw-r--r--src/components/Artwork.svelte118
-rw-r--r--src/components/CollapsableContent.svelte36
-rw-r--r--src/components/EditorsChoiceBadge.svelte56
-rw-r--r--src/components/Error.svelte10
-rw-r--r--src/components/GradientOverlay.svelte23
-rw-r--r--src/components/Grid.svelte37
-rw-r--r--src/components/HoverWrapper.svelte54
-rw-r--r--src/components/LaunchNativeButton.svelte69
-rw-r--r--src/components/LinkWrapper.svelte60
-rw-r--r--src/components/Menu.svelte218
-rw-r--r--src/components/MotionArtwork.svelte152
-rw-r--r--src/components/Page.svelte68
-rw-r--r--src/components/PageModal.svelte82
-rw-r--r--src/components/PageResolver.svelte25
-rw-r--r--src/components/ProductPageArcadeBanner.svelte188
-rw-r--r--src/components/ProductPageArcadeFooter.svelte159
-rw-r--r--src/components/SFSymbol.svelte51
-rw-r--r--src/components/ShareArrowButton.svelte90
-rw-r--r--src/components/Shelf/Title.svelte112
-rw-r--r--src/components/Shelf/Wrapper.svelte81
-rw-r--r--src/components/ShelfItemLayout.svelte103
-rw-r--r--src/components/StarRating.svelte80
-rw-r--r--src/components/SystemImage.svelte52
-rw-r--r--src/components/VideoPlayer.svelte412
-rw-r--r--src/components/decorators/HlsJSDecorator.svelte67
-rw-r--r--src/components/hero/AppLockupDetail.svelte109
-rw-r--r--src/components/hero/Carousel.svelte132
-rw-r--r--src/components/hero/CarouselBackgroundPortal.svelte17
-rw-r--r--src/components/hero/Hero.svelte536
-rw-r--r--src/components/icons/AppStoreLogo.svg1
-rw-r--r--src/components/icons/AppleArcadeLogo.svg1
-rw-r--r--src/components/jet/Video.svelte66
-rw-r--r--src/components/jet/action/ExternalUrlAction.svelte52
-rw-r--r--src/components/jet/action/FlowAction.svelte41
-rw-r--r--src/components/jet/action/ShelfBasedPageScrollAction.svelte51
-rw-r--r--src/components/jet/badge/ContentRatingBadge.svelte61
-rw-r--r--src/components/jet/item/AccessibilityFeaturesItem.svelte159
-rw-r--r--src/components/jet/item/AccessibilityParagraphItem.svelte22
-rw-r--r--src/components/jet/item/Annotation/AnnotationItem.svelte17
-rw-r--r--src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte146
-rw-r--r--src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte114
-rw-r--r--src/components/jet/item/AppEventItem.svelte176
-rw-r--r--src/components/jet/item/ArcadeFooterItem.svelte83
-rw-r--r--src/components/jet/item/BannerItem.svelte37
-rw-r--r--src/components/jet/item/BrickItem.svelte300
-rw-r--r--src/components/jet/item/ContentModal.svelte39
-rw-r--r--src/components/jet/item/EditorialCardItem.svelte41
-rw-r--r--src/components/jet/item/FooterLockupItem.svelte93
-rw-r--r--src/components/jet/item/HeroCarouselItem.svelte60
-rw-r--r--src/components/jet/item/InAppPurchaseLockup.svelte74
-rw-r--r--src/components/jet/item/LargeBrickItem.svelte106
-rw-r--r--src/components/jet/item/LargeHeroBreakoutItem.svelte268
-rw-r--r--src/components/jet/item/LargeImageLockupItem.svelte130
-rw-r--r--src/components/jet/item/LargeLockupItem.svelte121
-rw-r--r--src/components/jet/item/LargeStoryCardItem.svelte38
-rw-r--r--src/components/jet/item/LinkableTextItem.svelte88
-rw-r--r--src/components/jet/item/MediumImageLockupItem.svelte118
-rw-r--r--src/components/jet/item/MediumLockupItem.svelte96
-rw-r--r--src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte304
-rw-r--r--src/components/jet/item/MediumStoryCardItem.svelte27
-rw-r--r--src/components/jet/item/MixedMediaLockupItem.svelte39
-rw-r--r--src/components/jet/item/ParagraphShelfItem.svelte21
-rw-r--r--src/components/jet/item/PosterLockupItem.svelte121
-rw-r--r--src/components/jet/item/PrivacyHeaderItem.svelte41
-rw-r--r--src/components/jet/item/PrivacyTypeItem.svelte193
-rw-r--r--src/components/jet/item/ProductBadgeItem.svelte188
-rw-r--r--src/components/jet/item/ProductCapabilityItem.svelte84
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte31
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte89
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte142
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte34
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte38
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte50
-rw-r--r--src/components/jet/item/ProductPageLinkItem.svelte68
-rw-r--r--src/components/jet/item/ProductRatingsItem.svelte37
-rw-r--r--src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte99
-rw-r--r--src/components/jet/item/ProductReview/UserReviewItem.svelte25
-rw-r--r--src/components/jet/item/ReviewItem.svelte237
-rw-r--r--src/components/jet/item/SearchLinkItem.svelte47
-rw-r--r--src/components/jet/item/SearchResult/AppSearchResultItem.svelte392
-rw-r--r--src/components/jet/item/SmallBreakoutItem.svelte187
-rw-r--r--src/components/jet/item/SmallLockupItem.svelte110
-rw-r--r--src/components/jet/item/SmallLockupWithOrdinalItem.svelte176
-rw-r--r--src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte69
-rw-r--r--src/components/jet/item/SmallStoryCardWithArtworkItem.svelte87
-rw-r--r--src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte156
-rw-r--r--src/components/jet/item/SmallStoryCardWithMediaItem.svelte104
-rw-r--r--src/components/jet/item/SmallStoryCardWithMediaRiver.svelte118
-rw-r--r--src/components/jet/item/TitledParagraphItem.svelte175
-rw-r--r--src/components/jet/item/TrailersLockupItem.svelte51
-rw-r--r--src/components/jet/marker-shelf/ProductTopLockup.svelte463
-rw-r--r--src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte36
-rw-r--r--src/components/jet/shelf/AccessibilityFeaturesShelf.svelte35
-rw-r--r--src/components/jet/shelf/AccessibilityHeaderShelf.svelte182
-rw-r--r--src/components/jet/shelf/ActionShelf.svelte80
-rw-r--r--src/components/jet/shelf/AnnotationShelf.svelte49
-rw-r--r--src/components/jet/shelf/AppEventDetailShelf.svelte290
-rw-r--r--src/components/jet/shelf/AppPromotionShelf.svelte47
-rw-r--r--src/components/jet/shelf/AppShowcaseShelf.svelte29
-rw-r--r--src/components/jet/shelf/AppTrailerLockupShelf.svelte48
-rw-r--r--src/components/jet/shelf/ArcadeFooterShelf.svelte32
-rw-r--r--src/components/jet/shelf/BannerShelf.svelte35
-rw-r--r--src/components/jet/shelf/BrickShelf.svelte31
-rw-r--r--src/components/jet/shelf/CategoryBrickShelf.svelte28
-rw-r--r--src/components/jet/shelf/EditorialCardShelf.svelte32
-rw-r--r--src/components/jet/shelf/EditorialLinkShelf.svelte122
-rw-r--r--src/components/jet/shelf/FallbackShelf.svelte39
-rw-r--r--src/components/jet/shelf/FramedArtworkShelf.svelte98
-rw-r--r--src/components/jet/shelf/FramedVideoShelf.svelte78
-rw-r--r--src/components/jet/shelf/HeroCarouselShelf.svelte38
-rw-r--r--src/components/jet/shelf/HorizontalRuleShelf.svelte54
-rw-r--r--src/components/jet/shelf/HorizontalShelf.svelte53
-rw-r--r--src/components/jet/shelf/InAppPurchaseLockupShelf.svelte31
-rw-r--r--src/components/jet/shelf/LargeBrickShelf.svelte26
-rw-r--r--src/components/jet/shelf/LargeHeroBreakoutShelf.svelte31
-rw-r--r--src/components/jet/shelf/LargeImageLockupShelf.svelte30
-rw-r--r--src/components/jet/shelf/LargeLockupShelf.svelte28
-rw-r--r--src/components/jet/shelf/LargeStoryCardShelf.svelte32
-rw-r--r--src/components/jet/shelf/LinkableTextShelf.svelte43
-rw-r--r--src/components/jet/shelf/MarkerShelf.svelte36
-rw-r--r--src/components/jet/shelf/MediumImageLockupShelf.svelte28
-rw-r--r--src/components/jet/shelf/MediumLockupShelf.svelte31
-rw-r--r--src/components/jet/shelf/MediumStoryCardShelf.svelte31
-rw-r--r--src/components/jet/shelf/PageHeaderShelf.svelte34
-rw-r--r--src/components/jet/shelf/ParagraphShelf.svelte52
-rw-r--r--src/components/jet/shelf/PosterLockupShelf.svelte31
-rw-r--r--src/components/jet/shelf/PrivacyFooterShelf.svelte40
-rw-r--r--src/components/jet/shelf/PrivacyHeaderShelf.svelte145
-rw-r--r--src/components/jet/shelf/PrivacyTypeShelf.svelte29
-rw-r--r--src/components/jet/shelf/ProductBadgeShelf.svelte59
-rw-r--r--src/components/jet/shelf/ProductCapabilityShelf.svelte31
-rw-r--r--src/components/jet/shelf/ProductDescriptionShelf.svelte95
-rw-r--r--src/components/jet/shelf/ProductMediaShelf.svelte269
-rw-r--r--src/components/jet/shelf/ProductPageLinkShelf.svelte59
-rw-r--r--src/components/jet/shelf/ProductRatingsShelf.svelte29
-rw-r--r--src/components/jet/shelf/ProductReviewShelf.svelte38
-rw-r--r--src/components/jet/shelf/QuoteShelf.svelte80
-rw-r--r--src/components/jet/shelf/ReviewsContainerShelf.svelte84
-rw-r--r--src/components/jet/shelf/ReviewsShelf.svelte28
-rw-r--r--src/components/jet/shelf/RibbonBarShelf.svelte135
-rw-r--r--src/components/jet/shelf/SearchLinkShelf.svelte26
-rw-r--r--src/components/jet/shelf/SearchResultShelf.svelte49
-rw-r--r--src/components/jet/shelf/Shelf.svelte320
-rw-r--r--src/components/jet/shelf/SmallBreakoutShelf.svelte32
-rw-r--r--src/components/jet/shelf/SmallBrickShelf.svelte26
-rw-r--r--src/components/jet/shelf/SmallLockupShelf.svelte54
-rw-r--r--src/components/jet/shelf/SmallStoryCardShelf.svelte66
-rw-r--r--src/components/jet/shelf/TitledParagraphShelf.svelte118
-rw-r--r--src/components/jet/shelf/TodayCardShelf.svelte187
-rw-r--r--src/components/jet/shelf/UberShelf.svelte40
-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
-rw-r--r--src/components/jet/web-navigation/CategoryTabItem.svelte67
-rw-r--r--src/components/jet/web-navigation/PlatformSelectorDropdown.svelte88
-rw-r--r--src/components/jet/web-navigation/PlatformSelectorItem.svelte97
-rw-r--r--src/components/navigation/Navigation.svelte423
-rw-r--r--src/components/navigation/SearchInput.svelte82
-rw-r--r--src/components/navigation/Skeleton.svelte85
-rw-r--r--src/components/navigation/navigation-items.ts79
-rw-r--r--src/components/pages/AppEventDetailPage.svelte44
-rw-r--r--src/components/pages/ArticlePage.svelte141
-rw-r--r--src/components/pages/ChartsHubPage.svelte11
-rw-r--r--src/components/pages/DefaultPage.svelte173
-rw-r--r--src/components/pages/ErrorPage.svelte23
-rw-r--r--src/components/pages/ProductPage.svelte77
-rw-r--r--src/components/pages/SearchLandingPage.svelte33
-rw-r--r--src/components/pages/SearchResultsPage.svelte113
-rw-r--r--src/components/pages/SeeAllPage.svelte56
-rw-r--r--src/components/pages/StaticMessagePage.svelte113
-rw-r--r--src/components/pages/TodayPage.svelte22
-rw-r--r--src/components/pages/TopChartsPage.svelte218
-rw-r--r--src/components/pages/VisionProPage.svelte12
-rw-r--r--src/components/structure/Fonts.svelte19
-rw-r--r--src/components/structure/Footer.svelte47
-rw-r--r--src/components/structure/MetaTags.svelte68
-rw-r--r--src/components/structure/VisionProFooter.svelte142
-rw-r--r--src/config/build.ts1
-rw-r--r--src/config/components/artwork.ts163
-rw-r--r--src/config/components/shelf.ts208
-rw-r--r--src/config/errorkit.ts17
-rw-r--r--src/config/hlsjs.ts25
-rw-r--r--src/config/media-api/browser.ts1
-rw-r--r--src/config/media-api/search-jwt.ts27
-rw-r--r--src/config/metrics.ts17
-rw-r--r--src/config/rtcjs.ts103
-rw-r--r--src/constants/footer-items.ts24
-rw-r--r--src/constants/media-metrics.ts18
-rw-r--r--src/constants/storefront.ts60
-rw-r--r--src/context/accessibility-layout.ts93
-rw-r--r--src/context/today-card-layout.ts98
-rw-r--r--src/jet/action-handlers/browser.ts16
-rw-r--r--src/jet/action-handlers/compound-action.ts33
-rw-r--r--src/jet/action-handlers/external-url-action.ts19
-rw-r--r--src/jet/action-handlers/flow-action.ts369
-rw-r--r--src/jet/bootstrap.ts125
-rw-r--r--src/jet/dependencies/bag.ts290
-rw-r--r--src/jet/dependencies/client.ts96
-rw-r--r--src/jet/dependencies/console.ts26
-rw-r--r--src/jet/dependencies/feature-flags.ts20
-rw-r--r--src/jet/dependencies/locale.ts99
-rw-r--r--src/jet/dependencies/localization.ts523
-rw-r--r--src/jet/dependencies/make-dependencies.ts45
-rw-r--r--src/jet/dependencies/media-token-service.ts11
-rw-r--r--src/jet/dependencies/metrics-identifiers.ts13
-rw-r--r--src/jet/dependencies/net.ts117
-rw-r--r--src/jet/dependencies/object-graph.ts59
-rw-r--r--src/jet/dependencies/properties.ts5
-rw-r--r--src/jet/dependencies/seo.ts254
-rw-r--r--src/jet/dependencies/storage.ts44
-rw-r--r--src/jet/dependencies/user.ts30
-rw-r--r--src/jet/intents/charts-page-redirect-intent-controller.ts68
-rw-r--r--src/jet/intents/error-page-intent-controller.ts52
-rw-r--r--src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts18
-rw-r--r--src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts23
-rw-r--r--src/jet/intents/route-url/route-url-controller.ts28
-rw-r--r--src/jet/intents/route-url/route-url-intent.ts48
-rw-r--r--src/jet/intents/static-message-pages/carrier-page-intent-controller.ts41
-rw-r--r--src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts49
-rw-r--r--src/jet/intents/static-message-pages/invoice-page-intent-controller.ts41
-rw-r--r--src/jet/intents/static-message-pages/win-back-page-intent-controller.ts49
-rw-r--r--src/jet/jet.ts320
-rw-r--r--src/jet/metrics/providers/StorefrontFieldsProvider.ts19
-rw-r--r--src/jet/metrics/providers/index.ts15
-rw-r--r--src/jet/metrics/settings.ts20
-rw-r--r--src/jet/models/error-page.ts15
-rw-r--r--src/jet/models/external-action.ts7
-rw-r--r--src/jet/models/flow-action.ts28
-rw-r--r--src/jet/models/page.ts177
-rw-r--r--src/jet/models/static-message-page.ts33
-rw-r--r--src/jet/svelte.ts45
-rw-r--r--src/jet/utils/app-event-formatted-date.ts194
-rw-r--r--src/jet/utils/error-metadata.ts16
-rw-r--r--src/jet/utils/handle-modal-presentation.ts29
-rw-r--r--src/jet/utils/with-platform.ts5
-rw-r--r--src/sf-symbols/AgeRating-AU-15.svg1
-rw-r--r--src/sf-symbols/AgeRating-AU-18.svg1
-rw-r--r--src/sf-symbols/accessibility.svg1
-rw-r--r--src/sf-symbols/app.3.stack.3d.fill.svg1
-rw-r--r--src/sf-symbols/app.3.stack.3d.svg1
-rw-r--r--src/sf-symbols/appearance.darkmode.svg1
-rw-r--r--src/sf-symbols/applewatch.svg1
-rw-r--r--src/sf-symbols/appstore-ribbon-bar-fallback-icon.svg1
-rw-r--r--src/sf-symbols/appstore.svg1
-rw-r--r--src/sf-symbols/arkit.svg1
-rw-r--r--src/sf-symbols/bag.fill.svg1
-rw-r--r--src/sf-symbols/br.10.official.svg1
-rw-r--r--src/sf-symbols/br.10.svg1
-rw-r--r--src/sf-symbols/br.12.official.svg1
-rw-r--r--src/sf-symbols/br.12.svg1
-rw-r--r--src/sf-symbols/br.14.official.svg1
-rw-r--r--src/sf-symbols/br.14.svg1
-rw-r--r--src/sf-symbols/br.16.official.svg1
-rw-r--r--src/sf-symbols/br.16.svg1
-rw-r--r--src/sf-symbols/br.18.official.svg1
-rw-r--r--src/sf-symbols/br.18.svg1
-rw-r--r--src/sf-symbols/br.l.official.svg1
-rw-r--r--src/sf-symbols/br.l.svg1
-rw-r--r--src/sf-symbols/captions.bubble.fill.svg1
-rw-r--r--src/sf-symbols/chart.bar.fill.svg1
-rw-r--r--src/sf-symbols/checkmark.circle.svg1
-rw-r--r--src/sf-symbols/checkmark.svg1
-rw-r--r--src/sf-symbols/chevron.down.svg1
-rw-r--r--src/sf-symbols/chevron.forward.svg1
-rw-r--r--src/sf-symbols/chevron.right.svg1
-rw-r--r--src/sf-symbols/circle.dotted.and.circle.svg1
-rw-r--r--src/sf-symbols/circle.lefthalf.filled.inverse.svg1
-rw-r--r--src/sf-symbols/clock.fill.svg1
-rw-r--r--src/sf-symbols/creditcard.fill.svg1
-rw-r--r--src/sf-symbols/ellipsis.circle.fill.svg1
-rw-r--r--src/sf-symbols/eye.fill.svg1
-rw-r--r--src/sf-symbols/figure.svg1
-rw-r--r--src/sf-symbols/gamecontroller.fill.svg1
-rw-r--r--src/sf-symbols/gearshape.fill.svg1
-rw-r--r--src/sf-symbols/hammer.fill.svg1
-rw-r--r--src/sf-symbols/hammer.svg1
-rw-r--r--src/sf-symbols/heart.circle.fill.svg1
-rw-r--r--src/sf-symbols/house.svg1
-rw-r--r--src/sf-symbols/info.circle.fill.svg1
-rw-r--r--src/sf-symbols/ipad.gen2.landscape.svg1
-rw-r--r--src/sf-symbols/ipad.gen2.svg1
-rw-r--r--src/sf-symbols/iphone.gen2.svg1
-rw-r--r--src/sf-symbols/joystickcontroller.fill.svg1
-rw-r--r--src/sf-symbols/joystickcontroller.svg1
-rw-r--r--src/sf-symbols/kr.12.svg1
-rw-r--r--src/sf-symbols/kr.15.svg1
-rw-r--r--src/sf-symbols/kr.all.svg1
-rw-r--r--src/sf-symbols/laurel.leading.svg1
-rw-r--r--src/sf-symbols/laurel.left.svg1
-rw-r--r--src/sf-symbols/laurel.trailing.svg1
-rw-r--r--src/sf-symbols/line.3.horizontal.svg1
-rw-r--r--src/sf-symbols/location.fill.svg1
-rw-r--r--src/sf-symbols/macbook.gen2.svg1
-rw-r--r--src/sf-symbols/magnifyingglass.circle.fill.svg1
-rw-r--r--src/sf-symbols/magnifyingglass.svg1
-rw-r--r--src/sf-symbols/message.svg1
-rw-r--r--src/sf-symbols/paintbrush.fill.svg1
-rw-r--r--src/sf-symbols/paintbrush.svg1
-rw-r--r--src/sf-symbols/paperplane.fill.svg1
-rw-r--r--src/sf-symbols/paperplane.svg1
-rw-r--r--src/sf-symbols/person.circle.slash.svg1
-rw-r--r--src/sf-symbols/person.circle.svg1
-rw-r--r--src/sf-symbols/person.crop.rectangle.line.fill.svg1
-rw-r--r--src/sf-symbols/person.crop.square.svg1
-rw-r--r--src/sf-symbols/person.fill.viewfinder.svg1
-rw-r--r--src/sf-symbols/photo.fill.on.rectangle.fill.svg1
-rw-r--r--src/sf-symbols/plus.heavy.svg1
-rw-r--r--src/sf-symbols/quote.bubble.fill.svg1
-rw-r--r--src/sf-symbols/rocket.fill.svg1
-rw-r--r--src/sf-symbols/rocket.svg1
-rw-r--r--src/sf-symbols/square.and.arrow.up.svg1
-rw-r--r--src/sf-symbols/square.grid.2x2.fill.svg1
-rw-r--r--src/sf-symbols/square.grid.2x2.svg1
-rw-r--r--src/sf-symbols/star.fill.svg1
-rw-r--r--src/sf-symbols/star.svg1
-rw-r--r--src/sf-symbols/text.rectangle.page.fill.svg1
-rw-r--r--src/sf-symbols/text.rectangle.page.svg1
-rw-r--r--src/sf-symbols/textformat.size.svg1
-rw-r--r--src/sf-symbols/tv.svg1
-rw-r--r--src/sf-symbols/visionpro.svg1
-rw-r--r--src/sf-symbols/voice.control.svg1
-rw-r--r--src/sf-symbols/voiceover.svg1
-rw-r--r--src/sf-symbols/xmark.svg1
-rw-r--r--src/sf-symbols/xmark.triangle.circle.square.fill.svg1
-rw-r--r--src/stores/carousel-media-style.ts5
-rw-r--r--src/stores/i18n.ts73
-rw-r--r--src/stores/modalPage.ts35
-rw-r--r--src/utils/app-platforms.ts25
-rw-r--r--src/utils/array.ts33
-rw-r--r--src/utils/color.ts168
-rw-r--r--src/utils/error.ts28
-rw-r--r--src/utils/features/consts.ts13
-rw-r--r--src/utils/features/runtime.ts44
-rw-r--r--src/utils/file-size.ts23
-rw-r--r--src/utils/launch-client.ts13
-rw-r--r--src/utils/locale.ts142
-rw-r--r--src/utils/media-queries.ts12
-rw-r--r--src/utils/metrics.ts4
-rw-r--r--src/utils/number-formatting.ts39
-rw-r--r--src/utils/portal.ts34
-rw-r--r--src/utils/seo/app-event-detail-page.ts43
-rw-r--r--src/utils/seo/arcade-see-all-page.ts40
-rw-r--r--src/utils/seo/article-page.ts276
-rw-r--r--src/utils/seo/charts-hub-page.ts46
-rw-r--r--src/utils/seo/charts-page.ts58
-rw-r--r--src/utils/seo/common.ts75
-rw-r--r--src/utils/seo/developer-page.ts174
-rw-r--r--src/utils/seo/editorial-shelf-collection-page.ts51
-rw-r--r--src/utils/seo/image-url.ts71
-rw-r--r--src/utils/seo/product-page.ts353
-rw-r--r--src/utils/seo/reviews-page.ts56
-rw-r--r--src/utils/seo/search-landing-page.ts18
-rw-r--r--src/utils/seo/search-results-page.ts56
-rw-r--r--src/utils/seo/see-all-page.ts47
-rw-r--r--src/utils/shelves.ts56
-rw-r--r--src/utils/storefront-data.ts15
-rw-r--r--src/utils/string-formatting.ts126
-rw-r--r--src/utils/transition.ts45
-rw-r--r--src/utils/types.ts17
-rw-r--r--src/utils/url.ts13
-rw-r--r--src/utils/video-poster.ts27
377 files changed, 24769 insertions, 0 deletions
diff --git a/src/App.svelte b/src/App.svelte
new file mode 100644
index 0000000..846f1df
--- /dev/null
+++ b/src/App.svelte
@@ -0,0 +1,161 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+
+ import { BUILD } from '~/config/build';
+ import { getJet } from '~/jet';
+ import { makeErrorPageIntent } from '~/jet/intents/error-page-intent-controller';
+ import { getLocale } from '~/utils/locale';
+
+ // Types
+ import type { Page } from './jet/models/page';
+
+ // Components
+ import Fonts from '~/components/structure/Fonts.svelte';
+ import Footer from '~/components/structure/Footer.svelte';
+ import Navigation from '~/components/navigation/Navigation.svelte';
+ import NavigationSkeleton from '~/components/navigation/Skeleton.svelte';
+ import PageResolver from '~/components/PageResolver.svelte';
+
+ const locale = getLocale();
+ const jet = getJet();
+
+ $: language = locale.language;
+
+ export let page: Promise<Page> | Page = new Promise(() => {});
+ export let isFirstPage: boolean = true;
+
+ $: pageWithRejectionErrorPage = transformRejectionIntoErrorPage(page);
+
+ // Critically, this function is not async. We want to preserve the behavior
+ // where if page is not a promise than neither is
+ // pageWithRejectionErrorPage.
+ function transformRejectionIntoErrorPage(
+ page: Promise<Page> | Page,
+ ): Promise<Page> | Page {
+ if (!(page instanceof Promise)) {
+ return page;
+ }
+
+ // The async IIFE allows this function to return synchronously.
+ return (async (): Promise<Page> => {
+ try {
+ return await page;
+ } catch (error) {
+ return jet.dispatch(
+ makeErrorPageIntent({
+ // This allows the error page to pick the right platform
+ // and display the correct mesage (ex. "Page not found" for
+ // a 404)
+ error: error instanceof Error ? error : null,
+ }),
+ );
+ }
+ })();
+ }
+
+ // NOTE: The use of page instead of pageWithRejectionErrorPage here is very
+ // intentional. Since pageWithRejectionErrorPage is reactive, it will
+ // be undefined in this initializer. This is intentionally not
+ // not derived (eg. defined as $: webNavigation = ...), since we only
+ // want to update it _after_ the page promise resolves (so the nav
+ // doesn't disappear on navigation). But then for SSR, there are no
+ // promises, so we need a sync value here so the nav renders, which
+ // is why we have the initializer.
+ let webNavigation = page instanceof Promise ? null : page.webNavigation;
+ $: {
+ if (pageWithRejectionErrorPage instanceof Promise) {
+ // Clientside once the new page resolves, update the navigation
+ // (in case it changed)
+ pageWithRejectionErrorPage.then((page: Page) => {
+ webNavigation = page.webNavigation;
+ });
+ } else {
+ // Sometimes clientside a promise is not passed to updateApp, so
+ // we need to handle a WebRenderablePage (possible with a
+ // different webNavigation).
+ webNavigation = pageWithRejectionErrorPage.webNavigation;
+ }
+ }
+
+ onMount(() => {
+ //@ts-ignore
+ window.__ASOTW = {
+ version: BUILD,
+ };
+ });
+</script>
+
+<svelte:head>
+ <meta name="version" content={BUILD} />
+</svelte:head>
+
+<Fonts {language} />
+
+{#if import.meta.env.DEV}
+ {#await import('~/components/ArtworkBreakpointLogger.svelte') then { default: ArtworkBreakpointLogger }}
+ <ArtworkBreakpointLogger />
+ {/await}
+{/if}
+
+<div class="app-container" data-testid="app-container">
+ <div class="navigation-container">
+ {#if webNavigation}
+ <Navigation {webNavigation} />
+ {:else}
+ <NavigationSkeleton />
+ {/if}
+ </div>
+
+ <div
+ style="display: flex;
+ position: relative;
+ flex-direction: column;
+ min-height: 100vh;
+ "
+ >
+ <main class="page-container">
+ <PageResolver page={pageWithRejectionErrorPage} {isFirstPage} />
+ </main>
+
+ <Footer />
+ </div>
+</div>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use '@amp/web-shared-styles/app/core/viewports' as *;
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ .app-container {
+ min-height: 100vh;
+ min-height: 100dvh;
+ display: grid;
+ grid-template-areas:
+ 'structure-header'
+ 'structure-main-section';
+ grid-template-columns: minmax(0, 1fr);
+ grid-gap: 0;
+ grid-template-rows: 44px auto;
+
+ @media (--sidebar-visible) {
+ grid-template-rows: auto;
+ grid-template-columns: 260px minmax(0, 1fr);
+ }
+
+ @media (--sidebar-large-visible) {
+ grid-template-columns: $global-sidebar-width-large minmax(0, 1fr);
+ }
+ }
+
+ .navigation-container {
+ @media (--range-small-up) {
+ height: 100vh;
+ position: sticky;
+ top: 0;
+ }
+ }
+
+ .page-container {
+ flex-grow: 1;
+ }
+</style>
diff --git a/src/bootstrap.ts b/src/bootstrap.ts
new file mode 100644
index 0000000..14ebbe8
--- /dev/null
+++ b/src/bootstrap.ts
@@ -0,0 +1,97 @@
+// Sets up app specific configurations
+import type { Opt } from '@jet/environment';
+import type { Intent } from '@jet/environment/dispatching';
+import type { ActionModel } from '@jet/environment/types/models';
+import { initializeUniqueIdContext } from '@amp/web-app-components/src/utils/uniqueId';
+import { setLocale as setSharedLocale } from '@amp/web-app-components/src/utils/locale';
+
+import type {
+ NormalizedStorefront,
+ NormalizedLanguage,
+} from '@jet-app/app-store/api/locale';
+
+import {
+ DEFAULT_STOREFRONT_CODE,
+ DEFAULT_LANGUAGE_BCP47,
+} from '~/constants/storefront';
+import { Jet } from '~/jet';
+import { setup as setupI18n } from '~/stores/i18n';
+import type { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
+import type { LoggerFactory } from '@amp/web-apps-logger';
+import type { Locale as Language } from '@amp/web-apps-localization';
+import type I18N from '@amp/web-apps-localization';
+import '~/config/components/artwork';
+import '~/config/components/shelf';
+import type { FeaturesCallbacks } from './jet/dependencies/net';
+
+export type Context = Map<string, unknown>;
+
+export async function bootstrap({
+ loggerFactory,
+ initialUrl,
+ fetch,
+ prefetchedIntents,
+ featuresCallbacks,
+}: {
+ loggerFactory: LoggerFactory;
+ initialUrl: string;
+ fetch: typeof window.fetch;
+ prefetchedIntents: PrefetchedIntents;
+ featuresCallbacks?: FeaturesCallbacks;
+}): Promise<{
+ context: Context;
+ jet: Jet;
+ initialAction: Opt<ActionModel>;
+ intent: Opt<Intent<unknown>>;
+ storefront: NormalizedStorefront;
+ language: NormalizedLanguage;
+ i18n: I18N;
+}> {
+ const log = loggerFactory.loggerFor('bootstrap');
+
+ const context = new Map();
+
+ const jet = Jet.load({
+ loggerFactory,
+ context,
+ fetch,
+ prefetchedIntents,
+ featuresCallbacks,
+ });
+
+ initializeUniqueIdContext(context, loggerFactory);
+
+ const routing = await jet.routeUrl(initialUrl);
+
+ if (routing) {
+ log.info('initial URL routed to:', routing);
+ } else {
+ log.warn('initial URL was unroutable:', initialUrl);
+ }
+
+ const {
+ intent = null,
+ action: initialAction = null,
+ storefront = DEFAULT_STOREFRONT_CODE,
+ language = DEFAULT_LANGUAGE_BCP47,
+ } = routing || {};
+
+ // TODO: rdar://78109398 (i18n Improvements)
+ const i18nStore = await setupI18n(
+ context,
+ loggerFactory,
+ language.toLowerCase() as Language,
+ );
+ jet.setLocale(i18nStore, storefront, language);
+ setSharedLocale(context, { storefront, language });
+
+ return {
+ context,
+ jet,
+ initialAction,
+ intent,
+ storefront,
+ language,
+ i18n: i18nStore,
+ };
+}
diff --git a/src/browser.ts b/src/browser.ts
new file mode 100644
index 0000000..18c20f7
--- /dev/null
+++ b/src/browser.ts
@@ -0,0 +1,100 @@
+// This must be imported first to ensure base styles are imported first
+import '~/styles/app-store.scss';
+
+import App from '~/App.svelte';
+import { bootstrap } from '~/bootstrap';
+import { registerActionHandlers } from '~/jet/action-handlers';
+import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
+import {
+ CompositeLoggerFactory,
+ ConsoleLoggerFactory,
+ DeferredLoggerFactory,
+ setContext,
+} from '@amp/web-apps-logger';
+
+import { setHTMLAttributes } from '@amp/web-apps-localization';
+import { ERROR_KIT_CONFIG } from '~/config/errorkit';
+import {
+ ErrorKitLoggerFactory,
+ setupErrorKit,
+} from '@amp/web-apps-logger/src/errorkit';
+import { setupRuntimeFeatures } from '~/utils/features/runtime';
+
+export async function startApplication() {
+ const onyxFeatures = await setupRuntimeFeatures(
+ new DeferredLoggerFactory(() => logger),
+ );
+ const consoleLogger = new ConsoleLoggerFactory();
+ const errorKit = setupErrorKit(ERROR_KIT_CONFIG, consoleLogger);
+ const logger = new CompositeLoggerFactory([
+ consoleLogger,
+ new ErrorKitLoggerFactory(errorKit),
+ ...(onyxFeatures ? [onyxFeatures.recordingLogger] : []),
+ ]);
+
+ let url = window.location.href;
+
+ // TODO: this is busted for some reason? rdar://111465791 ([Onyx] Foundation - PerfKit)
+ // const perfkit = setupBrowserPerfkit(PERF_KIT_CONFIG, logger);
+
+ // Initialize Jet, and get starting state.
+ const { context, jet, initialAction, storefront, language, i18n } =
+ await bootstrap({
+ loggerFactory: logger,
+ initialUrl: url,
+ fetch: window.fetch.bind(window),
+ prefetchedIntents: PrefetchedIntents.fromDom(logger, {
+ evenIfSignedIn: true,
+ featureKitItfe: onyxFeatures?.featureKit?.itfe,
+ }),
+ featuresCallbacks: {
+ getITFEValues(): string | undefined {
+ return onyxFeatures?.featureKit?.itfe;
+ },
+ },
+ });
+
+ // TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit)
+ // setPageSpeedContext(context, perfkit, logger);
+ setContext(context, logger);
+
+ // Add lang + dir tag to HTML node
+ setHTMLAttributes(language);
+
+ // Using a container element to avoid svelte hydration
+ // "clean up" from removing tags that have
+ // been add to the <body> tag in our HTML file.
+ const container = document.querySelector('.body-container');
+
+ const app = new App({
+ target: container,
+ context,
+ hydrate: true,
+ });
+
+ // Initialize action-handlers.
+ registerActionHandlers({
+ jet,
+ logger,
+ updateApp: (props) => app.$set(props),
+ });
+
+ if (initialAction) {
+ // TODO: rdar://73165545 (Error Handling Across App): handle throw
+ await jet.perform(initialAction);
+ } else {
+ app.$set({
+ page: Promise.reject(new Error('404')),
+ isFirstPage: true,
+ });
+ }
+}
+
+// If we export default here, this will run during tests when we do
+// `import { startApplication } from '~/browser';`. To avoid this, we guard using the
+// presence of an ENV var only set by Vitest.
+
+// This is covered by acceptance tests
+if (!import.meta.env?.VITEST) {
+ startApplication();
+}
diff --git a/src/components/AmbientBackgroundArtwork.svelte b/src/components/AmbientBackgroundArtwork.svelte
new file mode 100644
index 0000000..bc9563c
--- /dev/null
+++ b/src/components/AmbientBackgroundArtwork.svelte
@@ -0,0 +1,202 @@
+<script lang="ts">
+ import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import ResizeDetector from '@amp/web-app-components/src/components/helpers/ResizeDetector.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let artwork: JetArtworkType;
+ export let active: boolean = false;
+
+ $: isBackgroundImageLoaded = false;
+ $: backgroundImage = artwork
+ ? buildSrc(
+ artwork.template,
+ {
+ crop: 'sr',
+ width: 400,
+ height: Math.floor(400 / 1.6667),
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+
+ $: if (backgroundImage) {
+ const img = new Image();
+ img.onload = () => (isBackgroundImageLoaded = true);
+ img.src = backgroundImage;
+ }
+
+ let resizing = false;
+ const handleResizeUpdate = (e: CustomEvent<{ isResizing: boolean }>) =>
+ (resizing = e.detail.isResizing);
+
+ let isOutOfView = true;
+ const handleIntersectionOberserverUpdate = (
+ isIntersectingViewport: boolean,
+ ) => (isOutOfView = !isIntersectingViewport);
+</script>
+
+{#if backgroundImage}
+ <ResizeDetector on:resizeUpdate={handleResizeUpdate} />
+
+ <div
+ class="container"
+ class:active
+ class:resizing
+ class:loaded={isBackgroundImageLoaded}
+ class:out-of-view={isOutOfView}
+ style:--background-image={`url(${backgroundImage})`}
+ style:--background-color={artwork.backgroundColor &&
+ colorAsString(artwork.backgroundColor)}
+ use:intersectionObserver={{
+ callback: handleIntersectionOberserverUpdate,
+ threshold: 0,
+ }}
+ >
+ <div class="overlay" />
+ </div>
+{/if}
+
+<style>
+ .container {
+ --veil: rgb(240, 240, 240, 0.65);
+ --speed: 0.66s;
+ --aspect-ratio: 16/9;
+ --scale: 1.2;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ aspect-ratio: var(--aspect-ratio);
+ max-height: 900px;
+ opacity: 0;
+
+ /*
+ This stack of background images represents the following three layers, listed front-to-back:
+
+ 1) A gradient from transparent to white that acts as a mask for the entire container.
+ `mask-image` caused too much thrashing and CPU usage when animating and resizing,
+ so we are mimicking its functionality with this top-layer background image.
+ 2) A semi-transparent veil to evenly fade out the bg. Note that this is not technically
+ a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
+ used in `background-image`.
+ 3) The joe color of the background image that will eventualy be loaded.
+ */
+ background-image: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0) 50%,
+ var(--pageBg) 80%
+ ),
+ linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
+ linear-gradient(
+ 0deg,
+ var(--background-color) 0%,
+ var(--background-color) 80%
+ );
+ background-position: center;
+ background-size: 120%;
+
+ /*
+ Blurring via the CSS filter does not extend edge-to-edge of the contents width, but we
+ can mitigate that by ever-so-slightly bumping up the `scale` of content so it bleeds off
+ the page cleanly.
+ */
+ filter: blur(20px) saturate(1.3);
+ transform: scale(var(--scale));
+ transition: opacity calc(var(--speed) * 2) ease-out,
+ background-size var(--speed) ease-in;
+
+ @media (prefers-color-scheme: dark) {
+ --veil: rgba(0, 0, 0, 0.5);
+ }
+ }
+
+ .container.loaded {
+ /*
+ This stack of background images represents the following three layers, listed front-to-back:
+
+ 1) A gradient from transparent to white that acts as a mask for the entire container.
+ `mask-image` caused too much thrashing and CPU usage when animating and resizing,
+ so we are mimicking its functionality with this top-layer background image.
+ 2) A semi-transparent veil to evenly fade out the image. Note that this is not technically
+ a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
+ used in `background-image`.
+ 3) The actual background image.
+ */
+ background-image: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0) 50%,
+ var(--pageBg) 80%
+ ),
+ linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
+ var(--background-image);
+ }
+
+ .container.active {
+ opacity: 1;
+ transition: opacity calc(var(--speed) / 2) ease-in;
+ background-size: 100%;
+ }
+
+ .overlay {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ left: 0;
+ width: 100%;
+ aspect-ratio: var(--aspect-ratio);
+ max-height: 900px;
+ opacity: 0;
+ background-image: var(--background-image);
+ background-position: 100% 100%;
+ background-size: 250%;
+ filter: brightness(1.3) saturate(0);
+ mix-blend-mode: overlay;
+ will-change: opacity, background-position;
+ animation: shift-background 60s infinite linear alternate;
+ animation-play-state: paused;
+ transition: opacity var(--speed) ease-in;
+ }
+
+ .active .overlay {
+ opacity: 0.3;
+ animation-play-state: running;
+ transition: opacity calc(var(--speed) * 2) ease-in
+ calc(var(--speed) * 2);
+ }
+
+ .active.out-of-view .overlay,
+ .active.resizing .overlay {
+ animation-play-state: paused;
+ opacity: 0;
+ }
+
+ @keyframes shift-background {
+ 0% {
+ background-position: 0% 50%;
+ background-size: 250%;
+ }
+
+ 25% {
+ background-position: 60% 20%;
+ background-size: 300%;
+ }
+
+ 50% {
+ background-position: 100% 50%;
+ background-size: 320%;
+ }
+
+ 75% {
+ background-position: 40% 100%;
+ background-size: 220%;
+ }
+
+ 100% {
+ background-position: 20% 50%;
+ background-size: 300%;
+ }
+ }
+</style>
diff --git a/src/components/AppEventDate.svelte b/src/components/AppEventDate.svelte
new file mode 100644
index 0000000..41ee248
--- /dev/null
+++ b/src/components/AppEventDate.svelte
@@ -0,0 +1,72 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { fade } from 'svelte/transition';
+ import type { Optional } from '@jet/environment/types/optional';
+ import type { AppEvent } from '@jet-app/app-store/api/models';
+ import { getJet } from '~/jet';
+ import {
+ chooseAppEventDate,
+ renderDate,
+ computeAppEventFormattedDates,
+ type RequiredAppEventFormattedDate,
+ } from '~/jet/utils/app-event-formatted-date';
+
+ const jet = getJet();
+
+ /**
+ * New pattern (*prefered*): accept appEvent object and compute formattedDates on client-side.
+ * This avoids timezone differences in SSR server (UTC) which cause incorrect event date and time.
+ * By computing dates in the browser, we ensure the user sees dates in their local timezone.
+ */
+ export let appEvent:
+ | Pick<AppEvent, 'appEventBadgeKind' | 'startDate' | 'endDate'>
+ | undefined = undefined;
+
+ // Legacy pattern: accept pre-computed formattedDates from Jet
+ export let formattedDates: RequiredAppEventFormattedDate[] | undefined =
+ undefined;
+
+ let appEventDate: Optional<RequiredAppEventFormattedDate>;
+
+ onMount(() => {
+ const dates = appEvent
+ ? computeAppEventFormattedDates(
+ jet.objectGraph,
+ appEvent.appEventBadgeKind,
+ appEvent.startDate,
+ appEvent.endDate,
+ )
+ : formattedDates;
+
+ if (dates) {
+ appEventDate = chooseAppEventDate(dates);
+ }
+ });
+
+ /**
+ * `Date` instances in the view-model will have been serialized to `string`
+ * instances by ServerKit when delivered to the client; we need to normalize
+ * this so that we have a `string` both client- and server-side.
+ */
+ function normalizeDate(date: Date | string): string {
+ return typeof date === 'string' ? date : date.toISOString();
+ }
+</script>
+
+{#if appEventDate}
+ <time
+ transition:fade={{ duration: 210 }}
+ datetime={appEventDate.displayFromDate &&
+ normalizeDate(appEventDate.displayFromDate)}
+ >
+ {renderDate(jet.objectGraph.loc, appEventDate)}
+ </time>
+{:else}
+ <span aria-hidden="true">&hellip;</span>
+{/if}
+
+<style>
+ span {
+ color: transparent;
+ }
+</style>
diff --git a/src/components/AppIcon.svelte b/src/components/AppIcon.svelte
new file mode 100644
index 0000000..4cb0262
--- /dev/null
+++ b/src/components/AppIcon.svelte
@@ -0,0 +1,131 @@
+<script lang="ts" context="module">
+ import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
+ import type { NamedProfile } from '~/config/components/artwork';
+
+ export type AppIconProfile = Extract<
+ NamedProfile,
+ | 'app-icon'
+ | 'app-icon-large'
+ | 'app-icon-medium'
+ | 'app-icon-small'
+ | 'app-icon-xlarge'
+ | 'app-icon-river'
+ | 'brick-app-icon'
+ >;
+
+ export function doesAppIconNeedBorder(icon: JetArtworkType): boolean {
+ const doesIconHaveTransparentBackground =
+ icon.backgroundColor &&
+ isNamedColor(icon.backgroundColor) &&
+ icon.backgroundColor.name === 'clear';
+ const isIconPrerendered =
+ icon.style === 'roundedRectPrerendered' ||
+ icon.style === 'roundPrerendered';
+ const isIconUnadorned = icon.style === 'unadorned';
+
+ return (
+ !doesIconHaveTransparentBackground &&
+ !isIconPrerendered &&
+ !isIconUnadorned
+ );
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
+ import { isNamedColor } from '~/utils/color';
+
+ export let icon: JetArtworkType;
+ export let profile: AppIconProfile = 'app-icon';
+ export let fixedWidth: boolean = true;
+ export let disableAutoCenter: boolean = false;
+ export let withBorder: boolean = false;
+
+ const profiles = ArtworkConfig.get().PROFILES;
+
+ $: computedProfile = (
+ icon.style === 'pill'
+ ? `${profile}-pill`
+ : icon.style === 'tvRect'
+ ? `${profile}-tv-rect`
+ : profile
+ ) as NamedProfile;
+ $: widthFromProfile = profiles?.get(computedProfile)?.[0] ?? 0;
+ $: hasTransparentBackground =
+ !!icon.backgroundColor &&
+ isNamedColor(icon.backgroundColor) &&
+ icon.backgroundColor.name === 'clear';
+ $: needsBorder = withBorder || doesAppIconNeedBorder(icon);
+
+ // These prerendered "Solarium" icons need to use higher than normal quality due to how their
+ // rendering pipeline downscales/transforms sources.
+ $: quality =
+ icon.style &&
+ ['roundedRectPrerendered', 'roundPrerendered'].includes(icon.style)
+ ? 75
+ : undefined;
+</script>
+
+<div
+ class="app-icon"
+ class:pill={icon.style === 'pill'}
+ class:round={icon.style === 'round'}
+ class:rounded-rect={icon.style === 'roundedRect'}
+ class:tv-rect={icon.style === 'tvRect'}
+ class:rounded-rect-prerendered={icon.style === 'roundedRectPrerendered'}
+ class:round-prerendered={icon.style === 'roundPrerendered'}
+ class:with-border={needsBorder}
+ style={fixedWidth ? `--profileWidth: ${widthFromProfile}px` : ''}
+>
+ <Artwork
+ {disableAutoCenter}
+ {hasTransparentBackground}
+ {quality}
+ artwork={icon}
+ profile={computedProfile}
+ noShelfChevronAnchor={true}
+ />
+</div>
+
+<style>
+ .app-icon {
+ aspect-ratio: 1 / 1;
+ min-width: var(--profileWidth, auto);
+ }
+
+ .app-icon.pill {
+ aspect-ratio: 4 / 3;
+
+ /*
+ Creates elliptical corners with horizontal radii at 50% of the width and vertical radii
+ at 65% of the height, for a rounded, squished, pill-like effect
+ */
+ border-radius: 50% 50% 50% 50% / 65% 65% 65% 65%;
+ }
+
+ .app-icon.round {
+ border-radius: 50%;
+ }
+
+ .app-icon.rounded-rect {
+ border-radius: 23%;
+ }
+
+ .app-icon.tv-rect {
+ aspect-ratio: 16/9;
+ border-radius: 9% / 16%;
+ }
+
+ .app-icon.rounded-rect-prerendered {
+ border-radius: 25%;
+ }
+
+ .app-icon.round-prerendered {
+ border-radius: 50%;
+ }
+
+ .app-icon.with-border {
+ box-shadow: 0 0 0 1px var(--systemQuaternary);
+ }
+</style>
diff --git a/src/components/AppIconRiver.svelte b/src/components/AppIconRiver.svelte
new file mode 100644
index 0000000..b673dd0
--- /dev/null
+++ b/src/components/AppIconRiver.svelte
@@ -0,0 +1,92 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import type { Artwork } from '@jet-app/app-store/api/models';
+ import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte';
+
+ export let icons: Artwork[];
+ export let profile: AppIconProfile = 'app-icon-river';
+
+ $: aspectRatio = icons[0].width / icons[0].height;
+
+ let mounted = false;
+ const numberOfIcons = icons.length;
+
+ // We shift the order of the bottom row of icons to ensure that the same icons aren't shown
+ // next to each other. Note that this is different from purely shuffling the icons, as that
+ // could still lead to the same icons being next to one another, due to how small the set is.
+ // The input and output here is as such:
+ // in = [1, 2, 3, 4, 5, 6, 7]
+ // out = [4, 5, 6, 7, 1, 2, 3]
+ const iconsInShiftedOrder = [
+ ...icons.slice(numberOfIcons / 2),
+ ...icons.slice(0, numberOfIcons / 2),
+ ];
+
+ // We are quadrupling the icons we render so the flow is seamless and stretches across the
+ // full width of the container.
+ const topRow = Array(4).fill(icons).flat();
+ const bottomRow = Array(4).fill(iconsInShiftedOrder).flat();
+
+ // We use this `mounted` flag to defer the rendering of the `AppIconRiver`, since it's markup heavy
+ // and has no semantic meaning for SEO. This deferring saves about 190kb of initial HTML per instance.
+ onMount(() => (mounted = true));
+</script>
+
+{#if mounted}
+ {#each [topRow, bottomRow] as iconRow}
+ <ul class="app-icons">
+ {#each iconRow as icon}
+ <li
+ class="app-icon-container"
+ style:--aspect-ratio={aspectRatio}
+ >
+ <AppIcon {icon} {profile} fixedWidth={false} />
+ </li>
+ {/each}
+ </ul>
+ {/each}
+{/if}
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .app-icons {
+ --icon-width: var(--app-icon-river-icon-width, 128px);
+ --speed: var(--app-icon-river-speed, 240s);
+ --direction: -50%;
+
+ @include rtl {
+ --direction: 50%;
+ }
+ display: flex;
+ width: fit-content;
+ z-index: 2;
+ animation: scroll var(--speed) linear infinite;
+ }
+
+ .app-icons:last-of-type {
+ margin-bottom: 20px;
+ }
+
+ .app-icon-container {
+ width: var(--icon-width);
+ aspect-ratio: var(--aspect-ratio);
+ margin: 8px;
+ }
+
+ .app-icons:last-of-type .app-icon-container {
+ position: relative;
+ right: calc((var(--icon-width) / 2) + 8px);
+ }
+
+ @keyframes scroll {
+ 0% {
+ transform: translateX(0);
+ }
+
+ 100% {
+ transform: translateX(var(--direction));
+ }
+ }
+</style>
diff --git a/src/components/Artwork.svelte b/src/components/Artwork.svelte
new file mode 100644
index 0000000..04de1d4
--- /dev/null
+++ b/src/components/Artwork.svelte
@@ -0,0 +1,118 @@
+<script lang="ts" context="module">
+ import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
+ import type {
+ Artwork as ComponentArtworkType,
+ Profile as ArtworkProfile,
+ CropCode,
+ ImageSizes,
+ } from '@amp/web-app-components/src/components/Artwork/types';
+
+ import type { NamedProfile } from '~/config/components/artwork';
+
+ /**
+ * Creates a {@linkcode Profile} on-the-fly based on the properties of
+ * the {@linkcode artwork}
+ */
+ export function getNaturalProfile(
+ artwork: JetArtworkType,
+ imageSizes: ImageSizes = [artwork.width],
+ ): ArtworkProfile {
+ const aspectRatio = artwork.width / artwork.height;
+
+ return [imageSizes, aspectRatio, artwork.crop as CropCode];
+ }
+
+ export type Profile = NamedProfile | ArtworkProfile;
+</script>
+
+<script lang="ts">
+ import type { ImageSettings } from '@amp/web-app-components/src/components/Artwork/types';
+ import Artwork from '@amp/web-app-components/src/components/Artwork/Artwork.svelte';
+ import { colorAsString, isNamedColor } from '~/utils/color';
+
+ import {
+ ArtworkConfig,
+ type ArtworkProfileMap,
+ } from '@amp/web-app-components/config/components/artwork';
+
+ export let artwork: JetArtworkType;
+ export let profile: Profile;
+ export let alt: string = '';
+ export let topRoundedSecondary: boolean = false;
+ export let useContainerStyle: boolean = false;
+ export let forceFullWidth: boolean = true;
+ export let isDecorative: boolean = true;
+ export let lazyLoad: boolean = true;
+ export let disableAutoCenter: boolean = false;
+ export let noShelfChevronAnchor: boolean = false;
+ export let forceCropCode: boolean = false;
+ export let quality: number | undefined = undefined;
+ export let hasTransparentBackground: boolean =
+ !!artwork.backgroundColor &&
+ isNamedColor(artwork.backgroundColor) &&
+ artwork.backgroundColor.name === 'clear';
+ export let useCropCodeFromArtwork: boolean = true;
+ export let withoutBorder: boolean = false;
+
+ let imageSettings: ImageSettings;
+ $: imageSettings = {
+ forceCropCode,
+ hasTransparentBackground,
+ quality,
+ };
+
+ let PROFILES: ArtworkProfileMap<string> | undefined;
+ let computedProfileAttributes: Profile | undefined;
+
+ $: {
+ const config = ArtworkConfig?.get();
+ PROFILES = config?.PROFILES;
+
+ const defaultProfileAttributes: Profile | undefined =
+ typeof profile === 'string' ? PROFILES?.get(profile) : profile;
+
+ const cropCodeIndex = 2;
+
+ if (
+ useCropCodeFromArtwork &&
+ artwork?.crop &&
+ defaultProfileAttributes
+ ) {
+ computedProfileAttributes = [...defaultProfileAttributes];
+ computedProfileAttributes[cropCodeIndex] =
+ artwork?.crop as CropCode;
+ }
+ }
+
+ $: artworkForComponent = {
+ ...artwork,
+ backgroundColor: artwork.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : undefined,
+ } satisfies ComponentArtworkType;
+</script>
+
+<Artwork
+ artwork={artworkForComponent}
+ profile={computedProfileAttributes || profile}
+ {topRoundedSecondary}
+ {useContainerStyle}
+ {forceFullWidth}
+ {imageSettings}
+ {alt}
+ {isDecorative}
+ {lazyLoad}
+ {disableAutoCenter}
+ {noShelfChevronAnchor}
+ {withoutBorder}
+/>
+
+<style>
+ /* When a user enables the "Smart Invert" accessibility setting, images should not be inverted,
+ so we are re-inverting back to their normal state in this media query, which only currently works for Safari. */
+ @media (inverted-colors: inverted) {
+ :global(.artwork-component img) {
+ filter: invert(1);
+ }
+ }
+</style>
diff --git a/src/components/CollapsableContent.svelte b/src/components/CollapsableContent.svelte
new file mode 100644
index 0000000..e75fbf1
--- /dev/null
+++ b/src/components/CollapsableContent.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import ChevronDown from '~/sf-symbols/chevron.down.svg';
+</script>
+
+<details>
+ <summary>
+ <slot name="summary" />
+ <ChevronDown />
+ </summary>
+
+ <slot />
+</details>
+
+<style>
+ details[open] summary {
+ display: none;
+ }
+
+ summary {
+ list-style: none;
+ cursor: pointer;
+ }
+
+ summary::-webkit-details-marker {
+ display: none;
+ }
+
+ summary :global(svg) {
+ overflow: visible;
+ width: 14px;
+ fill: var(--systemTertiary);
+ position: relative;
+ top: 3px;
+ left: 2px;
+ }
+</style>
diff --git a/src/components/EditorsChoiceBadge.svelte b/src/components/EditorsChoiceBadge.svelte
new file mode 100644
index 0000000..2c4efe1
--- /dev/null
+++ b/src/components/EditorsChoiceBadge.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ import LaurelIcon from '~/sf-symbols/laurel.left.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+</script>
+
+<h4>
+ <span class="icon-container left" aria-hidden="true">
+ <LaurelIcon />
+ </span>
+ {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
+ <span class="icon-container right" aria-hidden="true">
+ <LaurelIcon />
+ </span>
+</h4>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ h4 {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ margin-bottom: 10px;
+ gap: 10px;
+ font: var(--font, var(--title-1-emphasized));
+ color: var(--systemSecondary);
+ }
+
+ .icon-container.right {
+ transform: rotateY(180deg);
+
+ @include rtl {
+ transform: rotateY(0);
+ }
+ }
+
+ .icon-container.left {
+ @include rtl {
+ transform: rotateY(180deg);
+ }
+ }
+
+ .icon-container :global(svg) {
+ overflow: visible;
+ height: 42px;
+ transform: translateY(3px);
+ }
+
+ .icon-container :global(svg path) {
+ fill: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/Error.svelte b/src/components/Error.svelte
new file mode 100644
index 0000000..a0aeba1
--- /dev/null
+++ b/src/components/Error.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import ErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let error: Error;
+
+ const i18n = getI18n();
+</script>
+
+<ErrorPage translateFn={$i18n.t} {error} />
diff --git a/src/components/GradientOverlay.svelte b/src/components/GradientOverlay.svelte
new file mode 100644
index 0000000..5827a2c
--- /dev/null
+++ b/src/components/GradientOverlay.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ export let shouldDarken: boolean = true;
+</script>
+
+<div class="gradient-overlay" style:--brightness={shouldDarken ? 0.85 : 1} />
+
+<style>
+ .gradient-overlay {
+ position: absolute;
+ z-index: 1;
+ bottom: 0;
+ width: 100%;
+ height: var(--height, 60%);
+ border-radius: var(--border-radius, var(--global-border-radius-large));
+ background: linear-gradient(
+ transparent,
+ var(--color, var(--systemSecondary-onLight)) var(--height, 100%)
+ );
+ backdrop-filter: blur(10px);
+ filter: saturate(1.5) brightness(var(--brightness));
+ mask-image: linear-gradient(180deg, transparent 6%, rgb(0, 0, 0.5) 85%);
+ }
+</style>
diff --git a/src/components/Grid.svelte b/src/components/Grid.svelte
new file mode 100644
index 0000000..df2ca74
--- /dev/null
+++ b/src/components/Grid.svelte
@@ -0,0 +1,37 @@
+<script lang="ts" generics="T">
+ import { getGridVars } from '@amp/web-app-components/src/components/Shelf/utils/getGridVars';
+ import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
+
+ export let items: T[] = [];
+ export let gridType: GridType;
+
+ $: style = getGridVars(gridType);
+</script>
+
+<ul {style} class="grid" data-test-id="grid">
+ {#each items as item}
+ <li>
+ <slot {item} />
+ </li>
+ {/each}
+</ul>
+
+<style lang="scss">
+ @mixin grid-styles-for-viewport($viewport: null) {
+ grid-template-columns: repeat(var(--grid-#{$viewport}), 1fr);
+ column-gap: var(--grid-column-gap-#{$viewport});
+ row-gap: var(--grid-row-gap-#{$viewport});
+ }
+
+ .grid {
+ display: grid;
+ width: 100%;
+ padding: 0 var(--bodyGutter);
+
+ @each $viewport in ('xsmall', 'small', 'medium', 'large', 'xlarge') {
+ @media (--range-#{$viewport}-only) {
+ @include grid-styles-for-viewport($viewport);
+ }
+ }
+ }
+</style>
diff --git a/src/components/HoverWrapper.svelte b/src/components/HoverWrapper.svelte
new file mode 100644
index 0000000..2d2742f
--- /dev/null
+++ b/src/components/HoverWrapper.svelte
@@ -0,0 +1,54 @@
+<script lang="ts">
+ export let element: keyof HTMLElementTagNameMap = 'article';
+ export let hasChin: boolean = false;
+</script>
+
+<svelte:element this={element} class="hover-wrapper" class:has-chin={hasChin}>
+ <slot />
+</svelte:element>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/mixins/scrim-opacity-controller' as *;
+ @use 'amp/stylekit/core/mixins/hover-style' as *;
+
+ .hover-wrapper {
+ position: relative;
+ display: var(--display, flex);
+ overflow: hidden;
+ align-items: center;
+ cursor: pointer;
+ border-radius: var(--global-border-radius-large);
+ box-shadow: var(--shadow-small);
+
+ @include scrim-opacity-controller;
+ }
+
+ .hover-wrapper.has-chin,
+ .hover-wrapper.has-chin::after {
+ // For chins, we cannot use `border-raidus` due a Chrome bug with unequal radii
+ // (e.g. there is no rounding at the bottom) and mask-image. To get around that,
+ // we use clip-path to the same effect.
+ // https://issues.chromium.org/issues/40778541.
+ border-radius: unset;
+ clip-path: inset(
+ 0 0 0 0 round var(--global-border-radius-large)
+ var(--global-border-radius-large) 0 0
+ );
+ }
+
+ /* stylelint-disable order/order */
+ .hover-wrapper::after {
+ mix-blend-mode: soft-light;
+
+ @include content-container-hover-style;
+
+ // These properties are overriding those provided by `content-container-hover-style`
+ border-radius: var(--global-border-radius-large);
+ transition: opacity 210ms ease-out;
+ }
+ /* stylelint-enable order/order */
+
+ .hover-wrapper:hover::after {
+ @include scrim-opacity;
+ }
+</style>
diff --git a/src/components/LaunchNativeButton.svelte b/src/components/LaunchNativeButton.svelte
new file mode 100644
index 0000000..eb7942b
--- /dev/null
+++ b/src/components/LaunchNativeButton.svelte
@@ -0,0 +1,69 @@
+<script lang="ts">
+ import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { getJet } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+ import { launchAppOnMac } from '~/utils/launch-client';
+
+ export let url: string;
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ function handleButtonClick(event: MouseEvent) {
+ // Need to call both event.preventDefault() and event.stopPropagation()
+ // to prevent navigation to the production page on web
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (url) {
+ launchAppOnMac(url);
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ targetId: 'OpenInMacAppStore',
+ targetType: 'button',
+ actionType: 'open',
+ });
+ }
+ }
+</script>
+
+<button
+ class="get-button blue"
+ aria-label={$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.AX')}
+ on:click={handleButtonClick}
+>
+ <LineClamp clamp={1}>
+ {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.Action')}
+ <span>
+ {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.App')}
+ </span>
+ </LineClamp>
+ <ArrowIcon class="external-link-arrow" aria-hidden="true" />
+</button>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ button {
+ display: inline-flex;
+ }
+
+ button span {
+ font-weight: 500;
+ }
+
+ button :global(.external-link-arrow) {
+ align-self: center;
+ width: var(--launch-native-button-arrow-size, 9px);
+ height: var(--launch-native-button-arrow-size, 9px);
+ padding-top: 1px;
+ margin-inline-start: 4px;
+ fill: var(--systemPrimary-onDark);
+
+ @include rtl {
+ transform: rotate(-90deg);
+ }
+ }
+</style>
diff --git a/src/components/LinkWrapper.svelte b/src/components/LinkWrapper.svelte
new file mode 100644
index 0000000..0e5025d
--- /dev/null
+++ b/src/components/LinkWrapper.svelte
@@ -0,0 +1,60 @@
+<!--
+@component
+Wraps a link around the provided slot contents if a valid `FlowAction` or `ExternalUrlAction` is given.
+If no valid action is provided, the contents are rendered as-is with no decoration.
+
+💡 For accessibility, this component should ideally wrap the entire visual block (e.g., `div`, `article`) so that
+screen readers and keyboard users interpret the entire element as a single link.
+
+@example
+```
+ <LinkWrapper action={item.clickAction}>
+ <article>
+ <Artwork artwork={item.artwork} />
+ {item.title}
+ </article>
+ </LinkWrapper>
+```
+-->
+<script lang="ts">
+ import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
+ import { type Opt, isSome } from '@jet/environment/types/optional';
+
+ import FlowActionComponent from '~/components/jet/action/FlowAction.svelte';
+ import { isExternalUrlAction } from '~/jet/models';
+ import ExternalUrlAction from './jet/action/ExternalUrlAction.svelte';
+ import ShelfBasedPageScrollAction, {
+ isShelfBasedPageScrollAction,
+ } from './jet/action/ShelfBasedPageScrollAction.svelte';
+
+ export let action: Opt<Action> = null;
+ export let label: Opt<string> = null;
+ export let withoutLabel: Opt<boolean> = false;
+ export let includeExternalLinkArrowIcon: boolean = true;
+</script>
+
+{#if isSome(action) && isFlowAction(action) && isSome(action.pageUrl)}
+ <FlowActionComponent
+ destination={action}
+ aria-label={withoutLabel ? null : label || action.title}
+ >
+ <slot />
+ </FlowActionComponent>
+{:else if isSome(action) && isExternalUrlAction(action)}
+ <ExternalUrlAction
+ destination={action}
+ aria-label={withoutLabel ? null : label || action.title}
+ includeArrowIcon={includeExternalLinkArrowIcon}
+ >
+ <slot />
+ </ExternalUrlAction>
+{:else if isSome(action) && isShelfBasedPageScrollAction(action)}
+ <ShelfBasedPageScrollAction
+ destination={action}
+ aria-label={withoutLabel ? null : label || action.title}
+ >
+ <slot />
+ </ShelfBasedPageScrollAction>
+{:else}
+ <slot />
+{/if}
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte
new file mode 100644
index 0000000..8221c79
--- /dev/null
+++ b/src/components/Menu.svelte
@@ -0,0 +1,218 @@
+<script lang="ts" generics="T">
+ import { tick } from 'svelte';
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { MouseEventHandler } from 'svelte/elements';
+ import { onDestroy, onMount } from 'svelte';
+ import { generateUuid } from '@amp/web-apps-utils/src';
+ import {
+ computePosition,
+ autoUpdate,
+ offset,
+ flip,
+ shift,
+ } from '@floating-ui/dom';
+
+ export let options: T[];
+ // Allows the developer the override the floating-ui calculated offset to a fixed number
+ export let forcedXPosition: number | null = null;
+
+ export let handleShowMenu: () => void = () => {};
+
+ let isMenuOpen = false;
+
+ /**
+ * Display the menu
+ *
+ * @example
+ * <script>
+ * let menu;
+ *
+ * function showMenu() {
+ * menu.show();
+ * }
+ * <\/script>
+ *
+ * <Menu bind:this={menu} />
+ */
+ export async function show() {
+ if (!menuEl) return;
+
+ isMenuOpen = true;
+
+ // Menu position should be updated *only* after the dialog has been shown
+ updateMenuPosition();
+
+ // Focuses the first link in the dropdown after the DOM updates
+ await tick();
+ menuEl.querySelector('a')?.focus();
+
+ // When the modal is open, track viewport changes and update the menu position
+ floatingUIAutoUpdatePositionCleanupCallback = autoUpdate(
+ trigger!,
+ menuEl!,
+ updateMenuPosition,
+ );
+ }
+
+ /**
+ * Close the menu
+ *
+ * @example
+ * <script>
+ * let menu;
+ *
+ * function closeMenu() {
+ * menu.close();
+ * }
+ * <\/script>
+ *
+ * <Menu bind:this={menu} />
+ */
+ export function close() {
+ if (!menuEl) return;
+
+ isMenuOpen = false;
+ cleanUpFloatingUIAutoPosition();
+ }
+
+ function toggle() {
+ if (isMenuOpen) {
+ close();
+ } else {
+ show();
+ handleShowMenu?.();
+ }
+ }
+
+ const menuId = generateUuid();
+
+ let menuEl: HTMLUListElement | undefined;
+ let trigger: HTMLButtonElement | undefined;
+
+ function handleKeyUp(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ close();
+ }
+ }
+
+ /**
+ * Dismiss the dialog when clicking anywhere with the dialog open
+ */
+ const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => {
+ const clickedElement = event.target as HTMLElement;
+
+ // Only close the dialog if the click is "outside" of the trigger
+ // Otherwise, it will be closed immediately
+ if (!trigger?.contains(clickedElement)) {
+ close();
+ }
+ };
+
+ /// MARK: Menu Positioning through `FloatingUI`
+
+ /**
+ * Update the position of the menu to align it with the trigger
+ */
+ async function updateMenuPosition() {
+ const { x, y } = await computePosition(trigger!, menuEl!, {
+ middleware: [
+ offset({
+ mainAxis: 10,
+ }),
+
+ flip(),
+ shift(),
+ ],
+ placement: 'bottom-end',
+ });
+
+ Object.assign(menuEl!.style, {
+ left: `${forcedXPosition || x}px`,
+ top: `${y}px`,
+ });
+ }
+
+ let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>;
+
+ /**
+ * Cleans up the `FloatingUI` auto-update listener, which should only be "active"
+ * while the menu is open
+ */
+ function cleanUpFloatingUIAutoPosition() {
+ floatingUIAutoUpdatePositionCleanupCallback?.();
+ floatingUIAutoUpdatePositionCleanupCallback = undefined;
+ }
+
+ onMount(() => {
+ // Ensures menu is hidden initially
+ if (menuEl) isMenuOpen = false;
+ });
+
+ onDestroy(function () {
+ cleanUpFloatingUIAutoPosition();
+ });
+</script>
+
+<svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} />
+
+<button
+ class="menu-trigger"
+ aria-controls={menuId}
+ aria-haspopup="menu"
+ aria-expanded={isMenuOpen}
+ bind:this={trigger}
+ on:click={toggle}
+>
+ <slot name="trigger" />
+</button>
+
+<ul
+ id={menuId}
+ hidden={!isMenuOpen}
+ tabindex="-1"
+ class="menu-popover focus-visible"
+ bind:this={menuEl}
+>
+ {#each options as option}
+ <li class="menu-item" role="presentation">
+ <slot name="option" {option} />
+ </li>
+ {/each}
+</ul>
+
+<style>
+ :root {
+ --menu-common-padding: 4px 8px;
+ }
+
+ .menu-trigger {
+ background-color: var(--menu-trigger-background-color);
+ border-radius: var(--menu-trigger-border-radius);
+ font: var(--menu-trigger-font);
+ padding: var(--menu-trigger-padding, var(--menu-common-padding));
+ }
+
+ .menu-popover {
+ background-color: var(--menu-popover-background-color, var(--pageBg));
+ padding: var(--menu-popover-padding, 0);
+ border: var(--menu-popover-border, none);
+ border-radius: var(
+ --menu-popover-border-radius,
+ var(--global-border-radius-large)
+ );
+ box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium));
+ position: absolute;
+ inset: auto;
+ z-index: var(--menu-popover-z-index, 2);
+ }
+
+ .menu-popover::backdrop {
+ background: var(--menu-popover-backdrop-background, none);
+ }
+
+ .menu-item {
+ padding: var(--menu-item-padding, var(--menu-common-padding));
+ margin: var(--menu-item-margin, 0);
+ white-space: nowrap;
+ }
+</style>
diff --git a/src/components/MotionArtwork.svelte b/src/components/MotionArtwork.svelte
new file mode 100644
index 0000000..646df26
--- /dev/null
+++ b/src/components/MotionArtwork.svelte
@@ -0,0 +1,152 @@
+<script lang="ts">
+ import { createEventDispatcher, onMount, onDestroy } from 'svelte';
+ import { loggerFor } from '@amp/web-apps-logger';
+
+ const logger = loggerFor('components/MotionArtwork');
+
+ type HLSError = {
+ type: string;
+ message: string;
+ details: string;
+ fatal: boolean;
+ handled: boolean;
+ };
+
+ type MotionArtworkError = {
+ type: string;
+ reason: string;
+ fatal: boolean;
+ error?: Error;
+ };
+
+ /** HTML `id` attribute for the <video /> element */
+ export let id: string;
+
+ /** Source URL for the video, an HLS playlist ending in .m3u8 */
+ export let src: string;
+
+ /** Poster image to show while the video is loading */
+ export let poster: string | undefined;
+
+ /** If the video should loop from end to start. */
+ export let loop: boolean = true;
+
+ /** If the audio should be muted on the video. */
+ export let muted: boolean = true;
+
+ /** If the video should be paused when initially loaded. */
+ export let paused: boolean = true;
+
+ /** The constructor to use for creating an Hls playback session. */
+ export let HLS: Window['Hls'] = window.Hls;
+
+ /** RTCReportingAgent instance for RTC reporting on video playback. */
+ export let reportingAgent: any = undefined;
+
+ /** HTMLVideoElement used by HLS.js to render the video */
+ export let videoElement: HTMLVideoElement | null = null;
+
+ /** Internal error state for the component */
+ let errorState: MotionArtworkError | undefined;
+
+ let hlsSession: Window['Hls'] | undefined;
+
+ /** Dispatcher for errors. */
+ const dispatch = createEventDispatcher<{ error: MotionArtworkError }>();
+
+ function handleError(details: MotionArtworkError) {
+ logger.error(
+ `Error playing MotionArtwork with HLS: ${details?.reason}`,
+ details?.error,
+ );
+
+ errorState = {
+ type: details.type,
+ reason: details.reason,
+ fatal: details.fatal,
+ error: details?.error,
+ };
+
+ dispatch('error', errorState);
+ }
+
+ const hlsSupported = HLS?.isSupported() ?? false;
+
+ onMount(function () {
+ if (!hlsSupported) {
+ handleError({
+ type: 'runtime',
+ reason: 'unsupported',
+ fatal: true,
+ });
+ return;
+ }
+
+ // Create a new HLS.js playback session
+ hlsSession = new HLS({
+ debug: false,
+ debugLevel: 'error',
+ enablePerformanceLogging: false,
+ nativeControlsEnabled: false,
+
+ appData: {
+ reportingAgent: reportingAgent,
+ serviceName: reportingAgent?.ServiceName,
+ },
+ });
+
+ hlsSession.on(
+ HLS.Events.ERROR,
+ function (_event: string, error: HLSError) {
+ handleError({
+ type: 'hls',
+ reason: error.message,
+ fatal: error.fatal,
+ error: error as unknown as Error,
+ });
+ },
+ );
+
+ // Direct HLS.js to the VideoElement to use and start loading the video source
+ hlsSession.attachMedia(videoElement);
+ hlsSession.loadSource(src, {
+ /* HLS.js loading options go here */
+ });
+ });
+
+ onDestroy(() => {
+ // Stop the video, release resources, and destroy the HLS context
+ hlsSession?.destroy();
+ });
+</script>
+
+{#if errorState !== undefined}
+ <slot name="error" error={errorState} {poster} />
+{:else}
+ <!-- svelte-ignore a11y-media-has-caption -->
+ <video
+ {id}
+ {loop}
+ {poster}
+ preload="none"
+ data-loop={true}
+ playsinline={true}
+ controls={false}
+ bind:this={videoElement}
+ bind:muted
+ bind:paused
+ on:play
+ on:ended
+ on:loadedmetadata
+ />
+{/if}
+
+<style>
+ video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center center;
+ aspect-ratio: var(--aspect-ratio);
+ }
+</style>
diff --git a/src/components/Page.svelte b/src/components/Page.svelte
new file mode 100644
index 0000000..5b44c06
--- /dev/null
+++ b/src/components/Page.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import {
+ type Page,
+ hasVisionProUrl,
+ isAppEventDetailPage,
+ isArticlePage,
+ isChartsHubPage,
+ isGenericPage,
+ isSearchLandingPage,
+ isShelfBasedProductPage,
+ isTopChartsPage,
+ isTodayPage,
+ isSearchResultsPage,
+ isStaticMessagePage,
+ isSeeAllPage,
+ isErrorPage,
+ } from '~/jet/models';
+
+ import AppEventDetailPage from './pages/AppEventDetailPage.svelte';
+ import ArticlePage from './pages/ArticlePage.svelte';
+ import ChartsHubPage from './pages/ChartsHubPage.svelte';
+ import DefaultPage from './pages/DefaultPage.svelte';
+ import ErrorPage from './pages/ErrorPage.svelte';
+ import ProductPage from './pages/ProductPage.svelte';
+ import VisionProPage from './pages/VisionProPage.svelte';
+ import StaticMessagePageComponent from './pages/StaticMessagePage.svelte';
+ import SearchLandingPage from './pages/SearchLandingPage.svelte';
+ import SearchResultsPage from './pages/SearchResultsPage.svelte';
+ import TopChartsPage from './pages/TopChartsPage.svelte';
+ import TodayPage from './pages/TodayPage.svelte';
+ import SeeAllPage from './pages/SeeAllPage.svelte';
+ import MetaTags from '~/components/structure/MetaTags.svelte';
+ import PageModal from '~/components/PageModal.svelte';
+
+ export let page: Page;
+</script>
+
+<MetaTags {page} />
+
+<PageModal />
+
+{#if isAppEventDetailPage(page)}
+ <AppEventDetailPage {page} />
+{:else if isArticlePage(page)}
+ <ArticlePage {page} />
+{:else if isChartsHubPage(page)}
+ <ChartsHubPage {page} />
+{:else if isSearchLandingPage(page)}
+ <SearchLandingPage {page} />
+{:else if isSearchResultsPage(page)}
+ <SearchResultsPage {page} />
+{:else if isShelfBasedProductPage(page)}
+ <ProductPage {page} />
+{:else if isTopChartsPage(page)}
+ <TopChartsPage {page} />
+{:else if isGenericPage(page) && hasVisionProUrl(page)}
+ <VisionProPage {page} />
+{:else if isTodayPage(page)}
+ <TodayPage {page} />
+{:else if isStaticMessagePage(page)}
+ <StaticMessagePageComponent {page} />
+{:else if isSeeAllPage(page)}
+ <SeeAllPage {page} />
+{:else if isErrorPage(page)}
+ <ErrorPage {page} />
+{:else}
+ <DefaultPage {page} />
+{/if}
diff --git a/src/components/PageModal.svelte b/src/components/PageModal.svelte
new file mode 100644
index 0000000..9e5ee50
--- /dev/null
+++ b/src/components/PageModal.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { onMount, type SvelteComponent } from 'svelte';
+ import type { GenericPage } from '@jet-app/app-store/api/models';
+
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import { getModalPageStore } from '~/stores/modalPage';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import { LICENSE_AGREEMENT_MODAL_ID } from '~/utils/metrics';
+
+ let modalElement: SvelteComponent;
+ let modalPage = getModalPageStore();
+ let page: GenericPage | undefined;
+
+ $: page = $modalPage?.page;
+ $: shelves = page?.shelves ?? [];
+ $: title = page?.title ?? null;
+ $: targetId =
+ $modalPage?.pageDetail === 'licenseAgreement'
+ ? LICENSE_AGREEMENT_MODAL_ID
+ : undefined;
+
+ onMount(() => {
+ return modalPage.clearPage;
+ });
+
+ $: {
+ if ($modalPage) {
+ modalElement?.showModal();
+ } else {
+ handleModalClose();
+ }
+ }
+
+ function handleModalClose() {
+ modalElement?.close();
+ modalPage.clearPage();
+ }
+</script>
+
+<Modal
+ modalTriggerElement={null}
+ bind:this={modalElement}
+ on:close={handleModalClose}
+>
+ <div class="modal-content">
+ {#if page}
+ <ContentModal
+ {title}
+ subtitle={null}
+ on:close={handleModalClose}
+ {targetId}
+ >
+ <svelte:fragment slot="content">
+ {#each shelves as shelf}
+ <ShelfComponent {shelf}>
+ <slot
+ name="marker-shelf"
+ slot="marker-shelf"
+ let:shelf
+ {shelf}
+ />
+ </ShelfComponent>
+ {/each}
+ </svelte:fragment>
+ </ContentModal>
+ {/if}
+ </div>
+</Modal>
+
+<style lang="scss">
+ .modal-content :global(p) {
+ user-select: text;
+ margin-bottom: 15px;
+ overflow-wrap: break-word;
+ }
+
+ :global(.noscroll) {
+ overflow: hidden;
+ touch-action: none;
+ }
+</style>
diff --git a/src/components/PageResolver.svelte b/src/components/PageResolver.svelte
new file mode 100644
index 0000000..9f482aa
--- /dev/null
+++ b/src/components/PageResolver.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import type { Page } from '~/jet/models';
+
+ import PageComponent from '~/components/Page.svelte';
+ import ErrorComponent from '~/components/Error.svelte';
+ import LoadingSpinner from '@amp/web-app-components/src/components/LoadingSpinner/LoadingSpinner.svelte';
+
+ export let page: Promise<Page> | Page;
+ export let isFirstPage: boolean;
+</script>
+
+{#await page}
+ <div data-testid="page-loading">
+ <!--
+ Delay showing the spinner on initial page load after app boot.
+ After that, the FlowAction handler already waits 500ms before
+ it changes DOM, so we only need to wait 1000ms.
+ -->
+ <LoadingSpinner delay={isFirstPage ? 1500 : 1000} />
+ </div>
+{:then page}
+ <PageComponent {page} />
+{:catch error}
+ <ErrorComponent {error} />
+{/await}
diff --git a/src/components/ProductPageArcadeBanner.svelte b/src/components/ProductPageArcadeBanner.svelte
new file mode 100644
index 0000000..154c115
--- /dev/null
+++ b/src/components/ProductPageArcadeBanner.svelte
@@ -0,0 +1,188 @@
+<script lang="ts">
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+</script>
+
+<aside>
+ <div class="arcade-banner">
+ <div class="metadata-container">
+ <div class="logo-container">
+ <AppleArcadeLogo />
+ </div>
+
+ <h2>
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
+ <br />
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
+ </h2>
+
+ <a href="https://www.apple.com/apple-arcade/" target="_blank">
+ <span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
+ )}
+ </span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
+ )}
+ </a>
+ </div>
+ </div>
+</aside>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .logo-container {
+ width: 62px;
+ margin-bottom: 10px;
+ line-height: 0;
+
+ @media (--range-xsmall-only) {
+ width: 48px;
+ margin-bottom: 8px;
+ }
+ }
+
+ .logo-container :global(path) {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .metadata-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 60%;
+ height: 100%;
+ padding: 0 20px;
+
+ @media (--range-xsmall-only) {
+ align-items: flex-start;
+ justify-content: center;
+ }
+ }
+
+ h2 {
+ margin-bottom: 10px;
+ font: var(--title-1-emphasized);
+
+ @media (--range-xsmall-only) {
+ margin-bottom: 8px;
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ a {
+ display: flex;
+ font: var(--title-3-emphasized);
+
+ @media (--range-xsmall-only) {
+ font: var(--body-emphasized);
+ }
+ }
+
+ a::after {
+ content: '↗';
+ font-weight: normal;
+ margin-inline-start: 4px;
+ }
+
+ a:hover {
+ text-decoration: none;
+ }
+
+ a:hover span {
+ text-decoration: underline;
+ }
+
+ aside {
+ width: 100%;
+ max-width: calc(viewport-content-for(xlarge));
+ height: 152px;
+ margin: 0 auto 32px;
+ padding: 0 var(--bodyGutter);
+
+ @media (--range-xsmall-only) {
+ max-width: 100%;
+ padding: 0;
+ }
+ }
+
+ .arcade-banner {
+ width: 100%;
+ height: 100%;
+ color: var(--systemPrimary-onDark);
+ border-radius: var(--global-border-radius-medium);
+ background: #000;
+ background-repeat: no-repeat;
+ background-position: right;
+ background-size: contain;
+
+ @media (prefers-color-scheme: dark) {
+ border: 1px solid var(--systemQuaternary-onDark);
+ }
+
+ @media (--range-xsmall-only) {
+ border-radius: 0;
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
+ background-size: cover;
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
+ background-position: left;
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
+ background-position: left;
+ }
+ }
+ }
+
+ @media (--range-small-only) {
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
+ background-position: left;
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
+ background-position: left;
+ }
+ }
+ }
+
+ @media (--range-medium-up) {
+ background-image: url('/assets/images/arcade/upsell/banner-980@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-980@1x_RTL.png');
+ background-position: left;
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/banner-980@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-980@2x_RTL.png');
+ background-position: left;
+ }
+ }
+ }
+ }
+</style>
diff --git a/src/components/ProductPageArcadeFooter.svelte b/src/components/ProductPageArcadeFooter.svelte
new file mode 100644
index 0000000..0cd9b65
--- /dev/null
+++ b/src/components/ProductPageArcadeFooter.svelte
@@ -0,0 +1,159 @@
+<script lang="ts">
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+</script>
+
+<article>
+ <div class="metadata-container">
+ <div class="logo-container">
+ <AppleArcadeLogo />
+ </div>
+
+ <h2>
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
+ <br />
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
+ <br />
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
+ </h2>
+
+ <a href="https://www.apple.com/apple-arcade/" target="_blank">
+ <span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
+ )}
+ </span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
+ )}
+ </a>
+ </div>
+</article>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .logo-container {
+ width: 72px;
+ margin-bottom: 20px;
+ line-height: 0;
+
+ @media (--range-xsmall-only) {
+ width: 62px;
+ margin-bottom: 16px;
+ }
+ }
+
+ .logo-container :global(path) {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .metadata-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 60%;
+ height: 100%;
+ padding: 40px;
+
+ @media (--range-xsmall-only) {
+ align-items: center;
+ justify-content: end;
+ width: unset;
+ text-align: center;
+ }
+ }
+
+ h2 {
+ margin-bottom: 20px;
+ font: var(--header-emphasized);
+ line-height: 54px;
+
+ @media (--range-xsmall-only) {
+ font: var(--title-1-emphasized);
+ }
+ }
+
+ a {
+ display: flex;
+ font: var(--title-3-emphasized);
+ }
+
+ a::after {
+ content: '↗';
+ font-weight: normal;
+ margin-inline-start: 4px;
+ }
+
+ a:hover {
+ text-decoration: none;
+ }
+
+ a:hover span {
+ text-decoration: underline;
+ }
+
+ article {
+ flex-grow: 1;
+ width: 100%;
+ max-width: calc(viewport-content-for(xlarge) - var(--bodyGutter) * 2);
+ aspect-ratio: 2.55;
+ margin: 0 auto;
+ color: var(--systemPrimary-onDark);
+ background: #000;
+ background-size: cover;
+
+ @media (--range-xsmall-only) {
+ max-width: 338px;
+ aspect-ratio: 35/48;
+ border-radius: var(--global-border-radius-medium);
+ background-image: url('/assets/images/arcade/upsell/footer-280@1x.png');
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/footer-280@2x.png');
+ }
+ }
+
+ @media (--range-small-only) {
+ aspect-ratio: 173/96;
+ background-image: url('/assets/images/arcade/upsell/footer-692@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-692@1x_RTL.png');
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/footer-692@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-692@2x_RTL.png');
+ }
+ }
+ }
+
+ @media (--range-medium-up) {
+ background-image: url('/assets/images/arcade/upsell/footer-980@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-980@1x_RTL.png');
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/footer-980@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-980@2x_RTL.png');
+ }
+ }
+ }
+
+ @media (--range-xlarge-up) {
+ border-radius: var(--global-border-radius-medium);
+ }
+ }
+</style>
diff --git a/src/components/SFSymbol.svelte b/src/components/SFSymbol.svelte
new file mode 100644
index 0000000..998ab06
--- /dev/null
+++ b/src/components/SFSymbol.svelte
@@ -0,0 +1,51 @@
+<!--
+@component
+Renders a supported "SF Symbol" from the icons available in `~/sf-symbols`
+-->
+<script lang="ts" context="module">
+ import type { ComponentType } from 'svelte';
+
+ const iconComponents = import.meta.glob('~/sf-symbols/*.svg', {
+ eager: true,
+ import: 'default',
+ });
+
+ const iconNameToComponent: Record<string, ComponentType | undefined> =
+ Object.fromEntries(
+ Object.entries(iconComponents).map(
+ ([fullPathToIcon, iconComponent]) => {
+ const iconName = fullPathToIcon
+ .replace('/src/sf-symbols/', '')
+ .replace('.svg', '');
+
+ return [iconName, iconComponent as ComponentType];
+ },
+ ),
+ );
+
+ /**
+ * The list of all supported icons
+ *
+ * This is exposed only for testing/Storybook purposes
+ */
+ export const __iconNames = Object.keys(iconNameToComponent);
+
+ export function getIconComponentByName(iconName: string) {
+ return iconNameToComponent[iconName];
+ }
+</script>
+
+<script lang="ts">
+ /**
+ * The name of the SF Symbol to render
+ *
+ * Must match the name of an `.svg` file in `~/sf-symbols`. If a file with a matching
+ * name does not exist, nothing will be rendered
+ */
+ export let name: string;
+ export let ariaHidden: boolean = false;
+
+ $: icon = getIconComponentByName(name);
+</script>
+
+<svelte:component this={icon} aria-hidden={ariaHidden ? 'true' : 'false'} />
diff --git a/src/components/ShareArrowButton.svelte b/src/components/ShareArrowButton.svelte
new file mode 100644
index 0000000..7b822fc
--- /dev/null
+++ b/src/components/ShareArrowButton.svelte
@@ -0,0 +1,90 @@
+<script lang="ts" context="module">
+ export function isShareSupported() {
+ return (
+ typeof navigator !== 'undefined' &&
+ typeof navigator.share === 'function'
+ );
+ }
+</script>
+
+<script lang="ts">
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let url: string;
+ export let withLabel: boolean = false;
+
+ const i18n = getI18n();
+
+ $: isShareSheetOpen = false;
+
+ async function handleShareClick() {
+ isShareSheetOpen = !isShareSheetOpen;
+
+ try {
+ await navigator.share({ url });
+ isShareSheetOpen = false;
+ } catch {
+ isShareSheetOpen = false;
+ }
+ }
+</script>
+
+<button
+ on:click={handleShareClick}
+ aria-label={$i18n.t('ASE.Web.AppStore.Share.Button.AccessibilityValue')}
+ class:active={isShareSheetOpen}
+ class:with-label={withLabel}
+>
+ <SFSymbol name="square.and.arrow.up" ariaHidden={true} />
+
+ {#if withLabel}
+ {$i18n.t('ASE.Web.AppStore.Share.Button.Value')}
+ {/if}
+</button>
+
+<style lang="scss">
+ button {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: var(--share-arrow-size, 32px);
+ height: var(--share-arrow-size, 32px);
+ border-radius: var(--share-arrow-size, 32px);
+ background: var(--systemQuaternary-onDark);
+ transition: background-color 210ms ease-out;
+ mix-blend-mode: plus-lighter;
+ }
+
+ button.with-label {
+ display: flex;
+ align-items: center;
+ width: auto;
+ padding: 0 16px;
+ gap: 8px;
+ font: var(--body-emphasized);
+
+ :global(svg) {
+ height: 16px;
+ width: auto;
+ top: -2px;
+ position: relative;
+ }
+ }
+
+ button.active,
+ button:hover {
+ // stylelint-disable color-function-notation
+ background-color: rgb(from var(--systemTertiary-onDark) r g b/.13);
+ // stylelint-enable color-function-notation
+ }
+
+ button :global(svg) {
+ width: 37%;
+ fill: var(--systemPrimary-onDark);
+ overflow: visible;
+ }
+</style>
diff --git a/src/components/Shelf/Title.svelte b/src/components/Shelf/Title.svelte
new file mode 100644
index 0000000..e68f4b1
--- /dev/null
+++ b/src/components/Shelf/Title.svelte
@@ -0,0 +1,112 @@
+<!--
+@component
+
+Renders the "Title" and "See All action" for a `Shelf`
+
+### Supported CSS Variables
+
+- `--shelf-title-font`: overrides the font used for the "title" element
+
+-->
+<script lang="ts">
+ import { type Opt, isSome } from '@jet/environment/types/optional';
+ import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
+
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import LinkWrapper from '../LinkWrapper.svelte';
+
+ export let title: string;
+ export let subtitle: Opt<string> = undefined;
+ export let seeAllAction: Opt<Action> = undefined;
+</script>
+
+<div class="title-action-wrapper" class:with-subtitle={!!subtitle}>
+ <LinkWrapper action={seeAllAction} label={title}>
+ <div class="link-contents">
+ <h2 class="shelf-title" data-test-id="shelf-title">{title}</h2>
+
+ {#if isSome(seeAllAction) && isFlowAction(seeAllAction)}
+ <span
+ class="chevron-container"
+ data-test-id="shelf-see-all-chevron"
+ aria-hidden="true"
+ >
+ <SFSymbol name="chevron.forward" />
+ </span>
+ {/if}
+ </div>
+ </LinkWrapper>
+</div>
+
+{#if subtitle}
+ <p>{subtitle}</p>
+{/if}
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .title-action-wrapper {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ margin: 0 var(--bodyGutter) 13px;
+ }
+
+ .title-action-wrapper.with-subtitle {
+ margin-bottom: 3px;
+ }
+
+ .title-action-wrapper :global(a:hover) {
+ text-decoration: none;
+ }
+
+ p {
+ font: var(--title-3-tall);
+ color: var(--systemSecondary);
+ margin: 0 var(--bodyGutter) 13px;
+ }
+
+ h2 {
+ color: var(--systemPrimary, #000);
+ font: var(--shelf-title-font, var(--title-2-emphasized));
+ text-wrap: pretty;
+ }
+
+ .link-contents {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .chevron-container {
+ line-height: 0;
+ padding: 6px 0 4px;
+ display: block;
+ }
+
+ .chevron-container :global(svg) {
+ height: 12px;
+ display: block;
+ translate: 0 0;
+ transition: translate 210ms ease-out;
+
+ @include rtl {
+ transform: rotate(180deg);
+ }
+ }
+
+ .chevron-container :global(svg path:not([fill='none'])) {
+ fill: var(--systemGray2);
+ }
+
+ .link-contents:hover .chevron-container :global(svg) {
+ translate: 1px 0;
+
+ @include rtl {
+ transform: rotate(180deg);
+ translate: -1px 0;
+ }
+ }
+</style>
diff --git a/src/components/Shelf/Wrapper.svelte b/src/components/Shelf/Wrapper.svelte
new file mode 100644
index 0000000..850b0d0
--- /dev/null
+++ b/src/components/Shelf/Wrapper.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+
+ export let shelf: Shelf | undefined = undefined;
+
+ /**
+ * Whether or not to automatically display the shelf "centered" at the normal
+ * page width for the App Store
+ *
+ * When `false`, the shelf is not constrained horizontally in any way
+ *
+ * The value of this property may be ignored when the shelf's `.presentationHints`
+ * indicate that it is being rendered in a context where "centering" would not be
+ * appropriate
+ *
+ * @default true
+ */
+ export let centered: boolean = false;
+
+ export let withTopBorder: boolean = false;
+ export let withTopMargin: boolean = false;
+ export let withPaddingTop: boolean = true;
+ export let withBottomPadding: boolean = true;
+
+ $: seeAllAction =
+ shelf?.header?.titleAction ??
+ shelf?.header?.accessoryAction ??
+ shelf?.seeAllAction;
+</script>
+
+<section
+ id={shelf?.id}
+ data-test-id="shelf-wrapper"
+ class="shelf"
+ class:centered
+ class:border-top={withTopBorder}
+ class:margin-top={withTopMargin}
+ class:padding-top={withPaddingTop}
+ class:padding-bottom={withBottomPadding}
+>
+ {#if $$slots['title']}
+ <slot name="title" />
+ {:else if shelf?.header?.title}
+ <ShelfTitle
+ title={shelf.header.title}
+ subtitle={shelf.header.subtitle}
+ {seeAllAction}
+ />
+ {:else if shelf?.title}
+ <ShelfTitle
+ title={shelf.title}
+ subtitle={shelf.subtitle}
+ {seeAllAction}
+ />
+ {/if}
+
+ <slot />
+</section>
+
+<style>
+ .padding-top {
+ padding-top: 13px;
+ }
+
+ .centered {
+ margin: 0 var(--bodyGutter);
+ }
+
+ .margin-top {
+ margin-top: 13px;
+ }
+
+ .border-top {
+ border-top: 1px solid var(--systemGray4);
+ }
+
+ .shelf.padding-bottom {
+ padding-bottom: 32px;
+ }
+</style>
diff --git a/src/components/ShelfItemLayout.svelte b/src/components/ShelfItemLayout.svelte
new file mode 100644
index 0000000..ef1d07c
--- /dev/null
+++ b/src/components/ShelfItemLayout.svelte
@@ -0,0 +1,103 @@
+<!--
+@component
+Renders a set of `Shelf` items in either a horizontal shelf
+or a grid, depending on the `shelf` configuration
+
+Note: when configuring the `gridType` property, a single value will be used
+for both the shelf-based or grid-based item layouts. If two different grid types
+are needed instead, `gridTypeForShelf` and `gridTypeForGrid` are needed instead;
+these properties cannot be used alongside the general-purpose `gridType`.
+-->
+<script lang="ts" generics="Item">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+
+ import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
+
+ import type { XOR } from '~/utils/types';
+ import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
+ import Grid from '~/components/Grid.svelte';
+
+ /**
+ * The sub-set of {@linkcode Shelf} that is necesary to render this component
+ */
+ interface RequiredShelf
+ extends Pick<Shelf, 'rowsPerColumn' | 'isHorizontal'> {
+ items: Item[];
+ }
+
+ interface $$Slots {
+ default: {
+ item: Item;
+ };
+ }
+
+ /**
+ * Represents the `gridType` properties of this component
+ *
+ * Either a `gridType` that will be used for both the shelf or grid
+ * layouts can be provided, OR specific properties for the grid type
+ * for the shelf and grid respectively; this `XOR` here prevents
+ * these approachs from being mixed-and-matched.
+ */
+ type GeneralOrIndividualGridType = XOR<
+ {
+ gridType: GridType;
+ },
+ {
+ gridTypeForGrid: GridType;
+ gridTypeForShelf: GridType;
+ }
+ >;
+
+ type $$Props = GeneralOrIndividualGridType & {
+ shelf: RequiredShelf;
+ rowsPerColumnOverride?: number | null;
+ };
+
+ /**
+ * The shelf to render items for
+ */
+ export let shelf: RequiredShelf;
+
+ /**
+ * An optional override of the shelfs `rowsPerColumn` property
+ */
+ export let rowsPerColumnOverride: number | null = null;
+
+ /**
+ * Determine the grid type configuration for the shelf or grid layouts
+ * based on the mutually-exclusive properties of {@linkcode GeneralOrIndividualGridType}
+ */
+ function extractGridTypes(props: $$Props) {
+ if (typeof props.gridType === 'string') {
+ return {
+ gridTypeForShelf: props.gridType,
+ gridTypeForGrid: props.gridType,
+ };
+ } else {
+ return props;
+ }
+ }
+
+ $: ({ gridTypeForShelf, gridTypeForGrid } = extractGridTypes(
+ $$props as $$Props,
+ ));
+
+ $: isHorizontal = shelf.isHorizontal;
+ $: gridRows = rowsPerColumnOverride ?? shelf.rowsPerColumn ?? undefined;
+</script>
+
+{#if isHorizontal}
+ <HorizontalShelf
+ items={shelf.items}
+ {gridRows}
+ gridType={gridTypeForShelf}
+ let:item
+ >
+ <slot {item} />
+ </HorizontalShelf>
+{:else}
+ <Grid items={shelf.items} gridType={gridTypeForGrid} let:item>
+ <slot {item} />
+ </Grid>
+{/if}
diff --git a/src/components/StarRating.svelte b/src/components/StarRating.svelte
new file mode 100644
index 0000000..84da44b
--- /dev/null
+++ b/src/components/StarRating.svelte
@@ -0,0 +1,80 @@
+<script lang="ts" context="module">
+ export function calculateStarFillPercentages(rating: number) {
+ return [1, 2, 3, 4, 5].map((position) => {
+ if (position <= Math.floor(rating)) {
+ return 100;
+ }
+
+ if (position > Math.ceil(rating)) {
+ return 0;
+ }
+
+ return Math.round((rating % 1) * 100);
+ });
+ }
+</script>
+
+<script lang="ts">
+ import StarFilledIcon from '@amp/web-app-components/assets/icons/star-filled.svg';
+ import StarHollowIcon from '@amp/web-app-components/assets/icons/star-hollow.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ export let rating: number;
+
+ const i18n = getI18n();
+
+ $: starFillPercentages = calculateStarFillPercentages(rating);
+ $: label = $i18n.t('ASE.Web.AppStore.Review.StarsAria', {
+ count: rating,
+ });
+</script>
+
+<ol class="stars" aria-label={label}>
+ {#each starFillPercentages as fillPercent}
+ <li class="star">
+ {#if fillPercent === 100}
+ <StarFilledIcon />
+ {:else if fillPercent === 0}
+ <StarHollowIcon />
+ {:else}
+ <div
+ class="partial-star"
+ style:--partial-star-width={`${fillPercent}%`}
+ >
+ <StarFilledIcon />
+ </div>
+
+ <StarHollowIcon />
+ {/if}
+ </li>
+ {/each}
+</ol>
+
+<style>
+ .stars {
+ display: flex;
+ }
+
+ .star {
+ position: relative;
+ margin-inline-end: 2px;
+ line-height: 0;
+ }
+
+ .star :global(svg) {
+ height: var(--star-size, 10px);
+ width: var(--star-size, 10px);
+ fill: var(--fill-color, rgb(127, 127, 127));
+ }
+
+ .partial-star {
+ position: absolute;
+ overflow: hidden;
+ width: var(--partial-star-width);
+ fill: var(--fill-color, rgb(127, 127, 127));
+ }
+
+ .partial-star :global(path) {
+ stroke: transparent;
+ }
+</style>
diff --git a/src/components/SystemImage.svelte b/src/components/SystemImage.svelte
new file mode 100644
index 0000000..40723dd
--- /dev/null
+++ b/src/components/SystemImage.svelte
@@ -0,0 +1,52 @@
+<!--
+@component
+Renders an `Artwork` view model that references an SF Symbol through a `systemimage://` or `resource://` template URL
+-->
+<script lang="ts" context="module">
+ import type { Artwork } from '@jet-app/app-store/api/models';
+
+ const systemImagePrefix = 'systemimage://';
+ const resourcePrefix = 'resource://';
+
+ type SystemImageTemplate = `${typeof systemImagePrefix}${string}`;
+ type ResourceTemplate = `${typeof resourcePrefix}${string}`;
+
+ /**
+ * An {@linkcode Artwork} that references a system image
+ */
+ interface FullSystemImageArtwork extends Artwork {
+ template: SystemImageTemplate | ResourceTemplate;
+ }
+
+ /**
+ * The sub-set of {@linkcode FullSystemImageArtwork} required to render
+ * the icon
+ */
+ type SystemImageArtwork = Pick<FullSystemImageArtwork, 'template'>;
+
+ /**
+ * Determine if some {@linkcode Artwork} represents a "system image"
+ */
+ export function isSystemImageArtwork(
+ artwork: Artwork,
+ ): artwork is FullSystemImageArtwork {
+ return (
+ artwork.template.startsWith(systemImagePrefix) ||
+ artwork.template.startsWith(resourcePrefix)
+ );
+ }
+
+ export function getIconNameFromTemplate(template: string) {
+ return new URL(template).host;
+ }
+</script>
+
+<script lang="ts">
+ import SFSymbol from '~/components/SFSymbol.svelte';
+
+ export let artwork: SystemImageArtwork;
+
+ $: name = getIconNameFromTemplate(artwork.template);
+</script>
+
+<SFSymbol {name} />
diff --git a/src/components/VideoPlayer.svelte b/src/components/VideoPlayer.svelte
new file mode 100644
index 0000000..8012b9f
--- /dev/null
+++ b/src/components/VideoPlayer.svelte
@@ -0,0 +1,412 @@
+<script lang="ts">
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import MotionArtwork from '~/components/MotionArtwork.svelte';
+ import { getJet } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+ import type { Video } from '@jet-app/app-store/api/models';
+ import {
+ MetricsActionDetails,
+ MetricsActionType,
+ type MetricsActionDetailItem,
+ type MetricsActionTypeItem,
+ } from '~/constants/media-metrics';
+
+ /** HTML `id` attribute for the <video /> element */
+ export let id: string;
+
+ /** Source URL for the video, an HLS playlist ending in .m3u8 */
+ export let src: string;
+
+ /** Poster image to show while the video is loading */
+ export let poster: string | undefined;
+
+ /** If the video should play automatically when in view */
+ export let autoplay: boolean = false;
+
+ /* The whole-number percentage amount of the video needs to be in view before autoplay kicks in */
+ export let autoplayVisibilityThreshold: number = 0;
+
+ /** If the video should loop from end to start. */
+ export let loop: boolean = false;
+
+ /** If the audio should be muted on the video. */
+ export let muted: boolean = true;
+
+ /** If our controls should be shown in the video player. */
+ export let useControls: boolean = true;
+
+ /** The constructor to use for creating an Hls playback session. */
+ export let HLS: Window['Hls'] = window.Hls;
+
+ /**
+ * If we should bypass the `poster` attribute on the `video` tag, in favor of having the poster
+ * image overlaid as it's own DOM element, which covers an HLS playback bug in Safari, wherein
+ * the video is seeked to the first frame once the metadata is loaded, thus removing the poster.
+ */
+ export let shouldSuperimposePosterImage: boolean = false;
+
+ /** an optional metric template provided by jet */
+ export let metricsTemplate:
+ | Record<string, unknown>
+ | Video['templateMediaEvent'] = {};
+
+ export function play(isAutoPlay = true) {
+ videoRef?.play();
+ recordMediaEvent(
+ MetricsActionType.PLAY,
+ isAutoPlay
+ ? MetricsActionDetails.AUTOPLAY
+ : MetricsActionDetails.PLAY,
+ );
+ }
+
+ export function pause(isAutoPause = true) {
+ recordMediaEvent(
+ MetricsActionType.STOP,
+ isAutoPause
+ ? MetricsActionDetails.AUTOPAUSE
+ : MetricsActionDetails.PAUSE,
+ );
+
+ videoRef?.pause();
+ }
+
+ let isPaused: boolean = !autoplay;
+ let isMuted: boolean = muted;
+ let shouldShowReplayControl: boolean = false;
+ let shouldShowPlaybackControls: boolean = true;
+ let hasPlaybackBeenInitiated: boolean = false;
+ let videoRef: HTMLVideoElement | null = null;
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ const handleFullScreenButtonClick = () => {
+ videoRef?.requestFullscreen();
+ };
+
+ const handleReplayButtonClick = () => {
+ if (videoRef) {
+ videoRef.currentTime = 0;
+ videoRef.play();
+ shouldShowPlaybackControls = true;
+ }
+ };
+
+ const handlePlayButtonClick = () => {
+ if (isPaused) {
+ play(false);
+ } else {
+ pause(false);
+ }
+ };
+
+ const handleMuteButtonClick = () => {
+ isMuted = !isMuted;
+ };
+
+ const handleVideoEnded = () => {
+ if (!loop) {
+ shouldShowPlaybackControls = true;
+
+ if (videoRef) {
+ videoRef.currentTime = 1;
+ videoRef.pause();
+ }
+
+ recordMediaEvent(
+ MetricsActionType.STOP,
+ MetricsActionDetails.COMPLETE,
+ );
+ }
+ };
+
+ const handleVideoPlay = () => {
+ // Display the replay button after the first play
+ shouldShowReplayControl = true;
+ hasPlaybackBeenInitiated = true;
+ };
+
+ // metric events that are waiting for loadMetadata from video element
+ let queuedMetricEvents: Array<() => void> = [];
+
+ // flush any metric events once load metadata has been called
+ const flushMetricEvents = () => {
+ queuedMetricEvents.forEach((recordFn) => recordFn());
+
+ queuedMetricEvents = [];
+ };
+
+ const recordMediaEvent = (
+ actionType: MetricsActionTypeItem,
+ actionDetail: MetricsActionDetailItem,
+ ) => {
+ if (!metricsTemplate?.fields) {
+ return;
+ }
+
+ const recordEvent = () => {
+ const duration = Math.floor(videoRef?.duration ?? 0) * 1000;
+ const position = Math.min(
+ Math.floor((videoRef?.currentTime ?? 0) * 1000),
+ duration,
+ );
+ jet.recordCustomMetricsEvent({
+ ...(metricsTemplate?.fields ?? {}),
+ actionType: actionType,
+ actionDetails: actionDetail,
+ url: src,
+ duration,
+ position,
+ topic: metricsTemplate?.topic ?? '',
+ });
+ };
+
+ if (Number.isNaN(videoRef?.duration)) {
+ queuedMetricEvents.push(() => recordEvent());
+ } else {
+ recordEvent();
+ }
+ };
+
+ const isVideoPlaying = (video: HTMLVideoElement | null) => {
+ if (!video) {
+ return false;
+ }
+ return !!(
+ video.currentTime > 0 &&
+ !video.paused &&
+ !video.ended &&
+ video.readyState > 2
+ );
+ };
+
+ const intersectionObserverConfig = {
+ threshold: autoplayVisibilityThreshold,
+ callback: (isIntersectingViewport: boolean) => {
+ if (isIntersectingViewport) {
+ play();
+ } else if (isVideoPlaying(videoRef)) {
+ pause();
+ }
+ },
+ };
+</script>
+
+<div
+ class="video-container"
+ use:intersectionObserver={autoplay ? intersectionObserverConfig : undefined}
+>
+ <div class="video">
+ <MotionArtwork
+ {id}
+ {HLS}
+ {src}
+ {loop}
+ poster={!shouldSuperimposePosterImage ? poster : undefined}
+ bind:muted={isMuted}
+ bind:paused={isPaused}
+ bind:videoElement={videoRef}
+ on:play={handleVideoPlay}
+ on:ended={handleVideoEnded}
+ on:loadedmetadata={flushMetricEvents}
+ />
+ </div>
+
+ {#if shouldSuperimposePosterImage && !hasPlaybackBeenInitiated}
+ <img
+ src={poster}
+ class="fake-poster"
+ aria-hidden="true"
+ loading="lazy"
+ alt=""
+ />
+ {/if}
+
+ {#if useControls}
+ <div class="video-control">
+ {#if shouldShowReplayControl}
+ <button
+ class="video-control-replay"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Replay',
+ )}
+ on:click={handleReplayButtonClick}
+ >
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-replay.png"
+ alt={$i18n.t('ASE.Web.AppStore.VideoPlayer.AX.Replay')}
+ aria-hidden="true"
+ />
+ </button>
+ {/if}
+
+ {#if shouldShowPlaybackControls}
+ <div class="video-control-playback">
+ <button
+ class="video-control-play"
+ aria-label={$i18n.t(
+ isPaused
+ ? 'ASE.Web.AppStore.VideoPlayer.AX.Play'
+ : 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
+ )}
+ on:click={handlePlayButtonClick}
+ >
+ {#if isPaused}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-play.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Play',
+ )}
+ aria-hidden="true"
+ />
+ {:else}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-pause.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
+ )}
+ aria-hidden="true"
+ />
+ {/if}
+ </button>
+
+ <button
+ class="video-control-unmute"
+ aria-label={$i18n.t(
+ isMuted
+ ? 'ASE.Web.AppStore.VideoPlayer.AX.Unmute'
+ : 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
+ )}
+ on:click={handleMuteButtonClick}
+ >
+ {#if isMuted}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-volume-muted.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
+ )}
+ aria-hidden="true"
+ />
+ {:else}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-volume.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Unmute',
+ )}
+ aria-hidden="true"
+ />
+ {/if}
+ </button>
+
+ <button
+ class="video-control-fullscreen"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
+ )}
+ on:click={handleFullScreenButtonClick}
+ >
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-fullscreen.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
+ )}
+ aria-hidden="true"
+ />
+ </button>
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .video-container {
+ --button-size: 32px;
+ display: grid;
+ position: relative;
+ container-type: inline-size;
+ container-name: video-container;
+ width: 100%;
+ height: 100%;
+ background-color: var(--systemQuaternary);
+ }
+
+ .video {
+ width: 100%;
+ height: 100%;
+ grid-column: 1;
+ grid-row: 1;
+ line-height: 0;
+ }
+
+ .video-control {
+ grid-column: 1;
+ grid-row: 1;
+ display: inline-flex;
+ justify-content: space-between;
+ z-index: 1;
+ align-self: end;
+ color: white;
+ margin: 0 12px 12px;
+ }
+
+ .video-control::after {
+ position: absolute;
+ content: '';
+ z-index: -1;
+ bottom: 0;
+ left: 0;
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ height: calc(var(--button-size) * 2);
+ background: linear-gradient(
+ 0deg,
+ rgb(0, 0, 0, 0.68),
+ rgb(0, 0, 0, 0.2),
+ transparent
+ );
+ mask-image: linear-gradient(360deg, #000 47%, transparent);
+ }
+
+ .video-control-playback {
+ display: inline-flex;
+ margin-inline-start: auto;
+ gap: 6px;
+ }
+
+ .btn-img {
+ height: var(--button-size);
+ width: var(--button-size);
+ border-radius: 50%;
+ border: 1px solid var(--systemQuaternary-onDark);
+ background: rgba(0, 0, 0, 0.11);
+ backdrop-filter: blur(20px);
+ object-fit: cover;
+ transition: background 105ms ease-out;
+ }
+
+ .btn-img:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ @container video-container (max-width: 500px) {
+ .btn-img {
+ --button-size: 24px;
+ }
+ }
+
+ .fake-poster {
+ width: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+</style>
diff --git a/src/components/decorators/HlsJSDecorator.svelte b/src/components/decorators/HlsJSDecorator.svelte
new file mode 100644
index 0000000..591cb0d
--- /dev/null
+++ b/src/components/decorators/HlsJSDecorator.svelte
@@ -0,0 +1,67 @@
+<script lang="ts" context="module">
+ // This store is used to keep track of in-flight requests, ensuring that we don't attempt
+ // to load the same src (which is stored in the Map key) multiple times.
+ const inFlightRequests = new Map<string, Promise<void>>();
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { generateHLSJSURL } from '~/config/hlsjs';
+ import { generateRTCJSURL } from '~/config/rtcjs';
+
+ export let version: string | undefined = undefined;
+
+ let hlsjsSourceURL = generateHLSJSURL(version).toString();
+ let rtcjsSourceURL = generateRTCJSURL(version).toString();
+
+ function loadScript(src: string): Promise<void> {
+ // If we have an in-flight request for this `src`, return it.
+ const inFlightRequest = inFlightRequests.get(src);
+ if (inFlightRequest) {
+ return inFlightRequest;
+ }
+
+ const promise = new Promise<void>(function (resolve, reject) {
+ const scriptElement = document.createElement('script');
+ scriptElement.src = src;
+ scriptElement.onload = () => resolve();
+ scriptElement.onerror = () => {
+ // If a script fails to load due to a network blip, we remove it from the store,
+ // so that the next attempt in an `onMount` will try to load the `src` again.
+ inFlightRequests.delete(src);
+ reject();
+ };
+
+ document.head.appendChild(scriptElement);
+ });
+
+ // Add the given `src` to the store so we can keep track of in-flight requests.
+ inFlightRequests.set(src, promise);
+
+ return promise;
+ }
+
+ let loading: Promise<[void, void]> | undefined;
+
+ onMount(() => {
+ loading = Promise.all([
+ window.Hls ?? loadScript(hlsjsSourceURL),
+ window.rtc ?? loadScript(rtcjsSourceURL),
+ ]);
+ });
+</script>
+
+{#if loading}
+ {#await loading}
+ <slot name="loading-component" />
+ {:then}
+ <slot HLS={window.Hls} RTC={window.rtc} />
+ {:catch}
+ <div>
+ <p>
+ Failed to load HLS.js {version} from
+ <a href={hlsjsSourceURL}>{hlsjsSourceURL}</a>
+ </p>
+ </div>
+ {/await}
+{/if}
diff --git a/src/components/hero/AppLockupDetail.svelte b/src/components/hero/AppLockupDetail.svelte
new file mode 100644
index 0000000..e4abe47
--- /dev/null
+++ b/src/components/hero/AppLockupDetail.svelte
@@ -0,0 +1,109 @@
+<!--
+@component
+Component for rendering App information into the `details` slot
+of the `Hero.svelte` component
+-->
+<script lang="ts">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+
+ import { getI18n } from '~/stores/i18n';
+ import AppIcon from '~/components/AppIcon.svelte';
+
+ const i18n = getI18n();
+
+ export let lockup: Lockup;
+ export let isOnDarkBackground: boolean = true;
+</script>
+
+<div class="lockup-container">
+ {#if lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={lockup.icon} profile="app-icon-small" />
+ </div>
+ {/if}
+
+ <div class="text-container">
+ {#if lockup.heading}
+ <LineClamp clamp={1}>
+ <h4>{lockup.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if lockup.title}
+ <LineClamp clamp={2}>
+ <h3>{lockup.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if lockup.subtitle}
+ <LineClamp clamp={1}>
+ <p>{lockup.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <div class="button-container">
+ <span
+ class="get-button"
+ class:transparent={isOnDarkBackground}
+ class:dark-gray={!isOnDarkBackground}
+ >
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+</div>
+
+<style lang="scss">
+ .lockup-container {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ max-width: 350px;
+ margin-top: 20px;
+ padding-top: 20px;
+ color: var(--hero-primary-color, var(--systemPrimary-onDark));
+ border-top: 1px solid
+ var(--hero-divider-color, var(--systemQuaternary-onDark));
+
+ @media (--range-xsmall-down) {
+ text-align: left;
+ padding: 20px 0 10px;
+ max-width: unset;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 64px;
+ margin-inline-end: 16px;
+ }
+
+ .text-container {
+ width: 100%;
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ font: var(--title-3-emphasized);
+ text-wrap: pretty;
+ }
+
+ h4 {
+ color: var(--hero-secondary-color, var(--systemSecondary-onDark));
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
+ }
+
+ p {
+ mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
+ }
+
+ .button-container {
+ --get-button-font: var(--title-3-bold);
+ position: relative;
+ z-index: 1;
+ }
+</style>
diff --git a/src/components/hero/Carousel.svelte b/src/components/hero/Carousel.svelte
new file mode 100644
index 0000000..218813b
--- /dev/null
+++ b/src/components/hero/Carousel.svelte
@@ -0,0 +1,132 @@
+<!--
+@component
+Component for rendering a carousel of `Hero.svelte` components in a way taht's decoupled from
+any particular data model
+-->
+<script lang="ts" generics="Item">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { Artwork, Shelf } from '@jet-app/app-store/api/models';
+
+ import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import mediaQueries from '~/utils/media-queries';
+ import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
+ import HeroCarouselBackgroundPortal, {
+ id as portalId,
+ } from '~/components/hero/CarouselBackgroundPortal.svelte';
+ import AmbientBackgroundArtwork from '~/components/AmbientBackgroundArtwork.svelte';
+ import portal from '~/utils/portal';
+ import { carouselMediaStyle } from '~/stores/carousel-media-style';
+
+ interface $$Slots {
+ default: {
+ /**
+ * The `Item` to render as a `Hero` in the carousel
+ */
+ item: Item;
+ };
+ }
+
+ /**
+ * The shelf being rendered
+ *
+ * Used to derrive any shelf-specific presentation
+ */
+ export let shelf: Shelf;
+
+ /**
+ * The items to render in the hero carousel
+ *
+ * This is decoupled from `shelf` to avoid assuming that `shelf.items` is, itself,
+ * the set of items that we need to present; some shelves model their items as chilren
+ * of the first shelf item.
+ */
+ export let items: Item[];
+
+ /**
+ * Callback that determines the "background artwork" to use behind the
+ * active `Hero` for the given `Item`
+ */
+ export let deriveBackgroundArtworkFromItem: (item: Item) => Opt<Artwork>;
+
+ $: gridRows = shelf.rowsPerColumn ?? undefined;
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+
+ let activeIndex: number | undefined = 0;
+
+ function createIntersectionObserverCallback(index: number) {
+ return (isIntersectingViewport: boolean) => {
+ if (isIntersectingViewport) {
+ // Many different types of `item`s can be rendered in this Carousel, and all those
+ // different items have different ways of determining whether or not the background
+ // is dark or light, so we are running through all the options here.
+ const { style, mediaOverlayStyle, isMediaDark } = items[
+ index
+ ] as any;
+ const fallbackStyle = 'dark';
+ let derivedStyle;
+
+ if (typeof isMediaDark !== 'undefined') {
+ derivedStyle = isMediaDark ? 'dark' : 'light';
+ }
+
+ carouselMediaStyle.set(
+ style || mediaOverlayStyle || derivedStyle || fallbackStyle,
+ );
+
+ activeIndex = index;
+ }
+ };
+ }
+</script>
+
+<HeroCarouselBackgroundPortal />
+
+<ShelfWrapper {shelf} --shelfGridGutterWidth="0">
+ <HorizontalShelf
+ {gridRows}
+ {items}
+ --shelfScrollPaddingInline="0"
+ --grid-max-content-xsmall={!$sidebarIsHidden
+ ? 'calc(100% + 50px)'
+ : '100vw'}
+ gridType="Spotlight"
+ let:item
+ let:index
+ >
+ {#if isXSmallViewport}
+ <div
+ use:intersectionObserver={{
+ callback: createIntersectionObserverCallback(index),
+ threshold: 0.5,
+ }}
+ >
+ <slot {item} />
+ </div>
+ {:else}
+ <div
+ use:intersectionObserver={{
+ callback: createIntersectionObserverCallback(index),
+ threshold: 0,
+ }}
+ >
+ {#if !import.meta.env.SSR}
+ {@const backgroundArtwork =
+ deriveBackgroundArtworkFromItem(item)}
+
+ {#if backgroundArtwork}
+ <div use:portal={portalId}>
+ <AmbientBackgroundArtwork
+ artwork={backgroundArtwork}
+ active={activeIndex === index}
+ />
+ </div>
+ {/if}
+ {/if}
+
+ <slot {item} />
+ </div>
+ {/if}
+ </HorizontalShelf>
+</ShelfWrapper>
diff --git a/src/components/hero/CarouselBackgroundPortal.svelte b/src/components/hero/CarouselBackgroundPortal.svelte
new file mode 100644
index 0000000..4580ce0
--- /dev/null
+++ b/src/components/hero/CarouselBackgroundPortal.svelte
@@ -0,0 +1,17 @@
+<script lang="ts" context="module">
+ export const id = 'hero-carousel-shelf-background-portal';
+</script>
+
+<div {id} />
+
+<style>
+ #hero-carousel-shelf-background-portal {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ z-index: -1;
+ }
+</style>
diff --git a/src/components/hero/Hero.svelte b/src/components/hero/Hero.svelte
new file mode 100644
index 0000000..f643ffa
--- /dev/null
+++ b/src/components/hero/Hero.svelte
@@ -0,0 +1,536 @@
+<!--
+@component
+Component for rendering an item in a "Hero Carousel" without coupling to any specific data model
+-->
+<script lang="ts">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type {
+ Action,
+ Artwork as ArtworkModel,
+ Color,
+ Video as VideoModel,
+ } from '@jet-app/app-store/api/models';
+
+ import mediaQueries from '~/utils/media-queries';
+ import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+ import {
+ colorAsString,
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+
+ /**
+ * The main text for the carousel item
+ */
+ export let title: Opt<string> = undefined;
+
+ /**
+ * Additional text above the title.
+ * Note: If a slot is defined with the name `eyebrow`, the slot takes precedence.
+ */
+ export let eyebrow: Opt<string> = undefined;
+
+ /**
+ * Additional text below the title
+ */
+ export let subtitle: Opt<string> = undefined;
+
+ /**
+ * Primary accent color for the carousel item
+ */
+ export let backgroundColor: Opt<Color> = undefined;
+
+ /**
+ * Static artwork to display in the carousel item
+ */
+ export let artwork: Opt<ArtworkModel> = undefined;
+
+ /**
+ * Video to display in the carousel item
+ *
+ * Takes precedence over `artwork`
+ */
+ export let video: Opt<VideoModel> = undefined;
+
+ /**
+ * Action to perform when clicking on the carousel item
+ */
+ export let action: Opt<Action> = undefined;
+
+ /**
+ * Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container
+ */
+ export let pinArtworkToHorizontalEnd: boolean = false;
+
+ /**
+ * Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default)
+ */
+ export let pinArtworkToVerticalMiddle: boolean = false;
+
+ /**
+ * Whether the text (e.g. title, description, etc) should be pinned to the top of the container
+ */
+ export let pinTextToVerticalStart: boolean = false;
+
+ /**
+ * Allows for the absolute overriding of the profile used for the Hero artwork
+ */
+ export let profileOverride: Opt<NamedProfile> = null;
+
+ export let isMediaDark: boolean = true;
+
+ export let collectionIcons: ArtworkModel[] | undefined = undefined;
+
+ let isPortraitLayout: boolean;
+ let profile: NamedProfile;
+ let collectionIconsBackgroundGradientCssVars: string | undefined =
+ undefined;
+
+ $: isPortraitLayout = $mediaQueries === 'xsmall';
+
+ $: {
+ if (profileOverride) {
+ profile = profileOverride;
+ } else if (isPortraitLayout) {
+ profile = 'large-hero-portrait';
+ } else if (pinArtworkToHorizontalEnd && isRtl()) {
+ profile = 'large-hero-east';
+ } else if (pinArtworkToHorizontalEnd) {
+ profile = 'large-hero-west';
+ } else {
+ profile = 'large-hero';
+ }
+ }
+
+ const color: string = backgroundColor
+ ? colorAsString(backgroundColor)
+ : '#000';
+
+ if (collectionIcons && collectionIcons.length > 1) {
+ // If there are multiple app icons, we build a string of CSS variables from the icons
+ // background colors to fill as many of the lockups quadrants as possible.
+ collectionIconsBackgroundGradientCssVars =
+ getBackgroundGradientCSSVarsFromArtworks(collectionIcons, {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ shouldRemoveGreys: true,
+ });
+ }
+</script>
+
+<LinkWrapper {action} includeExternalLinkArrowIcon={false}>
+ <article
+ data-test-id="hero"
+ class:with-dark-media={isMediaDark}
+ class:with-collection-icons={!artwork && !video && collectionIcons}
+ class:text-pinned-to-vertical-start={pinTextToVerticalStart}
+ >
+ {#if video || artwork}
+ <div
+ class={`image-container ${profile}`}
+ class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd}
+ class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle}
+ style:--color={color}
+ >
+ {#if video && !$prefersReducedMotion}
+ <Video
+ loop
+ autoplay
+ useControls={false}
+ {video}
+ {profile}
+ />
+ {:else if artwork}
+ <Artwork
+ {artwork}
+ {profile}
+ noShelfChevronAnchor={true}
+ useCropCodeFromArtwork={false}
+ withoutBorder={true}
+ />
+ {/if}
+ </div>
+ {:else if collectionIcons}
+ <ul class="app-icons">
+ {#each collectionIcons?.slice(0, 5) as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon
+ icon={collectionIcon}
+ profile="app-icon-large"
+ fixedWidth={false}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ <div
+ class="collection-icons-background-gradient"
+ style={collectionIconsBackgroundGradientCssVars}
+ />
+ {/if}
+
+ <div class="gradient" style="--color: {color};" />
+
+ <slot name="badge" {isPortraitLayout} />
+
+ <div class="metadata-container">
+ {#if $$slots.eyebrow}
+ <h3><slot name="eyebrow" /></h3>
+ {:else if eyebrow}
+ <h3>{eyebrow}</h3>
+ {/if}
+
+ {#if title}
+ <h2>{@html sanitizeHtml(title)}</h2>
+ {/if}
+
+ {#if subtitle}
+ <p class="subtitle">{@html sanitizeHtml(subtitle)}</p>
+ {/if}
+
+ <slot name="details" {isPortraitLayout} />
+ </div>
+ </article>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ article {
+ --hero-primary-color: var(--systemPrimary-onLight);
+ --hero-secondary-color: var(--systemSecondary-onLight);
+ --hero-text-blend-mode: normal;
+ --hero-divider-color: var(--systemQuaternary-onLight);
+ position: relative;
+ display: flex;
+ overflow: hidden;
+ align-items: end;
+ aspect-ratio: 3 / 4;
+ container-name: hero-container;
+ container-type: size;
+
+ @media (--range-small-up) {
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ height: auto;
+ min-height: 360px;
+ max-height: min(60vh, 770px);
+ border-radius: var(--global-border-radius-large);
+ border: 1px solid var(--systemQuaternary);
+ }
+ }
+
+ article.with-dark-media,
+ article.with-collection-icons {
+ --hero-primary-color: var(--systemPrimary-onDark);
+ --hero-secondary-color: var(--systemSecondary-onDark);
+ --hero-divider-color: var(--systemQuaternary-onDark);
+ --hero-text-blend-mode: plus-lighter;
+ }
+
+ .image-container {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ background-color: var(--color);
+ }
+
+ .image-container.pinned-to-vertical-middle {
+ display: flex;
+ align-items: center;
+ }
+
+ .image-container.pinned-to-vertical-middle :global(.video-container),
+ .image-container.pinned-to-vertical-middle :global(.artwork-component) {
+ width: 100%;
+ height: auto;
+ }
+
+ .image-container.pinned-to-horizontal-end :global(.artwork-component) {
+ height: 100%;
+ display: flex;
+ }
+
+ .image-container.pinned-to-horizontal-end :global(.artwork-component img) {
+ height: 100%;
+ width: auto;
+ position: absolute;
+ inset-inline-end: 0;
+
+ @container hero-container (aspect-ratio >= 279/100) {
+ width: 100%;
+ height: auto;
+ }
+ }
+
+ .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl
+ :global(.artwork-component img) {
+ inset-inline-start: 0;
+ }
+
+ // This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of
+ // 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the
+ // artwork full-width rather than full-height. This should eventually be fixed when Editorial
+ // can prescribe us only 16x9 (1.77:1) hero images.
+ .image-container.pinned-to-horizontal-end.large-hero-story-card,
+ .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl {
+ @container hero-container (aspect-ratio >= 225/100) {
+ :global(.artwork-component img) {
+ width: 100%;
+ height: auto;
+ }
+ }
+ }
+
+ .metadata-container {
+ position: absolute;
+ width: 40%;
+ padding-bottom: 40px;
+ padding-inline-start: 40px;
+ text-wrap: pretty;
+ color: var(--hero-primary-color);
+
+ @media (--range-small-only) {
+ width: 50%;
+ padding: 0 20px 20px;
+ }
+
+ @media (--range-xsmall-down) {
+ width: 100%;
+ padding: 0 20px 20px;
+ text-align: center;
+ }
+ }
+
+ .text-pinned-to-vertical-start .metadata-container {
+ @media (--range-small-only) {
+ top: 20px;
+ }
+
+ @media (--range-medium-up) {
+ top: 40px;
+ }
+ }
+
+ h2 {
+ position: relative;
+ z-index: 1;
+ text-wrap: balance;
+ font: var(--header-emphasized);
+
+ @media (--range-xsmall-down) {
+ font: var(--title-1-emphasized);
+ }
+ }
+
+ @container hero-container (height < 420px) {
+ h2 {
+ font: var(--large-title-emphasized);
+ }
+ }
+
+ h3 {
+ margin-bottom: 8px;
+ position: relative;
+ z-index: 1;
+ color: var(--hero-secondary-color);
+ font: var(--callout-emphasized-tall);
+ mix-blend-mode: var(--hero-text-blend-mode);
+
+ @media (--range-xsmall-down) {
+ margin-bottom: 4px;
+ }
+ }
+
+ p {
+ mix-blend-mode: var(--hero-text-blend-mode);
+ }
+
+ .subtitle {
+ margin-top: 8px;
+ position: relative;
+ z-index: 1;
+ font: var(--body-tall);
+ color: var(--hero-secondary-color);
+ }
+
+ .gradient {
+ --rotation: 55deg;
+
+ &:dir(rtl) {
+ --rotation: -55deg;
+ mask-image: radial-gradient(
+ ellipse 127% 130% at 95% 100%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 24%,
+ rgba(0, 0, 0, 0.66) 32%,
+ transparent 40%
+ ),
+ linear-gradient(
+ -129deg,
+ rgb(0, 0, 0) 0%,
+ rgba(255, 255, 255, 0) 55%
+ );
+ }
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ // stylelint-disable color-function-notation
+ background: linear-gradient(
+ var(--rotation),
+ rgb(from var(--color) r g b / 0.25) 0%,
+ transparent 50%
+ );
+ // stylelint-enable color-function-notation
+ filter: saturate(1.5) brightness(0.9);
+ backdrop-filter: blur(40px);
+ mask-image: radial-gradient(
+ ellipse 127% 130% at 5% 100%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 24%,
+ rgba(0, 0, 0, 0.66) 32%,
+ transparent 40%
+ ),
+ linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%);
+
+ @media (--range-xsmall-down) {
+ --rotation: 0deg;
+ mask-image: linear-gradient(
+ var(--rotation),
+ rgb(0, 0, 0) 28%,
+ rgba(0, 0, 0, 0) 56%
+ );
+ }
+ }
+
+ // When the text is pinned to the top of the lockup, we use a different gradient for legibility
+ article.text-pinned-to-vertical-start .gradient {
+ --rotation: -170deg;
+ mask-image: radial-gradient(
+ ellipse 118% 121% at 100% 0%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 22%,
+ rgba(0, 0, 0, 0.66) 33%,
+ transparent 43%
+ );
+ }
+
+ .app-icons {
+ display: grid;
+ align-self: center;
+ width: 90%;
+ grid-template-rows: auto auto;
+ grid-auto-flow: column;
+ gap: 24px;
+ margin-inline-start: -4%;
+ position: absolute;
+ inset-inline-end: 24px;
+
+ @media (--range-small-up) {
+ width: 44%;
+ }
+ }
+
+ .app-icons li:nth-child(even) {
+ inset-inline-start: 44%;
+ }
+
+ .app-icon-container {
+ position: relative;
+ flex-shrink: 0;
+ max-width: 200px;
+ }
+
+ @property --top-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 20%;
+ }
+
+ @property --bottom-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 40%;
+ }
+
+ @property --top-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 55%;
+ }
+
+ @property --bottom-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 50%;
+ }
+
+ .collection-icons-background-gradient {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) var(--top-left-stop),
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) var(--bottom-left-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 66% -175%,
+ var(--top-right, #000) var(--top-right-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) var(--bottom-right-stop),
+ transparent 100%
+ );
+ animation: collection-icons-background-gradient-shift 16s infinite
+ alternate-reverse;
+ animation-play-state: paused;
+
+ @media (--range-small-up) {
+ animation-play-state: running;
+ }
+ }
+
+ @keyframes collection-icons-background-gradient-shift {
+ 0% {
+ --top-left-stop: 20%;
+ --bottom-left-stop: 40%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 50%;
+ background-size: 100% 100%;
+ }
+
+ 50% {
+ --top-left-stop: 25%;
+ --bottom-left-stop: 15%;
+ --top-right-stop: 70%;
+ --bottom-right-stop: 30%;
+ background-size: 130% 130%;
+ }
+
+ 100% {
+ --top-left-stop: 15%;
+ --bottom-left-stop: 20%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 20%;
+ background-size: 110% 110%;
+ }
+ }
+</style>
diff --git a/src/components/icons/AppStoreLogo.svg b/src/components/icons/AppStoreLogo.svg
new file mode 100644
index 0000000..185032f
--- /dev/null
+++ b/src/components/icons/AppStoreLogo.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__PaJpmjhr__" \ No newline at end of file
diff --git a/src/components/icons/AppleArcadeLogo.svg b/src/components/icons/AppleArcadeLogo.svg
new file mode 100644
index 0000000..52902b2
--- /dev/null
+++ b/src/components/icons/AppleArcadeLogo.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20384%2080'%20preserveAspectRatio='xMinYMin%20meet'%20%3e%3cpath%20fill='currentColor'%20d='M43.873%2012.699C46.606%209.28%2048.461%204.69%2047.972%200c-4.001.199-8.883%202.64-11.71%206.06-2.538%202.93-4.784%207.712-4.198%2012.206%204.49.39%208.978-2.245%2011.81-5.567M47.92%2019.144c-6.521-.389-12.067%203.701-15.182%203.701-3.116%200-7.885-3.506-13.044-3.411-6.714.098-12.945%203.895-16.352%209.933-7.008%2012.079-1.85%2029.996%204.966%2039.833%203.31%204.867%207.298%2010.226%2012.553%2010.034%204.966-.195%206.912-3.216%2012.948-3.216%206.032%200%207.785%203.216%2013.041%203.118%205.451-.097%208.859-4.869%2012.168-9.74%203.797-5.549%205.351-10.906%205.449-11.2-.098-.097-10.511-4.092-10.608-16.07-.098-10.03%208.176-14.801%208.565-15.097-4.672-6.91-11.972-7.69-14.503-7.885'%20/%3e%3cpath%20fill='currentColor'%20d='M115.598%2058.881H87.752L81.07%2078.627H69.273L95.651%205.569h12.252l26.377%2073.058h-12l-6.682-19.746zm-24.96-9.113h22.074L101.827%2017.72h-.304L90.638%2049.768zM140.503%2025.365h10.43v9.062h.253c1.773-6.226%206.531-9.923%2012.81-9.923%201.569%200%202.936.253%203.746.406v10.175c-.86-.354-2.784-.607-4.911-.607-7.038%200-11.391%204.71-11.391%2012.252v31.897h-10.937V25.365zM207.744%2043.693c-1.114-5.671-5.367-10.177-12.505-10.177-8.455%200-14.025%207.037-14.025%2018.48%200%2011.695%205.62%2018.48%2014.126%2018.48%206.734%200%2011.138-3.696%2012.404-9.873h10.53c-1.164%2011.34-10.227%2019.036-23.035%2019.036-15.24%200-25.162-10.43-25.162-27.643%200-16.91%209.923-27.593%2025.06-27.593%2013.72%200%2022.074%208.81%2023.036%2019.29h-10.43zM223.9%2063.489c0-9.317%207.14-15.037%2019.797-15.746l14.58-.86v-4.101c0-5.924-4-9.468-10.682-9.468-6.329%200-10.278%203.037-11.24%207.797h-10.328c.607-9.62%208.81-16.708%2021.973-16.708%2012.91%200%2021.163%206.835%2021.163%2017.517v36.707h-10.48v-8.76h-.254c-3.088%205.925-9.821%209.67-16.808%209.67-10.43%200-17.72-6.48-17.72-16.048zm34.378-4.81v-4.202l-13.113.81c-6.532.456-10.227%203.341-10.227%207.898%200%204.657%203.848%207.695%209.72%207.695%207.645%200%2013.62-5.265%2013.62-12.2zM276.853%2051.996c0-16.809%208.91-27.492%2022.276-27.492%207.645%200%2013.721%203.848%2016.707%209.721h.204V5.57h10.986v73.058h-10.632v-9.063h-.203c-3.139%206.075-9.214%209.974-16.96%209.974-13.468%200-22.378-10.734-22.378-27.542zm11.189%200c0%2011.239%205.417%2018.277%2014.075%2018.277%208.404%200%2014.023-7.139%2014.023-18.277%200-11.037-5.619-18.277-14.023-18.277-8.658%200-14.075%207.088-14.075%2018.277zM382.956%2062.982c-1.519%209.72-10.734%2016.657-22.935%2016.657-15.644%200-25.111-10.58-25.111-27.39%200-16.707%209.619-27.846%2024.656-27.846%2014.783%200%2023.997%2010.43%2023.997%2026.58v3.747h-37.616v.658c0%209.265%205.568%2015.39%2014.327%2015.39%206.228%200%2010.835-3.138%2012.303-7.796h10.379zm-36.96-15.897h26.631c-.252-8.15-5.417-13.873-13.061-13.873-7.646%200-13.012%205.823-13.57%2013.873z'%20/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/components/jet/Video.svelte b/src/components/jet/Video.svelte
new file mode 100644
index 0000000..8d2e4f3
--- /dev/null
+++ b/src/components/jet/Video.svelte
@@ -0,0 +1,66 @@
+<script lang="ts">
+ import type { Video } from '@jet-app/app-store/api/models';
+ import VideoPlayer from '../VideoPlayer.svelte';
+ import HlsJsDecorator from '../decorators/HlsJSDecorator.svelte';
+ import { buildPoster } from '~/utils/video-poster';
+ import { generateUuid } from '@amp/web-apps-utils/src';
+ import type { NamedProfile } from 'src/config/components/artwork';
+ import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
+ import mediaQueries from '~/utils/media-queries';
+ import { colorAsString } from '~/utils/color';
+
+ export let video: Video;
+ export let autoplay: boolean = false;
+ export let loop: boolean = false;
+ export let muted: boolean = true;
+ export let useControls: boolean = true;
+ export let profile: NamedProfile | Profile;
+ export let autoplayVisibilityThreshold: number = 0;
+ export let videoPlayerRef: InstanceType<typeof VideoPlayer> | null = null;
+ export let shouldSuperimposePosterImage: boolean = false;
+
+ $: poster =
+ video.preview && buildPoster(video.preview, profile, $mediaQueries);
+ $: backgroundColor = video.preview.backgroundColor
+ ? colorAsString(video.preview.backgroundColor)
+ : '#f1f1f1';
+
+ $: metricsTemplate = video?.templateMediaEvent ?? {};
+ const uuid = generateUuid();
+</script>
+
+<HlsJsDecorator let:HLS>
+ <VideoPlayer
+ {HLS}
+ {loop}
+ {muted}
+ {autoplay}
+ {useControls}
+ {autoplayVisibilityThreshold}
+ {metricsTemplate}
+ {shouldSuperimposePosterImage}
+ id={uuid}
+ src={video.videoUrl}
+ poster={poster ?? undefined}
+ --aspect-ratio={video.preview.width / video.preview.height}
+ bind:this={videoPlayerRef}
+ />
+
+ <div
+ class="loader"
+ slot="loading-component"
+ style:--aspect-ratio={video.preview.width / video.preview.height}
+ style:--background-image={`url(${poster})`}
+ style:--background-color={backgroundColor}
+ />
+</HlsJsDecorator>
+
+<style>
+ .loader {
+ aspect-ratio: var(--aspect-ratio);
+ width: 100%;
+ background-image: var(--background-image);
+ background-color: var(--background-color);
+ background-size: cover;
+ }
+</style>
diff --git a/src/components/jet/action/ExternalUrlAction.svelte b/src/components/jet/action/ExternalUrlAction.svelte
new file mode 100644
index 0000000..e8a2ad6
--- /dev/null
+++ b/src/components/jet/action/ExternalUrlAction.svelte
@@ -0,0 +1,52 @@
+<script lang="ts">
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+ import type { ExternalUrlAction } from '@jet-app/app-store/api/models';
+ import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
+ import { getJetPerform } from '~/jet';
+
+ type AllowedAnchorAttributes = Omit<
+ HTMLAnchorAttributes,
+ // The `href` attribute is not allowed because it will be provided
+ // by the `ExternalUrlAction`
+ 'href'
+ >;
+
+ interface $$Props extends AllowedAnchorAttributes {
+ destination: ExternalUrlAction;
+ includeArrowIcon?: boolean;
+ }
+
+ const perform = getJetPerform();
+
+ export let destination: ExternalUrlAction;
+ export let includeArrowIcon: boolean = true;
+
+ function handleClickAction() {
+ perform(destination);
+ }
+</script>
+
+<a
+ {...$$restProps}
+ data-test-id="external-link"
+ href={destination.url}
+ target="_blank"
+ rel="nofollow noopener noreferrer"
+ on:click={handleClickAction}
+>
+ <slot />
+ {#if includeArrowIcon}
+ <ArrowIcon class="external-link-arrow" aria-hidden="true" />
+ {/if}
+</a>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ a :global(.external-link-arrow) {
+ @include rtl {
+ transform: rotate(-90deg);
+ }
+ }
+</style>
diff --git a/src/components/jet/action/FlowAction.svelte b/src/components/jet/action/FlowAction.svelte
new file mode 100644
index 0000000..3e55e82
--- /dev/null
+++ b/src/components/jet/action/FlowAction.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+ import type { FlowAction } from '@jet-app/app-store/api/models';
+ import { getJetPerform } from '~/jet';
+
+ type AllowedAnchorAttributes = Omit<
+ HTMLAnchorAttributes,
+ // The `href` attribute is not allowed because it will be provided
+ // by the `FlowAction`
+ 'href'
+ >;
+
+ interface $$Props extends AllowedAnchorAttributes {
+ destination: FlowAction;
+ }
+
+ const perform = getJetPerform();
+
+ export let destination: FlowAction;
+
+ // Web cannot support internal protocols, so this guard prevents
+ // them from showing up in anchor tags.
+ $: pageUrl = destination.pageUrl?.includes('x-as3-internal:')
+ ? '#'
+ : destination?.pageUrl;
+
+ function onClick(event: MouseEvent) {
+ event.preventDefault();
+
+ perform(destination);
+ }
+</script>
+
+<a
+ {...$$restProps}
+ href={pageUrl}
+ data-test-id="internal-link"
+ on:click={onClick}
+>
+ <slot />
+</a>
diff --git a/src/components/jet/action/ShelfBasedPageScrollAction.svelte b/src/components/jet/action/ShelfBasedPageScrollAction.svelte
new file mode 100644
index 0000000..9c1c13e
--- /dev/null
+++ b/src/components/jet/action/ShelfBasedPageScrollAction.svelte
@@ -0,0 +1,51 @@
+<script lang="ts" context="module">
+ import type {
+ Action,
+ ShelfBasedPageScrollAction,
+ } from '@jet-app/app-store/api/models';
+
+ export function isShelfBasedPageScrollAction(
+ action: Action,
+ ): action is ShelfBasedPageScrollAction {
+ return (
+ action.$kind === 'ShelfBasedPageScrollAction' && 'shelfId' in action
+ );
+ }
+</script>
+
+<script lang="ts">
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+
+ interface $$Props extends HTMLAnchorAttributes {
+ destination: ShelfBasedPageScrollAction;
+ }
+
+ export let destination: ShelfBasedPageScrollAction;
+
+ function handleLinkClick(e: Event) {
+ const anchorElement = e.currentTarget as HTMLAnchorElement;
+ const hash = anchorElement.hash;
+ const elementToScrollTo = document.querySelector(hash);
+ if (!elementToScrollTo) {
+ return;
+ }
+ elementToScrollTo.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ history.replaceState(null, '', hash);
+ }
+</script>
+
+{#if destination.shelfId}
+ <a
+ {...$$restProps}
+ data-test-id="scroll-link"
+ href={`#${destination.shelfId}`}
+ on:click|preventDefault|stopPropagation={handleLinkClick}
+ >
+ <slot />
+ </a>
+{:else}
+ <slot />
+{/if}
diff --git a/src/components/jet/badge/ContentRatingBadge.svelte b/src/components/jet/badge/ContentRatingBadge.svelte
new file mode 100644
index 0000000..ff3a2c3
--- /dev/null
+++ b/src/components/jet/badge/ContentRatingBadge.svelte
@@ -0,0 +1,61 @@
+<script lang="ts" context="module">
+ import type { Badge, BadgeType } from '@jet-app/app-store/api/models';
+
+ const ARTWORK_TYPE: BadgeType = 'artwork';
+ const CONTENT_RATING_TYPE: BadgeType = 'contentRating';
+ const CONTENT_RATING_KEY = 'contentRating';
+
+ interface ContentRatingBadge extends Badge {
+ type: typeof CONTENT_RATING_TYPE;
+ }
+
+ export function isContentRatingBadge(
+ badge: Badge,
+ ): badge is ContentRatingBadge {
+ return (
+ badge.type === CONTENT_RATING_TYPE ||
+ (badge.key === CONTENT_RATING_KEY && badge.type === ARTWORK_TYPE)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let badge: ContentRatingBadge;
+
+ $: ({ artwork, accessibilityTitle } = badge);
+</script>
+
+{#if artwork && isSystemImageArtwork(artwork)}
+ <div class="pictogram-container" aria-label={accessibilityTitle}>
+ <SystemImage {artwork} />
+ </div>
+{:else}
+ <span>
+ {badge.content.contentRating}
+ </span>
+{/if}
+
+<style>
+ span {
+ height: 25px;
+ margin: 4px 0 2px;
+ font: var(--title-1-emphasized);
+ color: var(--color);
+ }
+
+ .pictogram-container {
+ height: 25px;
+ padding: 2px;
+ aspect-ratio: 1/1;
+ margin: 4px 0 2px;
+ }
+
+ .pictogram-container :global(svg) {
+ width: 21px;
+ height: 21px;
+ }
+</style>
diff --git a/src/components/jet/item/AccessibilityFeaturesItem.svelte b/src/components/jet/item/AccessibilityFeaturesItem.svelte
new file mode 100644
index 0000000..bcbeb6c
--- /dev/null
+++ b/src/components/jet/item/AccessibilityFeaturesItem.svelte
@@ -0,0 +1,159 @@
+<script lang="ts">
+ import type { AccessibilityFeatures } from '@jet-app/app-store/api/models';
+
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let item: AccessibilityFeatures;
+ export let isDetailView: boolean = false;
+</script>
+
+<article
+ class:is-detail-view={isDetailView}
+ role={isDetailView ? 'presentation' : 'article'}
+>
+ {#if !isDetailView}
+ {#if item.artwork && isSystemImageArtwork(item.artwork)}
+ <span class="icon-container" aria-hidden="true">
+ <SystemImage artwork={item.artwork} />
+ </span>
+ {/if}
+ <h2>{item.title}</h2>
+ {/if}
+
+ <ul class:grid={item.features.length > 1 && !isDetailView}>
+ {#each item.features as feature}
+ <li>
+ {#if isSystemImageArtwork(feature.artwork)}
+ <span class="feature-icon-container" aria-hidden="true">
+ <SystemImage artwork={feature.artwork} />
+ </span>
+ {/if}
+ <div class="feature-content">
+ <h3 class="feature-title">{feature.title}</h3>
+ {#if feature.description}
+ <span class="feature-description">
+ {feature.description}
+ </span>
+ {/if}
+ </div>
+ </li>
+ {/each}
+ </ul>
+</article>
+
+<style lang="scss">
+ @use 'amp/stylekit/core/border-radiuses' as *;
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ article {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 30px;
+ gap: 8px;
+ text-align: center;
+ font: var(--body-tall);
+ border-radius: $global-border-radius-rounded-large;
+ background-color: var(--systemQuinary);
+
+ &.is-detail-view {
+ padding: 0;
+ text-align: start;
+ background-color: transparent;
+ }
+ }
+
+ .icon-container {
+ width: 30px;
+ margin: 0 auto;
+ }
+
+ .icon-container :global(svg) {
+ width: 100%;
+ fill: var(--keyColor);
+ }
+
+ h2 {
+ font: var(--title-3-emphasized);
+ margin-bottom: 8px;
+ }
+
+ ul {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ }
+
+ ul.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ li {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: start;
+ padding: 4px 0;
+ gap: 8px;
+
+ .is-detail-view & {
+ gap: 10px;
+ justify-content: start;
+ align-items: flex-start;
+ }
+ }
+
+ .grid li {
+ justify-content: start;
+ }
+
+ .feature-icon-container {
+ display: inline-flex;
+
+ @media (prefers-color-scheme: dark) {
+ filter: invert(1);
+ }
+
+ .is-detail-view & {
+ display: flex;
+ align-items: center;
+
+ @media (prefers-color-scheme: dark) {
+ filter: none;
+ }
+ }
+ }
+
+ .feature-icon-container :global(svg) {
+ width: 20px;
+
+ .is-detail-view & {
+ width: 30px;
+ fill: var(--keyColor);
+ }
+ }
+
+ .feature-content {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .feature-title {
+ font: var(--body-tall);
+
+ .is-detail-view & {
+ color: var(--systemPrimary);
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .feature-description {
+ color: var(--systemSecondary);
+ font: var(--body);
+ }
+</style>
diff --git a/src/components/jet/item/AccessibilityParagraphItem.svelte b/src/components/jet/item/AccessibilityParagraphItem.svelte
new file mode 100644
index 0000000..836b52f
--- /dev/null
+++ b/src/components/jet/item/AccessibilityParagraphItem.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ import type { AccessibilityParagraph } from '@jet-app/app-store/api/models';
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ export let item: AccessibilityParagraph;
+</script>
+
+<div>
+ <p>
+ <LinkableTextItem item={item.text} />
+ </p>
+</div>
+
+<style>
+ p {
+ font: var(--body-tall);
+ }
+
+ p :global(a) {
+ color: var(--keyColor);
+ }
+</style>
diff --git a/src/components/jet/item/Annotation/AnnotationItem.svelte b/src/components/jet/item/Annotation/AnnotationItem.svelte
new file mode 100644
index 0000000..38bb269
--- /dev/null
+++ b/src/components/jet/item/Annotation/AnnotationItem.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { type Annotation } from '@jet-app/app-store/api/models';
+ import ModernAnnotationItemRenderer from '~/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte';
+ import LegacyAnnotationRenderer from '~/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte';
+
+ export let item: Annotation;
+
+ $: ({ items, items_V3, linkAction, summary } = item);
+
+ $: shouldRenderModernAnnotation = items_V3.length > 0;
+</script>
+
+{#if shouldRenderModernAnnotation}
+ <ModernAnnotationItemRenderer items={items_V3} {summary} />
+{:else}
+ <LegacyAnnotationRenderer {items} {linkAction} />
+{/if}
diff --git a/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte
new file mode 100644
index 0000000..fc6586f
--- /dev/null
+++ b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte
@@ -0,0 +1,146 @@
+<script lang="ts">
+ import { isSome } from '@jet/environment';
+ import {
+ type AnnotationItem,
+ type Action,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let items: AnnotationItem[];
+ export let linkAction: Action | undefined;
+
+ const shouldRenderAsDefinitionList = (items: AnnotationItem[]) =>
+ !!items[0]?.heading;
+
+ const shouldRenderAsOrderedList = (items: AnnotationItem[]) =>
+ !!items[0]?.textPairs;
+
+ const shouldRenderAsUnorderedList = (items: AnnotationItem[]) =>
+ !items[0]?.text;
+
+ const shouldRenderAsDefinitionListWithHeading = (items: AnnotationItem[]) =>
+ items[0]?.text && items[1]?.heading;
+</script>
+
+{#if shouldRenderAsDefinitionList(items)}
+ <dl class="secondary-definition-list">
+ {#each items as annotationItem}
+ <dt>{annotationItem.heading}</dt>
+ <dd>{annotationItem.text}</dd>
+ {/each}
+ </dl>
+{:else if shouldRenderAsOrderedList(items)}
+ <ol>
+ {#each items as annotationItem}
+ {#if annotationItem.textPairs}
+ {#each annotationItem.textPairs as [text, subtext]}
+ <li>
+ <span class="text">{text}</span>
+ <span class="subtext">{subtext}</span>
+ </li>
+ {/each}
+ {:else}
+ <li>{annotationItem.text}</li>
+ {/if}
+ {/each}
+ </ol>
+{:else if shouldRenderAsUnorderedList(items)}
+ <ul>
+ {#each items as annotationItem}
+ <li>
+ <span class="text">
+ {annotationItem.text}
+ </span>
+ </li>
+ {/each}
+ </ul>
+{:else if shouldRenderAsDefinitionListWithHeading(items)}
+ {@const [heading, ...remainingItems] = items}
+ <dd>
+ <p class="secondary-definition-list-heading">{heading.text}</p>
+
+ <dl class="secondary-definition-list">
+ {#each remainingItems as annotationItem}
+ <dt>{annotationItem.heading}</dt>
+ <dd>{annotationItem.text}</dd>
+ {/each}
+ </dl>
+ </dd>
+{:else}
+ <dd>
+ <ul>
+ {#each items as annotationItem}
+ <li>{annotationItem.text}</li>
+ {/each}
+ </ul>
+ {#if isSome(linkAction) && isFlowAction(linkAction)}
+ <LinkWrapper action={linkAction}>
+ {linkAction.title}
+ </LinkWrapper>
+ {/if}
+ </dd>
+{/if}
+
+<style>
+ dt {
+ color: var(--systemSecondary);
+ font: var(--body-tall);
+ }
+
+ dd {
+ white-space: pre-line;
+ font: var(--body-tall);
+ }
+
+ ol {
+ counter-reset: section;
+ }
+
+ ol li {
+ display: table-row;
+ font: var(--body-tall);
+ }
+
+ ol li::before {
+ counter-increment: section;
+ content: counter(section) '.';
+ display: table-cell;
+ padding-inline-end: 6px;
+ }
+
+ ol li .text {
+ display: table-cell;
+ width: 100%;
+ }
+
+ ol li .subtext {
+ display: table-cell;
+ }
+
+ .secondary-definition-list-heading {
+ margin-bottom: 16px;
+ }
+
+ .secondary-definition-list dt {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ }
+
+ .secondary-definition-list dd:not(:last-of-type) {
+ margin-bottom: 16px;
+ }
+
+ dd li:not(:last-of-type) {
+ margin-bottom: 16px;
+ }
+
+ dd :global(a) {
+ color: var(--keyColor);
+ text-decoration: none;
+ }
+
+ dd :global(a:hover) {
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte
new file mode 100644
index 0000000..20611d3
--- /dev/null
+++ b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte
@@ -0,0 +1,114 @@
+<script lang="ts">
+ import type { AnnotationItem_V3 } from '@jet-app/app-store/api/models';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let items: AnnotationItem_V3[];
+ export let summary: string | undefined;
+
+ const formatStyledText = (text: string): string => {
+ return (
+ text
+ // Replace \n with <br>
+ .replace(/\n/g, '<br>')
+ // Replace **text** with <strong>text</strong>
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
+ );
+ };
+</script>
+
+<ul>
+ {#each items as annotationItem}
+ <li>
+ {#if annotationItem.$kind === 'textEncapsulation'}
+ <div class="text-encapsulation">
+ {annotationItem.text}
+ </div>
+ {:else if annotationItem.$kind === 'linkableText'}
+ <div class="styled-text">
+ {@html sanitizeHtml(
+ formatStyledText(
+ annotationItem.linkableText.styledText.rawText,
+ ),
+ )}
+ </div>
+ {:else if annotationItem.$kind === 'artwork'}
+ {#if isSystemImageArtwork(annotationItem.artwork)}
+ <div class="artwork-wrapper" aria-label={summary}>
+ <SystemImage artwork={annotationItem.artwork} />
+ </div>
+ {/if}
+ {:else if annotationItem.$kind === 'textPair'}
+ <div class="text-pair">
+ <span>{annotationItem.leadingText}</span>
+ <span>
+ {annotationItem.trailingText}
+ </span>
+ </div>
+ {:else if annotationItem.$kind === 'button'}
+ <div class="button-wrapper">
+ <LinkWrapper action={annotationItem.action}>
+ {annotationItem.action.title}
+ </LinkWrapper>
+ </div>
+ {:else if annotationItem.$kind === 'spacer'}
+ <div class="spacer" />
+ {/if}
+ </li>
+ {/each}
+</ul>
+
+<style>
+ li {
+ font: var(--body-tall);
+ }
+
+ .styled-text :global(strong) {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ }
+
+ .text-encapsulation {
+ width: fit-content;
+ color: var(--keyColor);
+ border: 1px solid;
+ border-radius: 3px;
+ padding-inline: 3px;
+ border-color: var(--keyColor);
+ margin-block: 3px;
+ }
+
+ .artwork-wrapper :global(svg) {
+ height: 18px;
+ width: 18px;
+ margin-top: 4px;
+ }
+
+ .spacer {
+ height: 16px;
+ }
+
+ .button-wrapper :global(a) {
+ color: var(--keyColor);
+ text-decoration: none;
+ }
+
+ .button-wrapper :global(a:hover) {
+ text-decoration: underline;
+ }
+
+ .button-wrapper :global(a) :global(.external-link-arrow) {
+ width: 7px;
+ height: 7px;
+ fill: var(--keyColor);
+ margin-top: 3px;
+ }
+
+ .text-pair {
+ display: flex;
+ justify-content: space-between;
+ }
+</style>
diff --git a/src/components/jet/item/AppEventItem.svelte b/src/components/jet/item/AppEventItem.svelte
new file mode 100644
index 0000000..c1e5e5a
--- /dev/null
+++ b/src/components/jet/item/AppEventItem.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import type { AppEvent } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+ import SmallLockupItem from './SmallLockupItem.svelte';
+
+ export let item: AppEvent;
+ export let isArticleContext: boolean = false;
+
+ $: artwork = item.moduleArtwork;
+ $: video = item.moduleVideo;
+ $: hasLightArtwork = item.mediaOverlayStyle === 'light';
+ $: gradientColor = hasLightArtwork
+ ? 'rgb(240 240 240 / 48%)'
+ : 'rgb(83 83 83 / 48%)';
+ $: shouldShowLockup = !!item.lockup && !item.hideLockupWhenNotInstalled;
+</script>
+
+<div
+ class="app-event-item"
+ class:with-lockup={!!item.lockup && !item.hideLockupWhenNotInstalled}
+>
+ <span class="time-indicator">
+ <AppEventDate appEvent={item} />
+ </span>
+
+ <div class="lockup-container">
+ <HoverWrapper hasChin={shouldShowLockup} --display="block">
+ <LinkWrapper action={item.clickAction}>
+ <div class="text-over-artwork">
+ {#if video}
+ <div class="video-container">
+ <Video
+ {video}
+ autoplay
+ loop={true}
+ useControls={false}
+ profile="app-promotion"
+ />
+ </div>
+ {:else if artwork}
+ <div class="artwork-container">
+ <Artwork
+ {artwork}
+ profile={isArticleContext
+ ? 'app-promotion-in-article'
+ : 'app-promotion'}
+ />
+ </div>
+ {/if}
+
+ <div class="gradient-container">
+ <GradientOverlay
+ --border-radius={0}
+ --color={gradientColor}
+ --height="80%"
+ shouldDarken={!hasLightArtwork}
+ />
+ </div>
+
+ <div class="text-container" class:dark={hasLightArtwork}>
+ <h4>{item.kind}</h4>
+
+ <h3>{item.title}</h3>
+
+ <LineClamp clamp={1}>
+ <p>{item.detail}</p>
+ </LineClamp>
+ </div>
+ </div>
+ </LinkWrapper>
+ </HoverWrapper>
+
+ {#if item.lockup && shouldShowLockup}
+ <div class="small-lockup-container">
+ <SmallLockupItem item={item.lockup} appIconProfile="app-icon" />
+ </div>
+ {/if}
+ </div>
+</div>
+
+<style>
+ .app-event-item {
+ height: 100%;
+ display: grid;
+ grid-template-areas:
+ 'time-indicator'
+ 'lockup';
+ grid-template-rows: 1rem 1fr;
+ gap: 4px;
+ }
+
+ .time-indicator {
+ grid-area: time-indicator;
+ color: var(--keyColor);
+ font-weight: bold;
+ }
+
+ .lockup-container {
+ grid-area: lockup;
+ }
+
+ .text-over-artwork {
+ /* Allow artwork, overlay and text containers to overlap by targeting the same grid area */
+ display: grid;
+ grid-template-areas: 'content';
+ }
+
+ .artwork-container {
+ grid-area: content;
+ border-radius: var(--global-border-radius-large);
+ }
+
+ .video-container {
+ grid-area: content;
+ border-radius: var(--global-border-radius-large);
+ line-height: 0;
+ }
+
+ .app-event-item.with-lockup .artwork-container,
+ .app-event-item.with-lockup .video-container {
+ border-radius: 0;
+ }
+
+ .gradient-container {
+ grid-area: content;
+ z-index: 1;
+ position: relative;
+ }
+
+ .text-container {
+ color: var(--systemPrimary-onDark);
+ padding: 12px 16px;
+ grid-area: content;
+ z-index: 2;
+
+ /* Float text to the bottom of the lockup */
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ }
+
+ .text-container.dark {
+ color: var(--systemPrimary-onLight);
+ }
+
+ .small-lockup-container {
+ background: var(--systemPrimary-onDark);
+ border-radius: 0 0 var(--global-border-radius-large)
+ var(--global-border-radius-large);
+ box-shadow: var(--shadow-small);
+ padding: 12px;
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemQuinary-onDark);
+ }
+ }
+
+ h3 {
+ font: var(--title-2-tall);
+ }
+
+ h4 {
+ font: var(--callout-emphasized-tall);
+ }
+
+ p {
+ font: var(--callout-emphasized);
+ }
+</style>
diff --git a/src/components/jet/item/ArcadeFooterItem.svelte b/src/components/jet/item/ArcadeFooterItem.svelte
new file mode 100644
index 0000000..94fe61d
--- /dev/null
+++ b/src/components/jet/item/ArcadeFooterItem.svelte
@@ -0,0 +1,83 @@
+<script lang="ts">
+ import type {
+ ArcadeFooter,
+ Artwork,
+ ImpressionableArtwork,
+ } from '@jet-app/app-store/api/models';
+ import { unwrapOptional as unwrap } from '@jet/environment/types/optional';
+
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import AppIconRiver from '~/components/AppIconRiver.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: ArcadeFooter;
+
+ $: action = unwrap(item.buttonAction);
+
+ function isImpressionableArtwork(
+ item: ImpressionableArtwork | Artwork,
+ ): item is ImpressionableArtwork {
+ return 'art' in item;
+ }
+
+ // Sometimes data used to render an app icon is directly on `icon` but other times, in the case
+ // of `ImpressionableArtwork`, it's on `icon.art`. Here we are plucking the data no matter where it is.
+ const icons = (item.icons ?? []).map((icon) =>
+ isImpressionableArtwork(icon) ? icon.art : icon,
+ );
+</script>
+
+<LinkWrapper {action}>
+ <article>
+ {#if icons.length}
+ <AppIconRiver {icons} />
+ {/if}
+
+ <div class="metadata-container">
+ <div class="logo-container">
+ <AppleArcadeLogo />
+ </div>
+
+ <button class="get-button gray">
+ {action.title}
+ </button>
+ </div>
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ --app-icon-river-speed: 120s;
+ display: flex;
+ overflow: hidden;
+ flex-flow: column;
+ padding: 20px 0 30px;
+ margin-bottom: 20px;
+ text-align: center;
+ border-radius: var(--global-border-radius-large);
+ background: var(--footerBg);
+
+ @media (--range-small-down) {
+ --app-icon-river-icon-width: 88px;
+ }
+
+ @media (--range-medium-up) {
+ --get-button-font: var(--title-3-emphasized);
+ }
+ }
+
+ .metadata-container {
+ display: flex;
+ align-items: center;
+ flex-flow: column;
+ gap: 20px;
+ }
+
+ .logo-container {
+ width: 128px;
+
+ @media (--range-small-down) {
+ width: 88px;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/BannerItem.svelte b/src/components/jet/item/BannerItem.svelte
new file mode 100644
index 0000000..819f621
--- /dev/null
+++ b/src/components/jet/item/BannerItem.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import { isFlowAction, type Banner } from '@jet-app/app-store/api/models';
+ import { isSome } from '@jet/environment';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: Banner;
+</script>
+
+<div class="banner">
+ <p>
+ {item.message}
+ {#if isSome(item.action) && isFlowAction(item.action)}
+ &nbsp;<LinkWrapper action={item.action}>
+ {item.action.title}
+ </LinkWrapper>
+ {/if}
+ </p>
+</div>
+
+<style>
+ .banner {
+ background: rgba(var(--keyColor-rgb), 0.07);
+ padding: 8px 16px;
+ margin: 0 var(--bodyGutter);
+ text-align: center;
+ border-radius: var(--global-border-radius-small);
+ }
+
+ .banner :global(a) {
+ color: var(--keyColor);
+ text-decoration: none;
+ }
+
+ .banner :global(a:hover) {
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/components/jet/item/BrickItem.svelte b/src/components/jet/item/BrickItem.svelte
new file mode 100644
index 0000000..a9e6319
--- /dev/null
+++ b/src/components/jet/item/BrickItem.svelte
@@ -0,0 +1,300 @@
+<script lang="ts">
+ import type { Brick } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import {
+ colorAsString,
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+
+ export let item: Brick;
+ export let shouldOverlayDescription: boolean = false;
+
+ const rtlArtwork = item.artworks?.[1] || item.rtlArtwork;
+ const artwork = isRtl() && rtlArtwork ? rtlArtwork : item.artworks?.[0];
+ const { collectionIcons } = item;
+
+ const gradientColor: string = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : 'rgb(0 0 0 / 62%)';
+
+ let backgroundGradientCssVars: string | undefined = undefined;
+
+ if (collectionIcons && collectionIcons.length > 1) {
+ // If there are multiple app icons, we build a string of CSS variables from the icons
+ // background colors to fill as many of the lockups quadrants as possible.
+ backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
+ collectionIcons,
+ {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ shouldRemoveGreys: true,
+ },
+ );
+ }
+</script>
+
+<LinkWrapper
+ action={item.clickAction}
+ label={item.accessibilityLabel || item.clickAction?.title}
+>
+ <div class="container">
+ <HoverWrapper>
+ {#if artwork}
+ <Artwork
+ {artwork}
+ profile={shouldOverlayDescription ? 'small-brick' : 'brick'}
+ />
+ {:else if backgroundGradientCssVars}
+ <div
+ class="background-gradient"
+ style={backgroundGradientCssVars}
+ />
+ {/if}
+
+ {#if item.title}
+ <GradientOverlay --color={gradientColor} />
+ {/if}
+
+ <div class="text-container">
+ <div class="metadata-container">
+ {#if item.caption}
+ <LineClamp clamp={1}>
+ <h4>{item.caption}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={3}>
+ <h3 class="title">
+ {@html sanitizeHtml(item.title)}
+ </h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={2}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ {#if !artwork && collectionIcons}
+ <ul class="app-icons">
+ {#each collectionIcons?.slice(0, 8) as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon
+ icon={collectionIcon}
+ profile="brick-app-icon"
+ fixedWidth={false}
+ />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ </HoverWrapper>
+
+ {#if item.shortEditorialDescription}
+ <h3
+ class="editorial-description"
+ class:overlaid={shouldOverlayDescription}
+ >
+ {item.shortEditorialDescription}
+ </h3>
+ {/if}
+ </div>
+</LinkWrapper>
+
+<style>
+ .container {
+ position: relative;
+ container-type: inline-size;
+ container-name: container;
+ }
+
+ .metadata-container {
+ width: 100%;
+ align-self: end;
+ }
+
+ .text-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ display: flex;
+ align-items: flex-end;
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ color: var(--systemPrimary-onDark);
+ }
+
+ .app-icon-container {
+ position: relative;
+ flex-shrink: 0;
+ width: 60px;
+ margin-inline-end: 5%;
+ }
+
+ .title {
+ font: var(--title-1-emphasized);
+ text-wrap: pretty;
+ }
+
+ h4 {
+ margin-bottom: 3px;
+ font: var(--callout-emphasized);
+ }
+
+ p {
+ margin-top: 6px;
+ font: var(--body-emphasized);
+ }
+
+ .editorial-description {
+ margin-top: 8px;
+ font: var(--title-3);
+ }
+
+ .editorial-description.overlaid {
+ position: absolute;
+ z-index: 1;
+ bottom: 9px;
+ padding: 0 20px;
+ color: white;
+ font: var(--title-2-emphasized);
+ }
+
+ @property --top-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 20%;
+ }
+
+ @property --bottom-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 40%;
+ }
+
+ @property --top-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 55%;
+ }
+
+ @property --bottom-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 50%;
+ }
+
+ .container .background-gradient {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) var(--top-left-stop),
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) var(--bottom-left-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 66% -175%,
+ var(--top-right, #000) var(--top-right-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) var(--bottom-right-stop),
+ transparent 100%
+ );
+ animation: gradient-hover 8s infinite alternate-reverse;
+ animation-play-state: paused;
+ }
+
+ @keyframes gradient-hover {
+ 0% {
+ --top-left-stop: 20%;
+ --bottom-left-stop: 40%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 50%;
+ background-size: 100% 100%;
+ }
+
+ 50% {
+ --top-left-stop: 25%;
+ --bottom-left-stop: 15%;
+ --top-right-stop: 70%;
+ --bottom-right-stop: 30%;
+ background-size: 130% 130%;
+ }
+
+ 100% {
+ --top-left-stop: 15%;
+ --bottom-left-stop: 20%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 20%;
+ background-size: 110% 110%;
+ }
+ }
+
+ .container:hover .background-gradient {
+ animation-play-state: running;
+ }
+
+ .app-icons {
+ display: grid;
+ align-self: center;
+ flex-direction: row;
+ width: 44%;
+ grid-template-rows: auto auto;
+ grid-auto-flow: column;
+ gap: 8px;
+ }
+
+ .app-icons li:nth-child(even) {
+ inset-inline-start: 40px;
+ }
+
+ @container container (max-width: 298px) {
+ .title {
+ font: var(--title-2-emphasized);
+ }
+
+ .text-container {
+ padding: 16px;
+ }
+
+ .editorial-description.overlaid {
+ bottom: 16px;
+ padding-inline: 16px;
+ }
+
+ .app-icons {
+ width: 36%;
+ }
+
+ .app-icon-container {
+ width: 50px;
+ }
+ }
+
+ @container container (min-width: 440px) {
+ .app-icon-container {
+ width: 83px;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/ContentModal.svelte b/src/components/jet/item/ContentModal.svelte
new file mode 100644
index 0000000..486937d
--- /dev/null
+++ b/src/components/jet/item/ContentModal.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ import ContentModal from '@amp/web-app-components/src/components/Modal/ContentModal.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { createEventDispatcher } from 'svelte';
+ import { getJet } from '~/jet';
+
+ export let title: string | null;
+ export let subtitle: string | null;
+ export let text: string | null = null;
+ export let dialogTitleId: string | null = null;
+ export let targetId: string = 'close';
+
+ const i18n = getI18n();
+ const jet = getJet();
+ const dispatch = createEventDispatcher();
+
+ const translateFn = (key: string) => $i18n.t(key);
+
+ const handleCloseModal = () => {
+ dispatch('close');
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ targetId,
+ targetType: 'button',
+ actionType: 'close',
+ });
+ };
+</script>
+
+<ContentModal
+ on:close={handleCloseModal}
+ {translateFn}
+ {title}
+ {subtitle}
+ text={text || undefined}
+ {dialogTitleId}
+>
+ <slot name="content" slot="content" />
+</ContentModal>
diff --git a/src/components/jet/item/EditorialCardItem.svelte b/src/components/jet/item/EditorialCardItem.svelte
new file mode 100644
index 0000000..2998b05
--- /dev/null
+++ b/src/components/jet/item/EditorialCardItem.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { EditorialCard } from '@jet-app/app-store/api/models';
+
+ import Hero from '~/components/hero/Hero.svelte';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+ import AppLockupDetail from '~/components/hero/AppLockupDetail.svelte';
+ import mediaQueries from '~/utils/media-queries';
+ import { isRtl } from '~/utils/locale';
+
+ export let item: EditorialCard;
+
+ $: isPortraitLayout = $mediaQueries === 'xsmall';
+</script>
+
+<Hero
+ action={item.clickAction}
+ artwork={item.artwork}
+ subtitle={item.subtitle}
+ title={item.title}
+ pinArtworkToHorizontalEnd={true}
+ backgroundColor={item.artwork?.backgroundColor}
+ isMediaDark={item.mediaOverlayStyle === 'dark'}
+ profileOverride={isPortraitLayout ? 'large-hero-portrait-iphone' : null}
+>
+ <svelte:fragment slot="eyebrow">
+ {#if item.appEventFormattedDates}
+ <AppEventDate formattedDates={item.appEventFormattedDates} />
+ {:else}
+ {item.caption}
+ {/if}
+ </svelte:fragment>
+
+ <svelte:fragment slot="details">
+ {#if item.lockup}
+ <AppLockupDetail
+ lockup={item.lockup}
+ isOnDarkBackground={item.mediaOverlayStyle === 'dark'}
+ />
+ {/if}
+ </svelte:fragment>
+</Hero>
diff --git a/src/components/jet/item/FooterLockupItem.svelte b/src/components/jet/item/FooterLockupItem.svelte
new file mode 100644
index 0000000..848885d
--- /dev/null
+++ b/src/components/jet/item/FooterLockupItem.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Lockup;
+
+ const i18n = getI18n();
+</script>
+
+<div class="footer-lockup-item">
+ <LinkWrapper
+ action={item.clickAction}
+ label={`${$i18n.t('ASE.Web.AppStore.View')} ${
+ item.title ? item.title : null
+ }`}
+ >
+ {#if item.icon}
+ <AppIcon icon={item.icon} profile="app-icon-small" />
+ {/if}
+
+ <div>
+ {#if item.heading}
+ <LineClamp clamp={1}>
+ <h4 dir="auto">{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={1}>
+ <h3 dir="auto">{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p dir="auto">{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <span class="get-button blue" aria-hidden="true">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </LinkWrapper>
+</div>
+
+<style>
+ .footer-lockup-item > :global(a) {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ width: 100%;
+ padding: 32px;
+ gap: 16px;
+ text-align: center;
+ border-radius: var(--global-border-radius-small);
+ background-color: var(--systemQuinary);
+ transition: background-color 210ms ease-out;
+ }
+
+ .footer-lockup-item > :global(a:hover) {
+ --darken-amount: 2%;
+ background-color: color-mix(
+ in srgb,
+ var(--systemQuinary) calc(100% - var(--darken-amount)),
+ black
+ );
+
+ @media (prefers-color-scheme: dark) {
+ --darken-amount: 10%;
+ }
+ }
+
+ h3 {
+ margin-bottom: 4px;
+ font: var(--title-2-emphasized);
+ color: var(--title-color);
+ }
+
+ h4 {
+ text-transform: uppercase;
+ font: var(--subhead-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ p {
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/HeroCarouselItem.svelte b/src/components/jet/item/HeroCarouselItem.svelte
new file mode 100644
index 0000000..295aa8a
--- /dev/null
+++ b/src/components/jet/item/HeroCarouselItem.svelte
@@ -0,0 +1,60 @@
+<!--
+@component
+Component for rendering a `HeroCarouselItem` view-model from the App Store Client
+-->
+<script lang="ts">
+ import type { HeroCarouselItem } from '@jet-app/app-store/api/models';
+
+ import Hero from '~/components/hero/Hero.svelte';
+ import HeroAppLockup from '~/components/hero/AppLockupDetail.svelte';
+ import mediaQueries from '~/utils/media-queries';
+
+ export let item: HeroCarouselItem;
+
+ const {
+ titleText,
+ badgeText,
+ overlayType,
+ callToActionText,
+ lockup: overlayLockup,
+ clickAction,
+ descriptionText,
+ } = item.overlay || {};
+
+ $: artwork = item.artwork || item.video?.preview;
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: video = isXSmallViewport ? item.portraitVideo : item.video;
+</script>
+
+<Hero
+ {artwork}
+ {video}
+ title={titleText}
+ eyebrow={badgeText}
+ action={clickAction}
+ backgroundColor={item.backgroundColor}
+ subtitle={descriptionText}
+ isMediaDark={item.isMediaDark}
+ collectionIcons={item.collectionIcons}
+>
+ <svelte:fragment slot="details" let:isPortraitLayout>
+ {#if overlayLockup && overlayType === 'singleModule'}
+ <HeroAppLockup lockup={overlayLockup} />
+ {:else if callToActionText && !isPortraitLayout}
+ <div class="button-container">
+ <span class="get-button transparent">
+ {callToActionText}
+ </span>
+ </div>
+ {/if}
+ </svelte:fragment>
+</Hero>
+
+<style>
+ .button-container {
+ --get-button-font: var(--title-3-bold);
+ margin-top: 16px;
+ position: relative;
+ z-index: 1;
+ }
+</style>
diff --git a/src/components/jet/item/InAppPurchaseLockup.svelte b/src/components/jet/item/InAppPurchaseLockup.svelte
new file mode 100644
index 0000000..29b7196
--- /dev/null
+++ b/src/components/jet/item/InAppPurchaseLockup.svelte
@@ -0,0 +1,74 @@
+<script lang="ts">
+ import type { InAppPurchaseLockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import PlusIcon from '~/sf-symbols/plus.heavy.svg';
+
+ export let item: InAppPurchaseLockup;
+</script>
+
+<article>
+ <div class="artwork-container">
+ <PlusIcon class="plus-icon" aria-hidden="true" />
+ <Artwork artwork={item.icon} profile="in-app-purchase" />
+ </div>
+
+ <div class="metadata-container">
+ {#if item.title}
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.productDescription}
+ <LineClamp clamp={1}>
+ <p>{item.productDescription}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.offerDisplayProperties.titles}
+ <p>
+ {item.offerDisplayProperties.titles.discountUnownedParent ||
+ item.offerDisplayProperties.titles.standard}
+ </p>
+ {/if}
+ </div>
+</article>
+
+<style>
+ .artwork-container {
+ position: relative;
+ flex-shrink: 0;
+ width: 100%;
+ margin-bottom: 8px;
+ padding: 8%;
+ border-radius: var(--global-border-radius-small);
+ background: var(--systemQuinary);
+ }
+
+ .artwork-container :global(.plus-icon) {
+ position: absolute;
+ top: 6%;
+ width: 9%;
+ inset-inline-end: 5%;
+ }
+
+ .artwork-container :global(.artwork-component) {
+ border-radius: var(--global-border-radius-small) 43%
+ var(--global-border-radius-small) var(--global-border-radius-small);
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ font: var(--body-tall);
+ }
+
+ p {
+ font: var(--callout-tall);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/LargeBrickItem.svelte b/src/components/jet/item/LargeBrickItem.svelte
new file mode 100644
index 0000000..5ce9974
--- /dev/null
+++ b/src/components/jet/item/LargeBrickItem.svelte
@@ -0,0 +1,106 @@
+<script lang="ts">
+ import type { Brick } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ export let item: Brick;
+ const artwork =
+ isRtl() && item.rtlArtwork ? item.rtlArtwork : item.artworks?.[0];
+ const collectionIcon = item.collectionIcons?.[0];
+ let artworkFallbackColor: string | null = null;
+
+ const gradientOverlayColor: string = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : '#000';
+
+ if (!artwork) {
+ artworkFallbackColor = collectionIcon?.backgroundColor
+ ? colorAsString(collectionIcon.backgroundColor)
+ : '#000';
+ }
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ {#if artwork}
+ <div class="artwork-container">
+ <Artwork {artwork} profile="large-brick" />
+ </div>
+ {:else}
+ <div
+ class="gradient-container"
+ style={`--color: ${artworkFallbackColor};`}
+ />
+ {/if}
+
+ <div class="text-container">
+ <div class="metadata-container">
+ {#if item.caption}
+ <LineClamp clamp={1}>
+ <h4>{item.caption}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={2}>
+ <h3>{@html sanitizeHtml(item.title)}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={2}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+
+ <GradientOverlay --color={gradientOverlayColor} />
+ </HoverWrapper>
+</LinkWrapper>
+
+<style>
+ .artwork-container {
+ width: 100%;
+ }
+
+ .gradient-container {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background-color: var(--color);
+ }
+
+ .text-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ width: 66%;
+ padding-inline: 20px;
+ padding-bottom: 20px;
+ color: var(--systemPrimary-onDark);
+ }
+
+ h3 {
+ font: var(--title-1-emphasized);
+ text-wrap: balance;
+ }
+
+ h4 {
+ font: var(--callout-emphasized);
+ margin-bottom: 3px;
+ }
+
+ p {
+ font: var(--body-emphasized);
+ margin-top: 6px;
+ }
+</style>
diff --git a/src/components/jet/item/LargeHeroBreakoutItem.svelte b/src/components/jet/item/LargeHeroBreakoutItem.svelte
new file mode 100644
index 0000000..d07eec8
--- /dev/null
+++ b/src/components/jet/item/LargeHeroBreakoutItem.svelte
@@ -0,0 +1,268 @@
+<script lang="ts">
+ import {
+ type Artwork as JetArtworkType,
+ type LargeHeroBreakout,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import { isSome } from '@jet/environment/types/optional';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import mediaQueries from '~/utils/media-queries';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+ import { colorAsString, isRGBColor, isDark } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ export let item: LargeHeroBreakout;
+
+ let profile: NamedProfile;
+ let artwork: JetArtworkType | undefined;
+ let gradientColor: string;
+
+ const {
+ collectionIcons = [],
+ editorialDisplayOptions,
+ rtlArtwork,
+ video,
+ details: { callToActionButtonAction: action },
+ } = item;
+ const canUseRTLArtwork = isRtl() && rtlArtwork;
+ const shouldShowCollectionIcons =
+ collectionIcons?.length > 1 && !editorialDisplayOptions.suppressLockup;
+
+ $: artwork =
+ (canUseRTLArtwork ? rtlArtwork : item.artwork) || video?.preview;
+ $: doesArtworkHaveDarkBackground =
+ artwork?.backgroundColor &&
+ isRGBColor(artwork.backgroundColor) &&
+ isDark(artwork.backgroundColor);
+ $: isBackgroundDark = item.isMediaDark ?? doesArtworkHaveDarkBackground;
+
+ $: profile =
+ $mediaQueries === 'xsmall'
+ ? 'large-hero-portrait-iphone'
+ : canUseRTLArtwork
+ ? 'large-hero-breakout-rtl'
+ : 'large-hero-breakout';
+
+ $: gradientColor = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper {action}>
+ <HoverWrapper>
+ <div class="artwork-container">
+ {#if video && $mediaQueries !== 'xsmall' && !canUseRTLArtwork}
+ <Video {video} {profile} autoplay loop useControls={false} />
+ {:else if artwork}
+ <Artwork {artwork} {profile} />
+ {/if}
+ </div>
+
+ <div class="gradient" style="--color: {gradientColor};" />
+
+ <div
+ class="text-container"
+ class:on-dark={isBackgroundDark}
+ class:on-light={!isBackgroundDark}
+ >
+ {#if item.details?.badge}
+ <LineClamp clamp={1}>
+ <h4>{item.details.badge}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.title}
+ <LineClamp clamp={2}>
+ <h3>{@html sanitizeHtml(item.details.title)}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.description}
+ <LineClamp clamp={3}>
+ <p>{@html sanitizeHtml(item.details.description)}</p>
+ </LineClamp>
+ {/if}
+
+ {#if isSome(action) && isFlowAction(action)}
+ <span class="link-container">
+ {action.title}
+ <span aria-hidden="true">
+ <SFSymbol name="chevron.forward" />
+ </span>
+ </span>
+ {/if}
+
+ {#if shouldShowCollectionIcons}
+ <ul class="collection-icons">
+ {#each collectionIcons.slice(0, 6) as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon icon={collectionIcon} />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .artwork-container {
+ width: 100%;
+
+ @media (--range-small-up) {
+ aspect-ratio: 8 / 3;
+ }
+ }
+
+ .artwork-container :global(.video-container) {
+ display: flex;
+ }
+
+ .text-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ align-items: center;
+ width: 100%;
+ padding-inline: 20px;
+ padding-bottom: 20px;
+ text-wrap: pretty;
+
+ @media (--range-small-up) {
+ width: 50%;
+ }
+
+ @media (--range-large-up) {
+ width: 33%;
+ }
+ }
+
+ .text-container.on-dark {
+ color: var(--systemPrimary-onDark);
+
+ h4 {
+ color: var(--systemSecondary-onDark);
+ }
+
+ :global(svg) {
+ fill: var(--systemPrimary-onDark);
+ }
+ }
+
+ .text-container.on-light {
+ color: var(--systemPrimary-onLight);
+
+ h4 {
+ color: var(--systemSecondary-onLight);
+ }
+
+ :global(svg) {
+ fill: var(--systemPrimary-onLight);
+ }
+ }
+
+ .link-container {
+ margin-top: 8px;
+ display: flex;
+ gap: 4px;
+ font: var(--body-emphasized);
+
+ @media (--range-small-up) {
+ margin-top: 16px;
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .link-container :global(svg) {
+ width: 8px;
+ height: 8px;
+
+ @include rtl {
+ transform: rotate(180deg);
+ }
+
+ @media (--range-small-up) {
+ width: 10px;
+ height: 10px;
+ }
+ }
+
+ h3 {
+ text-wrap: balance;
+ font: var(--title-1-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--large-title-emphasized);
+ }
+ }
+
+ h4 {
+ font: var(--subhead-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--callout-emphasized);
+ }
+ }
+
+ p {
+ margin-top: 4px;
+ font: var(--body);
+
+ @media (--range-small-up) {
+ margin-top: 8px;
+ font: var(--title-3);
+ }
+ }
+
+ .collection-icons {
+ display: flex;
+ gap: 8px;
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 2px solid var(--systemTertiary-onDark);
+ }
+
+ .app-icon-container {
+ aspect-ratio: 1/1;
+ }
+
+ .gradient {
+ --rotation: 35deg;
+ position: absolute;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ filter: saturate(1.5) brightness(0.9);
+ background: linear-gradient(
+ var(--rotation),
+ var(--color) 20%,
+ transparent 50%
+ );
+
+ // In non-XS viewports with an RTL text direction, we flip the legibility gradient to
+ // accomodate the right-justified text.
+ @include rtl {
+ @media (--range-small-up) {
+ --rotation: -35deg;
+ }
+ }
+
+ // In XS viewports, this component is renderd in a 3/4 card layout, so we always want the
+ // gradient to be at 0deg rotation, as it goes from botttom to top.
+ @media (--range-xsmall-down) {
+ --rotation: 0deg;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/LargeImageLockupItem.svelte b/src/components/jet/item/LargeImageLockupItem.svelte
new file mode 100644
index 0000000..1df51c2
--- /dev/null
+++ b/src/components/jet/item/LargeImageLockupItem.svelte
@@ -0,0 +1,130 @@
+<script lang="ts">
+ import type { ImageLockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: ImageLockup;
+
+ const color: string = item.artwork.backgroundColor
+ ? colorAsString(item.artwork.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper action={item.lockup.clickAction}>
+ <HoverWrapper>
+ <div class="container">
+ <div class="artwork-container">
+ <Artwork artwork={item.artwork} profile="large-image-lockup" />
+ </div>
+
+ {#if item.lockup}
+ <div
+ class="lockup-container"
+ class:on-dark={item.isDark}
+ class:on-light={!item.isDark}
+ >
+ {#if item.lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={item.lockup.icon} />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if item.lockup.heading}
+ <LineClamp clamp={1}>
+ <p>{item.lockup.heading}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.title}
+ <LineClamp clamp={2}>
+ <h3>{item.lockup.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.subtitle}
+ <LineClamp clamp={1}>
+ <p>{item.lockup.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+ {/if}
+
+ <div class="gradient-container">
+ <GradientOverlay --color={color} --height="85%" />
+ </div>
+ </div>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style>
+ .artwork-container {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ }
+
+ .container {
+ width: 100%;
+ aspect-ratio: 16/9;
+ container-type: inline-size;
+ container-name: container;
+ }
+
+ .gradient-container {
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ .lockup-container {
+ display: flex;
+ align-items: flex-end;
+ width: 100%;
+ height: 100%;
+ padding: 0 20px 20px;
+ }
+
+ .lockup-container.on-dark {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .lockup-container.on-light {
+ color: var(--systemPrimary-onLight);
+ }
+
+ @container container (max-width: 260px) {
+ .lockup-container {
+ padding: 0 10px 10px;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 8px;
+ }
+
+ h3 {
+ margin: 2px 0;
+ font: var(--title-1-emphasized);
+ }
+
+ p {
+ font: var(--callout-emphasized);
+ }
+
+ .lockup-container.on-dark p {
+ mix-blend-mode: plus-lighter;
+ }
+</style>
diff --git a/src/components/jet/item/LargeLockupItem.svelte b/src/components/jet/item/LargeLockupItem.svelte
new file mode 100644
index 0000000..93adc6e
--- /dev/null
+++ b/src/components/jet/item/LargeLockupItem.svelte
@@ -0,0 +1,121 @@
+<script lang="ts">
+ import {
+ isFlowAction,
+ type FlowAction,
+ type Lockup,
+ } from '@jet-app/app-store/api/models';
+ import type { Opt } from '@jet/environment';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Lockup;
+ const i18n = getI18n();
+ const { clickAction } = item;
+ const destination: Opt<FlowAction> = isFlowAction(clickAction)
+ ? clickAction
+ : undefined;
+
+ $: secondaryLine = item.editorialTagline || item.subtitle;
+</script>
+
+<LinkWrapper action={destination}>
+ <article>
+ <div class="app-icon-container">
+ <AppIcon
+ fixedWidth={false}
+ icon={item.icon}
+ profile="app-icon-large"
+ />
+ </div>
+
+ <div class="metadata-container">
+ {#if item.heading}
+ <LineClamp clamp={2}>
+ <h4>{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={2}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if !item.heading && secondaryLine}
+ <LineClamp clamp={1}>
+ <p>{secondaryLine}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.tertiaryTitle}
+ <LineClamp clamp={1}>
+ <p class="tertiary-text">{item.tertiaryTitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ {#if destination}
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ {/if}
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ display: flex;
+ flex-direction: column;
+ min-height: 290px;
+ padding: 20px;
+ border-radius: var(--global-border-radius-large);
+ background: var(--systemPrimary-onDark);
+ box-shadow: var(--shadow-small);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ article {
+ background: var(--systemQuaternary);
+ }
+ }
+
+ .app-icon-container {
+ --artwork-override-height: 100px;
+ --artwork-override-width: auto;
+ display: flex;
+ margin-bottom: 10px;
+ }
+
+ .metadata-container {
+ flex-grow: 1;
+ }
+
+ h3 {
+ margin-bottom: 3px;
+ font: var(--title-2-emphasized);
+ }
+
+ h4 {
+ margin-bottom: 3px;
+ color: var(--systemSecondary);
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ }
+
+ p {
+ margin: 3px 0;
+ font: var(--body);
+ color: var(--systemSecondary);
+ text-wrap: pretty;
+ }
+
+ .tertiary-text {
+ font: var(--callout);
+ color: var(--systemTertiary);
+ }
+</style>
diff --git a/src/components/jet/item/LargeStoryCardItem.svelte b/src/components/jet/item/LargeStoryCardItem.svelte
new file mode 100644
index 0000000..66079c2
--- /dev/null
+++ b/src/components/jet/item/LargeStoryCardItem.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+ import type { TodayCard } from '@jet-app/app-store/api/models';
+
+ import Hero from '~/components/hero/Hero.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+ import mediaQueries from '~/utils/media-queries';
+ import { isRtl } from '~/utils/locale';
+
+ export let item: TodayCard;
+
+ let profile: NamedProfile;
+
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: artwork = item.heroMedia?.artworks[0];
+ $: video = isXSmallViewport ? null : item.heroMedia?.videos[0];
+ $: ({ backgroundColor, clickAction, heading, inlineDescription, title } =
+ item);
+ $: profile = isXSmallViewport
+ ? 'large-hero-story-card-portrait'
+ : isRtl()
+ ? 'large-hero-story-card-rtl'
+ : 'large-hero-story-card';
+</script>
+
+<Hero
+ {artwork}
+ {backgroundColor}
+ {title}
+ {video}
+ action={clickAction}
+ eyebrow={heading}
+ subtitle={inlineDescription}
+ pinArtworkToVerticalMiddle={true}
+ pinArtworkToHorizontalEnd={true}
+ pinTextToVerticalStart={isRtl()}
+ profileOverride={profile}
+ isMediaDark={item.style !== 'white'}
+/>
diff --git a/src/components/jet/item/LinkableTextItem.svelte b/src/components/jet/item/LinkableTextItem.svelte
new file mode 100644
index 0000000..a5a3e74
--- /dev/null
+++ b/src/components/jet/item/LinkableTextItem.svelte
@@ -0,0 +1,88 @@
+<script lang="ts">
+ import type { LinkableText, Action } from '@jet-app/app-store/api/models';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: LinkableText;
+
+ type Fragment = {
+ text: string;
+ action?: Action;
+ isTrailingPunctuation?: boolean;
+ };
+
+ const {
+ linkedSubstrings = {},
+ styledText: { rawText },
+ } = item;
+
+ // `LinkableText` items contain a `rawText` string, and an object of `linkedSubstrings`,
+ // where the key of the object is the substring to replace in the `rawText` and whose value
+ // is the `Action` that the link should trigger.
+ //
+ // That means we have to render replace the keys from `linkedSubstrings` in the `rawText`.
+ // To do this, we build a regex to match all the strings that are supposed to be linked,
+ // then build an array of objects representing the fully text, with the `Action` appended
+ // to the fragments that need to be linked.
+ const fragmentsToLink = Object.keys(linkedSubstrings);
+ let fragments: Fragment[];
+
+ if (fragmentsToLink.length === 0) {
+ fragments = [{ text: rawText }];
+ } else {
+ // Escapes regex-sensitive characters in the text, so characters like `.` or `+` don't act as regex operators
+ const cleanedFragmentsToLink = fragmentsToLink.map((fragment) =>
+ fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
+ );
+
+ const pattern = new RegExp(
+ `(${cleanedFragmentsToLink.join('|')})`,
+ 'g',
+ );
+
+ // After we split our text into an array representing the seqence of the raw text, with the
+ // linkable items as their own entries, we transform the array to contain include the linkable
+ // items actions, which we then use to determine if we want to render a `LinkWrapper` or plain-text.
+ fragments = rawText.split(pattern).map((fragment): Fragment => {
+ const action = linkedSubstrings[fragment];
+
+ if (action) {
+ return { action, text: fragment };
+ } else {
+ const isTrailingPunctuation = /^[.,;:!?)\]}"”»']+$/.test(
+ fragment.trim(),
+ );
+
+ return {
+ isTrailingPunctuation,
+ text: fragment,
+ };
+ }
+ });
+ }
+</script>
+
+{#each fragments as fragment}
+ {#if fragment.action}
+ <LinkWrapper
+ action={fragment.action}
+ includeExternalLinkArrowIcon={false}
+ >
+ {fragment.text}
+ </LinkWrapper>
+ {:else if fragment.isTrailingPunctuation}
+ <span class="trailing-punctuation">{fragment.text}</span>
+ {:else}
+ {@html sanitizeHtml(fragment.text)}
+ {/if}
+{/each}
+
+<style>
+ span :global(a:hover) {
+ text-decoration: underline;
+ }
+
+ .trailing-punctuation {
+ margin-inline-start: -0.45ch;
+ }
+</style>
diff --git a/src/components/jet/item/MediumImageLockupItem.svelte b/src/components/jet/item/MediumImageLockupItem.svelte
new file mode 100644
index 0000000..8b93453
--- /dev/null
+++ b/src/components/jet/item/MediumImageLockupItem.svelte
@@ -0,0 +1,118 @@
+<script lang="ts">
+ import type { ImageLockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: ImageLockup;
+
+ const color: string = item.artwork.backgroundColor
+ ? colorAsString(item.artwork.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper action={item.lockup.clickAction}>
+ <div class="container">
+ <HoverWrapper>
+ <div class="artwork-container">
+ <Artwork artwork={item.artwork} profile="brick" />
+ </div>
+
+ {#if item.lockup}
+ <div
+ class="lockup-container"
+ class:on-dark={item.isDark}
+ class:on-light={!item.isDark}
+ >
+ {#if item.lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={item.lockup.icon} />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if item.lockup.heading}
+ <LineClamp clamp={1}>
+ <p class="eyebrow">{item.lockup.heading}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.title}
+ <LineClamp clamp={2}>
+ <h3>{item.lockup.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.subtitle}
+ <LineClamp clamp={1}>
+ <p class="subtitle">{item.lockup.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+ {/if}
+
+ <GradientOverlay --color={color} --height="90%" />
+ </HoverWrapper>
+ </div>
+</LinkWrapper>
+
+<style>
+ .artwork-container {
+ width: 100%;
+ }
+
+ .container {
+ container-type: inline-size;
+ container-name: container;
+ }
+
+ .lockup-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 0 20px 20px;
+ }
+
+ .lockup-container.on-dark {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .lockup-container.on-light {
+ color: var(--systemPrimary-onLight);
+ }
+
+ @container container (max-width: 260px) {
+ .lockup-container {
+ padding: 0 10px 10px;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 8px;
+ }
+
+ h3 {
+ font: var(--title-3-emphasized);
+ }
+
+ .eyebrow {
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: plus-lighter;
+ }
+
+ .subtitle {
+ font: var(--callout-emphasized);
+ }
+</style>
diff --git a/src/components/jet/item/MediumLockupItem.svelte b/src/components/jet/item/MediumLockupItem.svelte
new file mode 100644
index 0000000..be70acb
--- /dev/null
+++ b/src/components/jet/item/MediumLockupItem.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ import {
+ type FlowAction,
+ type Lockup,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import type { Opt } from '@jet/environment';
+
+ export let item: Lockup;
+
+ const i18n = getI18n();
+
+ const { clickAction } = item;
+ const destination: Opt<FlowAction> = isFlowAction(clickAction)
+ ? clickAction
+ : undefined;
+</script>
+
+<LinkWrapper action={destination}>
+ <article>
+ <div class="app-icon-container">
+ <AppIcon
+ icon={item.icon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+
+ <div class="metadata-container">
+ {#if item.heading}
+ <span class="heading">{item.heading}</span>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+
+ {#if destination}
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ {/if}
+ </div>
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ display: flex;
+ align-items: center;
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 85px;
+ margin-inline-end: 16px;
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ margin-bottom: 2px;
+ }
+
+ p {
+ font: var(--callout);
+ color: var(--systemSecondary);
+ }
+
+ .heading {
+ font: var(--callout-emphasized);
+ }
+
+ .button-container {
+ margin-inline-start: auto;
+ margin-top: 8px;
+ }
+</style>
diff --git a/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte
new file mode 100644
index 0000000..7b7807c
--- /dev/null
+++ b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte
@@ -0,0 +1,304 @@
+<script lang="ts">
+ import {
+ isFlowAction,
+ type EditorialStoryCard,
+ type FlowAction,
+ } from '@jet-app/app-store/api/models';
+ import type { Opt } from '@jet/environment';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+
+ export let item: EditorialStoryCard;
+
+ let {
+ clickAction,
+ collectionIcons,
+ title,
+ lockup: { title: lockupTitle, subtitle, heading: lockupHeading } = {},
+ } = item;
+ const i18n = getI18n();
+ const hasMultipleCollectionIcons = (collectionIcons?.length ?? 0) > 1;
+ const destination: Opt<FlowAction> =
+ clickAction && isFlowAction(clickAction) ? clickAction : undefined;
+</script>
+
+<LinkWrapper action={destination}>
+ <article>
+ {#if item.artwork}
+ <div class="artwork-container">
+ <HoverWrapper element="div">
+ <Artwork
+ artwork={item.artwork}
+ profile="editorial-story-card"
+ />
+ </HoverWrapper>
+ </div>
+ {/if}
+ <div class="details-container">
+ <div
+ class="title-container"
+ class:on-dark={item.isMediaDark}
+ class:on-light={!item.isMediaDark}
+ >
+ {#if item.badge}
+ <h4>{item.badge.title}</h4>
+ {/if}
+
+ {#if item.title}
+ <h3>{@html sanitizeHtml(item.title)}</h3>
+ {/if}
+
+ {#if item.description}
+ <p>{@html sanitizeHtml(item.description)}</p>
+ {/if}
+ </div>
+
+ {#if collectionIcons && !item.editorialDisplayOptions.suppressLockup}
+ <div class="lockup-container">
+ <ul class:with-multiple-icons={hasMultipleCollectionIcons}>
+ {#each collectionIcons as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon
+ icon={collectionIcon}
+ fixedWidth={false}
+ profile={hasMultipleCollectionIcons
+ ? 'app-icon-medium'
+ : 'app-icon'}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ {#if !hasMultipleCollectionIcons}
+ <div class="metadata-container">
+ {#if lockupHeading}
+ <span class="lockup-eyebrow">
+ {lockupHeading}
+ </span>
+ {/if}
+
+ <!--
+ Some cards with the lockup UI don't have a `lockup` property,
+ so we use the title of the item as a fallback.
+ -->
+ {#if lockupTitle || title}
+ <LineClamp clamp={1}>
+ <h4 class="lockup-title">
+ {lockupTitle || title}
+ </h4>
+ </LineClamp>
+ {/if}
+
+ {#if subtitle}
+ <LineClamp clamp={1}>
+ <p class="lockup-subtitle">{subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ {#if destination}
+ <div class="button-container">
+ <span class="get-button transparent">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ {/if}
+ {/if}
+ </div>
+ {/if}
+ </div>
+ <div
+ class="blur-overlay"
+ style:--brightness={item.isMediaDark ? 0.75 : 1.25}
+ />
+ </article>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--global-border-radius-large);
+ box-shadow: var(--shadow-medium);
+ aspect-ratio: 3/4;
+ container-type: inline-size;
+ container-name: card;
+ }
+
+ .artwork-container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ }
+
+ .details-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ height: 100%;
+ border-radius: var(--global-border-radius-large);
+ overflow: hidden;
+ z-index: 1;
+ }
+
+ .title-container {
+ padding: 20px;
+ z-index: 2;
+ }
+
+ .title-container h3 {
+ margin-bottom: 2px;
+ font: var(--title-1-emphasized);
+ text-wrap: pretty;
+ }
+
+ .title-container h4 {
+ font: var(--callout-emphasized);
+ }
+
+ .on-dark {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .on-light {
+ color: var(--systemPrimary-onLight);
+ }
+
+ .title-container.on-dark h4 {
+ color: var(--systemSecondary-onDark);
+ mix-blend-mode: plus-lighter;
+ }
+
+ .title-container.on-light h4 {
+ color: var(--systemSecondary-onLight);
+ }
+
+ .title-container.on-dark p {
+ font: var(--body);
+ color: var(--systemSecondary-onDark);
+ }
+
+ .title-container.on-light p {
+ font: var(--body);
+ color: var(--systemSecondary-onLight);
+ }
+
+ .lockup-container {
+ display: flex;
+ align-items: center;
+ min-height: 80px;
+ padding: 10px 20px;
+ color: var(--systemPrimary-onDark);
+ background-image: linear-gradient(rgba(0, 0, 0, 0.2) 0 0);
+ z-index: 2;
+ }
+
+ .metadata-container {
+ flex-grow: 1;
+ margin-inline-end: 16px;
+ }
+
+ .lockup-title {
+ font: var(--title-3-emphasized);
+ }
+
+ .lockup-eyebrow {
+ color: var(--systemSecondary-onDark);
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: plus-lighter;
+ }
+
+ .lockup-subtitle {
+ color: var(--systemSecondary-onDark);
+ font: var(--callout);
+ mix-blend-mode: plus-lighter;
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 16px;
+ }
+
+ article:hover .blur-overlay {
+ height: 52%;
+ backdrop-filter: blur(70px) saturate(1.5)
+ brightness(calc(var(--brightness) * 0.9));
+ }
+
+ .blur-overlay {
+ position: absolute;
+ z-index: 1;
+ top: unset;
+ bottom: 0;
+ width: 100%;
+ height: 50%;
+ border-radius: var(--global-border-radius-large);
+ mask-image: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0) 5%,
+ rgba(0, 0, 0, 1) 50%
+ );
+ backdrop-filter: blur(50px) saturate(1.5)
+ brightness((var(--brightness)));
+ transition-property: height, backdrop-filter;
+ transition-duration: 210ms;
+ transition-timing-function: ease-out;
+ }
+
+ ul.with-multiple-icons {
+ width: 100%;
+ display: grid;
+ gap: 12px;
+
+ .app-icon-container {
+ width: 100%;
+ margin-inline-end: unset;
+ }
+ }
+
+ // In the following container queries, we are specifying column counts and hiding icons past
+ // that number to ensure a reasonable number of icons are shown for different size cards.
+ @container card (max-width: 300px) {
+ ul.with-multiple-icons {
+ // Think of "4" as the number of columns to show
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ // And "5" as the number of columns to hide past
+ .app-icon-container:nth-child(n + 5) {
+ display: none;
+ }
+ }
+
+ @container card (min-width: 300px) and (max-width: 400px) {
+ ul.with-multiple-icons {
+ grid-template-columns: repeat(5, 1fr);
+ }
+
+ .app-icon-container:nth-child(n + 6) {
+ display: none;
+ }
+ }
+
+ @container card (min-width: 400px) {
+ ul.with-multiple-icons {
+ grid-template-columns: repeat(6, 1fr);
+ }
+
+ .app-icon-container:nth-child(n + 7) {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/MediumStoryCardItem.svelte b/src/components/jet/item/MediumStoryCardItem.svelte
new file mode 100644
index 0000000..80ead7d
--- /dev/null
+++ b/src/components/jet/item/MediumStoryCardItem.svelte
@@ -0,0 +1,27 @@
+<script lang="ts" context="module">
+ import type {
+ EditorialStoryCard,
+ TodayCard,
+ } from '@jet-app/app-store/api/models';
+
+ export type Item = EditorialStoryCard | TodayCard;
+
+ function isEditorialStoryCard(item: Item): item is EditorialStoryCard {
+ return 'artwork' in item;
+ }
+</script>
+
+<script lang="ts">
+ import EditorialStoryCardItem from '~/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte';
+ import SmallStoryCardWithMediaItem, {
+ isSmallStoryCardWithMediaItem,
+ } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte';
+
+ export let item: Item;
+</script>
+
+{#if isEditorialStoryCard(item)}
+ <EditorialStoryCardItem {item} />
+{:else if isSmallStoryCardWithMediaItem(item)}
+ <SmallStoryCardWithMediaItem {item} />
+{/if}
diff --git a/src/components/jet/item/MixedMediaLockupItem.svelte b/src/components/jet/item/MixedMediaLockupItem.svelte
new file mode 100644
index 0000000..4874419
--- /dev/null
+++ b/src/components/jet/item/MixedMediaLockupItem.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ import type { MixedMediaLockup } from '@jet-app/app-store/api/models';
+
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: MixedMediaLockup;
+
+ let video = item.trailers?.[0]?.videos[0];
+</script>
+
+<div class="mixed-media-lockup-item">
+ <div class="video-wrapper">
+ {#if video}
+ <Video {video} profile="brick" shouldSuperimposePosterImage />
+ {/if}
+ </div>
+ <SmallLockupItem {item} />
+</div>
+
+<style>
+ .mixed-media-lockup-item {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .video-wrapper {
+ --mixed-media-lockup-video-aspect-ratio: 16/9;
+ aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio);
+ overflow: hidden;
+ border-radius: 7px;
+ }
+
+ .video-wrapper :global(video) {
+ aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio);
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/item/ParagraphShelfItem.svelte b/src/components/jet/item/ParagraphShelfItem.svelte
new file mode 100644
index 0000000..9adf09c
--- /dev/null
+++ b/src/components/jet/item/ParagraphShelfItem.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import type { Paragraph } from '@jet-app/app-store/api/models';
+ import he from 'he';
+
+ export let item: Paragraph;
+</script>
+
+<p>
+ {@html he.decode(item.text)}
+</p>
+
+<style>
+ p {
+ font: var(--title-2-medium);
+ color: var(--systemSecondary);
+ }
+
+ p :global(b) {
+ color: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/item/PosterLockupItem.svelte b/src/components/jet/item/PosterLockupItem.svelte
new file mode 100644
index 0000000..08b34e2
--- /dev/null
+++ b/src/components/jet/item/PosterLockupItem.svelte
@@ -0,0 +1,121 @@
+<script lang="ts">
+ import type { PosterLockup } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+
+ export let item: PosterLockup;
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ <article>
+ <div class="background">
+ {#if item.epicHeading}
+ <div class="title-container">
+ <Artwork
+ hasTransparentBackground
+ artwork={item.epicHeading}
+ alt={item.heading}
+ profile="poster-title"
+ />
+ </div>
+ {/if}
+
+ {#if item.posterVideo}
+ <div class="video-container">
+ <Video
+ autoplay
+ loop
+ video={item.posterVideo}
+ useControls={false}
+ profile="poster-lockup"
+ />
+ </div>
+ {:else if item.posterArtwork}
+ <div class="artwork-container">
+ <Artwork
+ artwork={item.posterArtwork}
+ profile="poster-lockup"
+ />
+ </div>
+ {/if}
+ </div>
+
+ <div class="content">
+ <div class="logo-container">
+ <AppleArcadeLogo aria-label={item.heading} />
+ </div>
+
+ <span>
+ {item.footerText}
+ {#if item.tertiaryTitle}
+ | {item.tertiaryTitle}
+ {/if}
+ </span>
+ </div>
+ </article>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style>
+ article {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16/9;
+ overflow: hidden;
+ color: var(--systemPrimary-onDark);
+ border-radius: var(--global-border-radius-large);
+ container-type: inline-size;
+ container-name: poster-lockup-item;
+ }
+
+ .title-container {
+ position: absolute;
+ z-index: 2;
+ width: 100%;
+ }
+
+ .background {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ line-height: 0;
+ }
+
+ .content {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: space-between;
+ height: 100%;
+ padding: 12px 0;
+ font: var(--body);
+ background: linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0.5) 0%,
+ rgba(255, 255, 255, 0) 25%,
+ rgba(255, 255, 255, 0) 50%,
+ rgba(255, 255, 255, 0) 80%,
+ rgba(0, 0, 0, 0.4) 100%
+ );
+ }
+
+ .logo-container {
+ width: 62px;
+ margin-bottom: 10px;
+ line-height: 0;
+ }
+
+ @container poster-lockup-item (min-width: 550px) {
+ .logo-container {
+ width: 78px;
+ }
+ }
+
+ .logo-container :global(path) {
+ color: var(--systemPrimary-onDark);
+ }
+</style>
diff --git a/src/components/jet/item/PrivacyHeaderItem.svelte b/src/components/jet/item/PrivacyHeaderItem.svelte
new file mode 100644
index 0000000..f9611a6
--- /dev/null
+++ b/src/components/jet/item/PrivacyHeaderItem.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { PrivacyHeader } from '@jet-app/app-store/api/models';
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ export let item: PrivacyHeader;
+</script>
+
+<div>
+ <p>
+ <LinkableTextItem item={item.bodyText} />
+ </p>
+
+ {#if item.supplementaryItems.length}
+ <div class="supplementary-items-container">
+ {#each item.supplementaryItems as supItem}
+ <p>
+ <LinkableTextItem item={supItem.bodyText} />
+ </p>
+ {/each}
+ </div>
+ {/if}
+</div>
+
+<style>
+ p {
+ font: var(--body-tall);
+ }
+
+ p :global(a) {
+ color: var(--keyColor);
+ }
+
+ .supplementary-items-container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 20px 0 0;
+ margin-top: 20px;
+ border-top: 1px solid var(--systemGray4);
+ }
+</style>
diff --git a/src/components/jet/item/PrivacyTypeItem.svelte b/src/components/jet/item/PrivacyTypeItem.svelte
new file mode 100644
index 0000000..5e63966
--- /dev/null
+++ b/src/components/jet/item/PrivacyTypeItem.svelte
@@ -0,0 +1,193 @@
+<script lang="ts">
+ import type { PrivacyType } from '@jet-app/app-store/api/models';
+
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let item: PrivacyType;
+ export let isDetailView: boolean = false;
+</script>
+
+<article class:is-detail-view={isDetailView}>
+ {#if item.artwork && isSystemImageArtwork(item.artwork)}
+ <span class="icon-container" aria-hidden="true">
+ <SystemImage artwork={item.artwork} />
+ </span>
+ {/if}
+
+ <h2>{item.title}</h2>
+ <p>{item.detail}</p>
+
+ <ul class:grid={item.categories.length > 1 && !isDetailView}>
+ {#each item.categories as category}
+ <li>
+ {#if isSystemImageArtwork(category.artwork)}
+ <span aria-hidden="true" class="category-icon-container">
+ <SystemImage artwork={category.artwork} />
+ </span>
+ {/if}
+ {category.title}
+ </li>
+ {/each}
+ </ul>
+
+ {#each item.purposes as purpose}
+ <section class="purpose-section">
+ <h3>{purpose.title}</h3>
+
+ {#each purpose.categories as category}
+ <li class="purpose-category">
+ {#if isSystemImageArtwork(category.artwork)}
+ <span
+ aria-hidden="true"
+ class="category-icon-container"
+ >
+ <SystemImage artwork={category.artwork} />
+ </span>
+ {/if}
+
+ <span class="category-title">{category.title}</span>
+
+ <ul class="privacy-data-types">
+ {#each category.dataTypes as type}
+ <li>{type}</li>
+ {/each}
+ </ul>
+ </li>
+ {/each}
+ </section>
+ {/each}
+</article>
+
+<style lang="scss">
+ @use 'amp/stylekit/core/border-radiuses' as *;
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ article {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 30px;
+ gap: 8px;
+ text-align: center;
+ font: var(--body-tall);
+ border-radius: $global-border-radius-rounded-large;
+ background-color: var(--systemQuinary);
+
+ &.is-detail-view {
+ padding: 20px 0 0;
+ margin-top: 20px;
+ text-align: left;
+ border-radius: 0;
+ background-color: transparent;
+ border-top: 1px solid var(--defaultLine);
+ }
+ }
+
+ .icon-container {
+ width: 30px;
+ margin: 0 auto;
+
+ .is-detail-view & {
+ display: block;
+ width: 32px;
+ margin: 0;
+ }
+ }
+
+ .icon-container :global(svg) {
+ width: 100%;
+ fill: var(--keyColor);
+ }
+
+ h2 {
+ font: var(--title-3-emphasized);
+
+ .is-detail-view & {
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ p {
+ text-wrap: pretty;
+ font: var(--body-tall);
+ color: var(--systemSecondary);
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ li {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: start;
+ padding: 4px 0;
+ gap: 8px;
+
+ .is-detail-view & {
+ justify-content: start;
+ }
+ }
+
+ .category-title {
+ font: var(--title-3);
+ }
+
+ .grid li {
+ justify-content: start;
+ }
+
+ .category-icon-container {
+ display: inline-flex;
+
+ @media (prefers-color-scheme: dark) {
+ filter: invert(1);
+ }
+
+ .is-detail-view & {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .category-icon-container :global(svg) {
+ width: 20px;
+
+ .is-detail-view & {
+ width: 20px;
+ height: 18px;
+ }
+ }
+
+ .purpose-section {
+ border-top: 1px solid var(--defaultLine);
+ padding-top: 16px;
+ }
+
+ .purpose-section + .purpose-section {
+ margin-top: 4px;
+ }
+
+ .purpose-section h3 {
+ margin-bottom: 8px;
+ }
+
+ .purpose-category {
+ display: grid;
+ grid-template-areas:
+ 'icon title'
+ '. types';
+ align-items: center;
+ }
+
+ .privacy-data-types {
+ grid-area: types;
+ color: var(--systemSecondary);
+ font: var(--body);
+ }
+</style>
diff --git a/src/components/jet/item/ProductBadgeItem.svelte b/src/components/jet/item/ProductBadgeItem.svelte
new file mode 100644
index 0000000..fa32e6f
--- /dev/null
+++ b/src/components/jet/item/ProductBadgeItem.svelte
@@ -0,0 +1,188 @@
+<script lang="ts">
+ import type { Badge } from '@jet-app/app-store/api/models';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import StarRating from '~/components/StarRating.svelte';
+ import GameController from '~/sf-symbols/gamecontroller.fill.svg';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import ContentRatingBadge, {
+ isContentRatingBadge,
+ } from '../badge/ContentRatingBadge.svelte';
+
+ export let item: Badge;
+
+ const { artwork, content, type } = item;
+
+ $: isParagraph = type === 'paragraph';
+ $: isRating = type === 'rating';
+ $: isEditorsChoice = type === 'editorsChoice';
+ $: isController = type === 'controller';
+ $: hasImageArtwork = artwork && !isSystemImageArtwork(artwork);
+</script>
+
+<LinkWrapper withoutLabel action={item.clickAction}>
+ <div class="badge-container">
+ <div class="badge">
+ <div class="badge-dt" role="term">
+ <LineClamp clamp={1}>
+ {item.heading}
+ </LineClamp>
+ </div>
+
+ <div class="badge-dd" role="definition">
+ {#if isContentRatingBadge(item)}
+ <ContentRatingBadge badge={item} />
+ {:else if isParagraph}
+ <span class="text-container">{content.paragraphText}</span>
+ {:else if isRating && !content.rating}
+ <span class="text-container">
+ {content.ratingFormatted}
+ </span>
+ {:else if isEditorsChoice}
+ <span class="editors-choice">
+ <SFSymbol name="laurel.leading" ariaHidden={true} />
+
+ <span>
+ <LineClamp clamp={2}>
+ {item.accessibilityTitle}
+ </LineClamp>
+ </span>
+
+ <SFSymbol name="laurel.trailing" ariaHidden={true} />
+ </span>
+ {:else if artwork && hasImageArtwork}
+ <div class="artwork-container" aria-hidden="true">
+ <Artwork
+ {artwork}
+ profile="app-icon"
+ hasTransparentBackground
+ />
+ </div>
+ {:else if artwork && isSystemImageArtwork(artwork)}
+ <div class="icon-container color" aria-hidden="true">
+ <SystemImage {artwork} />
+ </div>
+ {:else if isController}
+ <div class="icon-container" aria-hidden="true">
+ <GameController />
+ </div>
+ {/if}
+
+ {#if isRating && content.rating}
+ <span class="text-container" aria-hidden="true">
+ {content.ratingFormatted}
+ </span>
+ <StarRating rating={content.rating} />
+ {:else}
+ <LineClamp clamp={1}>{item.caption}</LineClamp>
+ {/if}
+ </div>
+ </div>
+ </div>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .badge-container {
+ --color: var(--systemGray3-onDark);
+ --accent-color: var(--systemSecondary);
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ transition: filter 210ms ease-in;
+
+ @media (prefers-color-scheme: dark) {
+ --color: var(--systemGray3-onLight);
+ }
+ }
+
+ .badge {
+ text-align: center;
+ }
+
+ .artwork-container {
+ height: 25px;
+ aspect-ratio: 1/1;
+ margin: 4px 0 2px;
+ opacity: 0.7;
+
+ @media (prefers-color-scheme: dark) {
+ filter: invert(1);
+ }
+ }
+
+ .icon-container {
+ display: flex;
+ width: 35px;
+ height: 25px;
+ margin: 4px 0 2px;
+ line-height: 0;
+ }
+
+ .icon-container.color {
+ filter: brightness(1);
+ }
+
+ .badge-dt {
+ text-transform: uppercase;
+ font: var(--subhead-emphasized);
+ color: var(--accent-color);
+ margin-bottom: 4px;
+ }
+
+ .text-container {
+ height: 25px;
+ margin: 4px 0 2px;
+ font: var(--title-1-emphasized);
+ color: var(--color);
+ }
+
+ .editors-choice {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 30px;
+
+ :global(svg) {
+ height: 20px;
+ flex-shrink: 0;
+
+ @include rtl {
+ transform: rotateY(180deg);
+ }
+ }
+
+ @media (--range-medium-only) {
+ gap: 2px;
+ }
+
+ :global(svg path:not([fill='none'])) {
+ fill: var(--color);
+ }
+ }
+
+ .editors-choice span {
+ width: 50%;
+ font: var(--subhead-medium);
+
+ @media (--range-medium-only) {
+ width: 55%;
+ }
+ }
+
+ .badge-dd {
+ --fill-color: var(--color);
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ font: var(--subhead-tall);
+ color: var(--color);
+ gap: 4px;
+ }
+</style>
diff --git a/src/components/jet/item/ProductCapabilityItem.svelte b/src/components/jet/item/ProductCapabilityItem.svelte
new file mode 100644
index 0000000..21e97cd
--- /dev/null
+++ b/src/components/jet/item/ProductCapabilityItem.svelte
@@ -0,0 +1,84 @@
+<script lang="ts">
+ import {
+ type ProductCapability,
+ type ProductCapabilityType,
+ } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ type CapabilityIcons = Record<ProductCapabilityType, string | undefined>;
+
+ const capabilityIcons: CapabilityIcons = {
+ gameCenter: '/assets/images/supports/supports-GameCenter@2x.png',
+ siri: '/assets/images/supports/supports-Siri@2x.png',
+ wallet: '/assets/images/supports/supports-Wallet@2x.png',
+ controllers: '/assets/images/supports/supports-GameController@2x.png',
+ familySharing: '/assets/images/supports/supports-FamilySharing@2x.png',
+ sharePlay: '/assets/images/supports/supports-Shareplay@2x.png',
+ spatialControllers:
+ '/assets/images/supports/supports-SpatialController@2x.png',
+ safariExtensions: '/assets/images/supports/supports-Safari@2x.png',
+ };
+
+ export let item: ProductCapability;
+</script>
+
+<article>
+ <div class="capability-icon-container">
+ <img
+ src={capabilityIcons[item.type]}
+ class="capability-icon"
+ alt=""
+ aria-hidden="true"
+ />
+ </div>
+
+ <div class="metadata-container">
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+
+ <p>
+ <LinkableTextItem item={item.caption} />
+ </p>
+ </div>
+</article>
+
+<style>
+ article {
+ display: flex;
+ align-items: center;
+ }
+
+ .capability-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 16px;
+ }
+
+ .capability-icon {
+ margin-top: 2px;
+ min-width: 46px;
+ height: 46px;
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ .metadata-container :global(a) {
+ color: var(--keyColor);
+ }
+
+ h3 {
+ color: var(--systemPrimary);
+ font-size: 1em;
+ margin-bottom: 1px;
+ }
+
+ p {
+ color: var(--systemSecondary);
+ font: var(--body-tall);
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte
new file mode 100644
index 0000000..516ed32
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+ import type { ProductMediaItem } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+</script>
+
+{#if item.screenshot}
+ <article>
+ <Artwork artwork={item.screenshot} profile="screenshot-mac" />
+ </article>
+{:else if item.video}
+ <article>
+ <Video autoplay video={item.video} profile="screenshot-mac" />
+ </article>
+{/if}
+
+<style>
+ article {
+ overflow: hidden;
+ }
+
+ article :global(.video) {
+ aspect-ratio: 16/10;
+ }
+
+ article :global(video) {
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte
new file mode 100644
index 0000000..6b9886c
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte
@@ -0,0 +1,89 @@
+<script lang="ts">
+ import type {
+ ProductMediaItem,
+ MediaType,
+ } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+ export let hasPortraitMedia: boolean;
+ export let mediaType: MediaType | undefined;
+</script>
+
+{#if item.screenshot || item.video}
+ <article>
+ <div
+ class="artwork-container"
+ class:ipad-pro-2018={mediaType === 'ipadPro_2018'}
+ class:ipad-11={mediaType === 'ipad_11'}
+ class:portrait={hasPortraitMedia}
+ >
+ {#if item.screenshot}
+ <Artwork
+ artwork={item.screenshot}
+ profile={hasPortraitMedia
+ ? 'screenshot-pad-portrait'
+ : 'screenshot-pad'}
+ />
+ {:else if item.video}
+ <Video
+ autoplay
+ video={item.video}
+ profile={hasPortraitMedia
+ ? 'screenshot-pad-portrait'
+ : 'screenshot-pad'}
+ />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container,
+ .artwork-container :global(video) {
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: contain;
+ border-radius: 1.3% / 1.9%;
+ overflow: hidden;
+
+ /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */
+ transform: translateZ(0);
+ }
+
+ .artwork-container.portrait {
+ aspect-ratio: 3/4;
+ background: var(--systemQuaternary);
+ }
+
+ .artwork-container.portrait,
+ .artwork-container.portrait :global(video) {
+ border-radius: 1.9% / 1.3%;
+ }
+
+ .ipad-pro-2018,
+ .ipad-pro-2018 :global(video) {
+ mask-image: url('/assets/images/masks/ipad-pro-2018-mask-landscape.svg');
+ }
+
+ .ipad-pro-2018.portrait,
+ .ipad-pro-2018.portrait :global(video) {
+ mask-image: url('/assets/images/masks/ipad-pro-2018-mask.svg');
+ }
+
+ .ipad-11,
+ .ipad-11 :global(video) {
+ mask-image: url('/assets/images/masks/ipad-11-mask-landscape.svg');
+ }
+
+ .ipad-11.portrait,
+ .ipad-11.portrait :global(video) {
+ mask-image: url('/assets/images/masks/ipad-11-mask.svg');
+ }
+
+ .artwork-container :global(video):fullscreen {
+ mask-image: none;
+ border-radius: 0;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte
new file mode 100644
index 0000000..255b663
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte
@@ -0,0 +1,142 @@
+<script lang="ts">
+ import type {
+ ProductMediaItem,
+ MediaType,
+ } from '@jet-app/app-store/api/models';
+ import { getAspectRatio } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+
+ export let item: ProductMediaItem;
+ export let hasPortraitMedia: boolean;
+ export let mediaType: MediaType | undefined;
+
+ const getArtworkProfile = (
+ mediaType: MediaType | undefined,
+ hasPortraitMedia: boolean,
+ ): NamedProfile => {
+ const suffix = hasPortraitMedia ? '_portrait' : '';
+
+ // Map specific media types to their artwork profile names
+ const mediaTypeProfiles: Record<string, string> = {
+ iphone_6_5: 'screenshot-iphone_6_5',
+ iphone_5_8: 'screenshot-iphone_5_8',
+ iphone_d74: 'screenshot-iphone_d74',
+ };
+
+ const baseProfile =
+ mediaType && mediaTypeProfiles[mediaType]
+ ? mediaTypeProfiles[mediaType]
+ : 'screenshot-phone';
+
+ return `${baseProfile}${suffix}` as NamedProfile;
+ };
+
+ $: isLandscapeScreenshot =
+ item.screenshot && item.screenshot.width > item.screenshot.height;
+ $: profile = getArtworkProfile(mediaType, !isLandscapeScreenshot);
+ $: restOfShelfAspectRatio = getAspectRatio(
+ getArtworkProfile(mediaType, hasPortraitMedia),
+ );
+</script>
+
+{#if item.screenshot || item.video}
+ <article
+ class:with-rotated-artwork={isLandscapeScreenshot && hasPortraitMedia}
+ style:--aspect-ratio={`${restOfShelfAspectRatio}`}
+ >
+ <div
+ class="artwork-container"
+ class:iphone-6-5={mediaType === 'iphone_6_5'}
+ class:iphone-5-8={mediaType === 'iphone_5_8'}
+ class:iphone-d74={mediaType === 'iphone_d74'}
+ class:portrait={hasPortraitMedia}
+ >
+ {#if item.screenshot}
+ <Artwork
+ {profile}
+ artwork={item.screenshot}
+ disableAutoCenter={true}
+ withoutBorder={true}
+ />
+ {:else if item.video}
+ <Video autoplay video={item.video} {profile} />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ article.with-rotated-artwork {
+ position: relative;
+ aspect-ratio: var(--aspect-ratio);
+ }
+
+ /*
+ * For iPhone screenshots that are landscape, but in a shelf/list with portrait screenshots,
+ * as denoted by `hasPortraitMedia`, we rotate the landscape screenshot to be in the portrait
+ * orientation, and scale it up so it fills the container.
+ */
+ article.with-rotated-artwork .artwork-container {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ height: auto;
+ width: calc((1 / var(--aspect-ratio)) * 100%);
+ transform: translate(-50%, -50%) rotate(-90deg);
+ transform-origin: center;
+ }
+
+ .artwork-container,
+ .artwork-container :global(video) {
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: 100%;
+ border-radius: 20px;
+ overflow: hidden;
+
+ /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */
+ transform: translateZ(0);
+ }
+
+ .iphone-5-8,
+ .iphone-5-8 :global(video) {
+ /* need to confirm with design for correct value */
+ border-radius: 23px;
+ mask-image: url('/assets/images/masks/iphone-5-8-mask-landscape.svg');
+ }
+
+ .iphone-5-8.portrait,
+ .iphone-5-8.portrait :global(video) {
+ mask-image: url('/assets/images/masks/iphone-5-8-mask.svg');
+ }
+
+ .iphone-6-5,
+ .iphone-6-5 :global(video) {
+ /* need to confirm with design for correct value */
+ border-radius: 21px;
+ mask-image: url('/assets/images/masks/iphone-6-5-mask-landscape.svg');
+ }
+
+ .iphone-6-5.portrait,
+ .iphone-6-5.portrait :global(video) {
+ mask-image: url('/assets/images/masks/iphone-6-5-mask.svg');
+ }
+
+ .iphone-d74,
+ .iphone-d74 :global(video) {
+ border-radius: 5.7% / 12.8%;
+ }
+
+ .iphone-d74.portrait,
+ .iphone-d74.portrait :global(video) {
+ border-radius: 12.8% / 5.7%;
+ }
+
+ .artwork-container :global(video):fullscreen {
+ mask-image: none;
+ border-radius: 0;
+ object-fit: contain;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte
new file mode 100644
index 0000000..7f7fd7a
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ import type { ProductMediaItem } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+</script>
+
+{#if item.screenshot || item.video}
+ <article>
+ <div class="artwork-container">
+ {#if item.screenshot}
+ <Artwork artwork={item.screenshot} profile="screenshot-tv" />
+ {:else if item.video}
+ <Video autoplay video={item.video} profile="screenshot-tv" />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container,
+ .artwork-container :global(video) {
+ border-radius: 1.3% / 1.9%;
+ overflow: hidden;
+
+ /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */
+ transform: translateZ(0);
+ }
+
+ .artwork-container :global(video):fullscreen {
+ border-radius: 0;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte
new file mode 100644
index 0000000..e893dd6
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+ import type { ProductMediaItem } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+</script>
+
+{#if item.screenshot || item.video}
+ <article>
+ <div class="artwork-container">
+ {#if item.screenshot}
+ <Artwork
+ artwork={item.screenshot}
+ profile="screenshot-vision"
+ />
+ {:else if item.video}
+ <Video
+ autoplay
+ video={item.video}
+ profile="screenshot-vision"
+ />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container,
+ .artwork-container :global(video) {
+ border-radius: 20px;
+ overflow: hidden;
+ }
+
+ .artwork-container :global(video):fullscreen {
+ border-radius: 0;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte
new file mode 100644
index 0000000..0a4b50e
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import type {
+ ProductMediaItem,
+ MediaType,
+ } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+
+ export let item: ProductMediaItem;
+ export let mediaType: MediaType | undefined;
+</script>
+
+{#if item.screenshot}
+ <article>
+ <div
+ class="artwork-container"
+ class:apple-watch-2018={mediaType === 'appleWatch_2018'}
+ class:apple-watch-2021={mediaType === 'appleWatch_2021'}
+ class:apple-watch-2022={mediaType === 'appleWatch_2022'}
+ class:apple-watch-2024={mediaType === 'appleWatch_2024'}
+ >
+ <Artwork artwork={item.screenshot} profile="screenshot-watch" />
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container {
+ mask-position: center;
+ mask-size: contain;
+ mask-repeat: no-repeat;
+ border-radius: 12px;
+ overflow: hidden;
+ }
+
+ .apple-watch-2018 {
+ mask-image: url('/assets/images/masks/apple-watch-2018-mask.svg');
+ }
+
+ .apple-watch-2021 {
+ mask-image: url('/assets/images/masks/apple-watch-2021-mask.svg');
+ }
+
+ .apple-watch-2022 {
+ mask-image: url('/assets/images/masks/apple-watch-2022-mask.svg');
+ }
+
+ .apple-watch-2024 {
+ mask-image: url('/assets/images/masks/apple-watch-2024-mask.svg');
+ }
+</style>
diff --git a/src/components/jet/item/ProductPageLinkItem.svelte b/src/components/jet/item/ProductPageLinkItem.svelte
new file mode 100644
index 0000000..be4bb16
--- /dev/null
+++ b/src/components/jet/item/ProductPageLinkItem.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import {
+ type ProductPageLink,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import { isExternalUrlAction } from '~/jet/models/';
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import ExternalURLAction from '~/components/jet/action/ExternalUrlAction.svelte';
+
+ export let item: ProductPageLink;
+
+ const clickAction = item.clickAction;
+
+ $: canRenderContainer =
+ isFlowAction(clickAction) || isExternalUrlAction(clickAction);
+</script>
+
+{#if canRenderContainer}
+ <div class="product-link-container">
+ {#if isFlowAction(clickAction)}
+ <FlowAction destination={clickAction}>
+ {item.text}
+ </FlowAction>
+ {:else if isExternalUrlAction(clickAction)}
+ <ExternalURLAction destination={clickAction}>
+ {item.text}
+ </ExternalURLAction>
+ {/if}
+ </div>
+{/if}
+
+<style>
+ .product-link-container {
+ @media (--range-xsmall-down) {
+ padding: 10px 0;
+ }
+ }
+
+ .product-link-container :global(a) {
+ display: inline-flex;
+ align-items: center;
+ color: var(--keyColor);
+ text-decoration: none;
+ gap: 6px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ @media (--range-xsmall-down) {
+ font-size: 18px;
+ gap: 8px;
+ }
+ }
+
+ .product-link-container :global(a) :global(.external-link-arrow) {
+ width: 7px;
+ height: 7px;
+ fill: var(--keyColor);
+ margin-top: 3px;
+
+ @media (--range-xsmall-down) {
+ width: 10px;
+ height: 10px;
+ margin-top: 2px;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/ProductRatingsItem.svelte b/src/components/jet/item/ProductRatingsItem.svelte
new file mode 100644
index 0000000..0345993
--- /dev/null
+++ b/src/components/jet/item/ProductRatingsItem.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import type { Ratings } from '@jet-app/app-store/api/models';
+
+ import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte';
+ import { getJet } from '~/jet/svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Ratings;
+
+ const i18n = getI18n();
+ const jet = getJet();
+ const numberOfRatings = jet.localization.formattedCount(
+ item.totalNumberOfRatings,
+ );
+</script>
+
+<article>
+ {#if item.totalNumberOfRatings === 0}
+ {item.status}
+ {:else}
+ <RatingComponent
+ averageRating={jet.localization.decimal(item.ratingAverage, 1)}
+ ratingCount={item.totalNumberOfRatings}
+ ratingCountText={$i18n.t('ASE.Web.AppStore.Ratings.CountText', {
+ numberOfRatings: numberOfRatings,
+ })}
+ totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')}
+ ratingCountsList={item.ratingCounts}
+ />
+ {/if}
+</article>
+
+<style>
+ article {
+ --ratingBarColor: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte
new file mode 100644
index 0000000..2bb6a06
--- /dev/null
+++ b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte
@@ -0,0 +1,99 @@
+<script lang="ts" context="module">
+ import type {
+ EditorsChoice,
+ ProductReview,
+ } from '@jet-app/app-store/api/models';
+
+ interface EditorsChoiceReview extends ProductReview {
+ sourceType: 'editorsChoice';
+ review: EditorsChoice;
+ }
+
+ export function isEditorsChoiceReviewItem(
+ productReview: ProductReview,
+ ): productReview is EditorsChoiceReview {
+ return productReview.sourceType === 'editorsChoice';
+ }
+</script>
+
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte';
+ import EditorsChoiceBadge from '~/components/EditorsChoiceBadge.svelte';
+ import { getJet } from '~/jet';
+ import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics';
+
+ export let item: EditorsChoiceReview;
+ export let isDetailView: boolean = false;
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const translateFn = (key: string) => $i18n.t(key);
+ const i18n = getI18n();
+ const jet = getJet();
+
+ const handleCloseModal = () => modalComponent?.close();
+ const handleOpenModal = () => {
+ modalComponent?.showModal();
+ jet.recordCustomMetricsEvent({
+ eventType: 'dialog',
+ dialogId: 'more',
+ targetId: CUSTOMER_REVIEW_MODAL_ID,
+ dialogType: 'button',
+ });
+ };
+</script>
+
+<article class:is-detail-view={isDetailView}>
+ <EditorsChoiceBadge
+ --font={isDetailView
+ ? 'var(--large-title-emphasized)'
+ : 'var(--title-1-emphasized)'}
+ />
+
+ {#if isDetailView}
+ <p>{item.review.notes}</p>
+ {:else}
+ <Truncate
+ {translateFn}
+ lines={4}
+ text={item.review.notes}
+ title={$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
+ isPortalModal={true}
+ on:openModal={handleOpenModal}
+ />
+ {/if}
+</article>
+
+{#if !isDetailView}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleCloseModal}
+ title={null}
+ subtitle={null}
+ targetId={CUSTOMER_REVIEW_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <svelte:self {item} isDetailView={true} />
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+{/if}
+
+<style>
+ article:not(.is-detail-view) {
+ height: 186px;
+ padding: 20px;
+ background-color: var(--systemQuinary);
+ border-radius: var(--global-border-radius-xlarge);
+ }
+
+ article :global(.more) {
+ --moreTextColorOverride: var(--keyColor);
+ --moreFontOverride: var(--body);
+ text-transform: lowercase;
+ }
+</style>
diff --git a/src/components/jet/item/ProductReview/UserReviewItem.svelte b/src/components/jet/item/ProductReview/UserReviewItem.svelte
new file mode 100644
index 0000000..472dd1f
--- /dev/null
+++ b/src/components/jet/item/ProductReview/UserReviewItem.svelte
@@ -0,0 +1,25 @@
+<script lang="ts" context="module">
+ import {
+ type Review as ReviewModel,
+ ProductReview,
+ } from '@jet-app/app-store/api/models';
+
+ interface UserReview extends ProductReview {
+ sourceType: 'user';
+ review: ReviewModel;
+ }
+
+ export function isUserReviewItem(
+ productReview: ProductReview,
+ ): productReview is UserReview {
+ return productReview.sourceType === 'user';
+ }
+</script>
+
+<script lang="ts">
+ import ReviewItem from '~/components/jet/item/ReviewItem.svelte';
+
+ export let item: UserReview;
+</script>
+
+<ReviewItem item={item.review} />
diff --git a/src/components/jet/item/ReviewItem.svelte b/src/components/jet/item/ReviewItem.svelte
new file mode 100644
index 0000000..7f406c8
--- /dev/null
+++ b/src/components/jet/item/ReviewItem.svelte
@@ -0,0 +1,237 @@
+<script lang="ts">
+ import type { Review as ReviewModel } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte';
+ import StarRating from '~/components/StarRating.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import { getI18n } from '~/stores/i18n';
+ import { getJet } from '~/jet/svelte';
+ import {
+ escapeHtml,
+ stripUnicodeWhitespace,
+ } from '~/utils/string-formatting';
+ import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics';
+
+ export let item: ReviewModel;
+ export let isDetailView: boolean = false;
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const jet = getJet();
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+
+ const handleCloseModal = () => modalComponent?.close();
+ const handleOpenModal = () => {
+ modalComponent?.showModal();
+ jet.recordCustomMetricsEvent({
+ eventType: 'dialog',
+ dialogId: 'more',
+ targetId: CUSTOMER_REVIEW_MODAL_ID,
+ dialogType: 'button',
+ });
+ };
+
+ $: ({ id, reviewerName, rating, contents, title, date, response } = item);
+ $: dateForDisplay = jet.localization.timeAgo(new Date(date));
+ $: dateForAttribute = new Date(date).toISOString();
+ $: titleId = `review-${id}-title`;
+ $: maximumLinesForReview = response ? 3 : 5;
+ $: responseDateForDisplay =
+ response && jet.localization.timeAgo(new Date(response.date));
+ $: responseDateForAttribute =
+ response && new Date(response.date).toISOString();
+ $: reviewContents = stripUnicodeWhitespace(escapeHtml(contents));
+ $: responseContents =
+ response && stripUnicodeWhitespace(escapeHtml(response.contents));
+</script>
+
+<article class:is-detail-view={isDetailView} aria-labelledby={titleId}>
+ <div class="header">
+ <div class="title-and-rating-container">
+ {#if !isDetailView}
+ <h3 id={titleId} class="title">
+ <LineClamp clamp={1}>
+ {title}
+ </LineClamp>
+ </h3>
+ {/if}
+
+ <StarRating
+ {rating}
+ --fill-color="var(--systemOrange)"
+ --star-size={isDetailView ? '24px' : '12px'}
+ />
+ </div>
+
+ <div class="review-header">
+ <time class="date" datetime={dateForAttribute}>
+ {dateForDisplay}
+ </time>
+
+ <LineClamp clamp={1}>
+ <p class="author">
+ {reviewerName}
+ </p>
+ </LineClamp>
+ </div>
+ </div>
+
+ {#if isDetailView}
+ <p>
+ {@html sanitizeHtml(reviewContents, {
+ allowedTags: [''],
+ keepChildrenWhenRemovingParent: true,
+ })}
+
+ {#if response}
+ <div class="developer-response-container">
+ <div class="developer-response-header">
+ <span class="developer-response-heading">
+ {$i18n.t(
+ 'ASE.Web.AppStore.Review.DeveloperResponse',
+ )}
+ </span>
+
+ <time class="date" datetime={responseDateForAttribute}>
+ {responseDateForDisplay}
+ </time>
+ </div>
+
+ {@html sanitizeHtml(responseContents, {
+ allowedTags: [''],
+ keepChildrenWhenRemovingParent: true,
+ })}
+ </div>
+ {/if}
+ </p>
+ {:else}
+ <div class="content">
+ <Truncate
+ on:openModal={handleOpenModal}
+ {title}
+ lines={maximumLinesForReview}
+ {translateFn}
+ text={reviewContents}
+ isPortalModal={true}
+ />
+
+ {#if item.response}
+ <div class="developer-response-container">
+ <span class="developer-response-heading">
+ {$i18n.t('ASE.Web.AppStore.Review.DeveloperResponse')}
+ </span>
+ <Truncate
+ on:openModal={handleOpenModal}
+ {title}
+ {translateFn}
+ lines={1}
+ text={responseContents}
+ isPortalModal={true}
+ />
+ </div>
+ {/if}
+ </div>
+ {/if}
+</article>
+
+{#if !isDetailView}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleCloseModal}
+ {title}
+ subtitle={null}
+ targetId={CUSTOMER_REVIEW_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <svelte:self {item} isDetailView={true} />
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+{/if}
+
+<style lang="scss">
+ article:not(.is-detail-view) {
+ height: 186px;
+ padding: 20px 16px;
+ background-color: var(--systemQuinary);
+ border-radius: var(--global-border-radius-xlarge);
+
+ @media (--small) {
+ padding: 20px;
+ }
+ }
+
+ .header {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 18px;
+ align-items: center;
+ justify-content: space-between;
+
+ .is-detail-view & {
+ margin-bottom: 0;
+ }
+ }
+
+ .title-and-rating-container {
+ .is-detail-view & {
+ display: flex;
+ }
+ }
+
+ .title {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ margin-bottom: 4px;
+ }
+
+ .date,
+ .author {
+ color: var(--systemSecondary);
+ font: var(--callout);
+ word-break: normal;
+ }
+
+ .content {
+ position: relative;
+ word-wrap: break-word; /* Break to fit the review block, even when people leave a review with long text without spaces */
+ text-align: start;
+ font: var(--body);
+ }
+
+ .review-header {
+ text-align: end;
+ }
+
+ .developer-response-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 5px;
+ margin-top: 20px;
+ }
+
+ .developer-response-heading {
+ font: var(--body-emphasized);
+
+ .is-detail-view & {
+ display: block;
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ .developer-response-container {
+ margin-top: 10px;
+ }
+
+ article :global(.more) {
+ --moreTextColorOverride: var(--keyColor);
+ --moreFontOverride: var(--body);
+ text-transform: lowercase;
+ }
+</style>
diff --git a/src/components/jet/item/SearchLinkItem.svelte b/src/components/jet/item/SearchLinkItem.svelte
new file mode 100644
index 0000000..cd60512
--- /dev/null
+++ b/src/components/jet/item/SearchLinkItem.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import {
+ isFlowAction,
+ type SearchLink,
+ } from '@jet-app/app-store/api/models';
+
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import MagnifyingGlass from '~/sf-symbols/magnifyingglass.svg';
+
+ export let item: SearchLink;
+</script>
+
+{#if isFlowAction(item.clickAction)}
+ <div class="link-container">
+ <FlowAction destination={item.clickAction}>
+ <MagnifyingGlass class="icon" />
+ {item.title}
+ </FlowAction>
+ </div>
+{/if}
+
+<style>
+ .link-container {
+ display: contents;
+ }
+
+ .link-container :global(a) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 12px;
+ font: var(--title-2);
+ border-radius: var(--global-border-radius-large);
+ background: var(--systemQuinary);
+ }
+
+ .link-container :global(a:hover) {
+ text-decoration: none;
+ }
+
+ .link-container :global(a) :global(.icon) {
+ overflow: visible;
+ width: 20px;
+ fill: currentColor;
+ }
+</style>
diff --git a/src/components/jet/item/SearchResult/AppSearchResultItem.svelte b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte
new file mode 100644
index 0000000..c36e5fc
--- /dev/null
+++ b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte
@@ -0,0 +1,392 @@
+<script lang="ts" context="module">
+ import type {
+ AppSearchResult,
+ AppEventSearchResult,
+ SearchResult,
+ Trailers,
+ Screenshots,
+ FlowAction,
+ Artwork as ArtworkType,
+ Video as VideoType,
+ } from '@jet-app/app-store/api/models';
+
+ export function isAppSearchResult(
+ result: SearchResult,
+ ): result is AppSearchResult {
+ return result.resultType === 'content';
+ }
+
+ export function isAppEventSearchResult(
+ result: SearchResult,
+ ): result is AppEventSearchResult {
+ return result.resultType === 'appEvent';
+ }
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import type {
+ ImageSizes,
+ Profile,
+ } from '@amp/web-app-components/src/components/Artwork/types';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
+
+ import type { NamedProfile } from '~/config/components/artwork';
+ import { getI18n } from '~/stores/i18n';
+ import AppIcon, {
+ doesAppIconNeedBorder,
+ } from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import StarRating from '~/components/StarRating.svelte';
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { isNamedColor } from '~/utils/color';
+ import mediaQueries from '~/utils/media-queries';
+ import VideoPlayer from '~/components/VideoPlayer.svelte';
+
+ const i18n = getI18n();
+
+ export let item: AppSearchResult;
+
+ $: ({
+ clickAction,
+ heading,
+ isEditorsChoice,
+ rating,
+ ratingCount,
+ screenshots,
+ subtitle,
+ title,
+ trailers,
+ } = item.lockup);
+ let video: VideoType | undefined;
+ let media: (ArtworkType | VideoType)[];
+ let mediaAspectRatio: number;
+ let numberOfMediaToShow: number;
+ let profile: NamedProfile | Profile;
+ let mediaSizes: ImageSizes;
+ let videoPlayerInstance: InstanceType<typeof VideoPlayer> | null = null;
+ let shouldAutoplayVideo: boolean = false;
+
+ const currentPlatform =
+ (item.lockup.clickAction as FlowAction).destination?.platform ?? '';
+
+ function isForCurrentPlatform(media: Trailers | Screenshots) {
+ return media.mediaPlatform.appPlatform === currentPlatform;
+ }
+
+ $: {
+ const selectedTrailer =
+ trailers?.find(isForCurrentPlatform) ?? trailers?.[0];
+ video = selectedTrailer?.videos?.[0];
+
+ const selectedScreenshot =
+ screenshots.find(isForCurrentPlatform) ?? screenshots[0];
+
+ const firstMedia = video
+ ? video.preview
+ : selectedScreenshot.artwork[0];
+ const hasPortraitMedia = firstMedia.width < firstMedia.height;
+ const isMobile = $mediaQueries === 'xsmall' && $sidebarIsHidden;
+
+ mediaAspectRatio = firstMedia.width / firstMedia.height;
+
+ if (!hasPortraitMedia) {
+ numberOfMediaToShow = 1;
+ mediaSizes = isMobile ? [308] : [648, 417, 417, 656];
+ } else if (currentPlatform !== 'iphone') {
+ numberOfMediaToShow = 2;
+ mediaSizes = isMobile ? [150] : [238, 203, 203, 320];
+ } else {
+ numberOfMediaToShow = 3;
+ mediaSizes = isMobile ? [98] : [156, 133, 133, 210];
+ }
+
+ profile = getNaturalProfile(firstMedia, mediaSizes);
+ media = [video, ...selectedScreenshot.artwork]
+ .filter(Boolean)
+ .slice(0, numberOfMediaToShow) as (ArtworkType | VideoType)[];
+ }
+
+ function handleMouseEnter() {
+ videoPlayerInstance?.play();
+ }
+
+ function handleMouseLeave() {
+ videoPlayerInstance?.pause();
+ }
+
+ onMount(() => {
+ shouldAutoplayVideo = navigator.maxTouchPoints > 0;
+ });
+</script>
+
+<LinkWrapper
+ action={clickAction}
+ label={`${$i18n.t('ASE.Web.AppStore.View')} ${clickAction.title}`}
+>
+ <article on:mouseenter={handleMouseEnter} on:mouseleave={handleMouseLeave}>
+ <div class="top-container">
+ {#if item.lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon
+ icon={item.lockup.icon}
+ profile="app-icon"
+ withBorder={doesAppIconNeedBorder(item.lockup.icon)}
+ />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if heading}
+ <LineClamp clamp={1}>
+ <h4>{heading}</h4>
+ </LineClamp>
+ {/if}
+
+ <LineClamp clamp={1}>
+ <h3>{title}</h3>
+ </LineClamp>
+
+ <LineClamp clamp={1}>
+ <p>{subtitle}</p>
+ </LineClamp>
+
+ {#if isEditorsChoice}
+ <div class="editors-choice-badge-container">
+ <SFSymbol name="laurel.leading" ariaHidden={true} />
+
+ {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
+
+ <SFSymbol name="laurel.trailing" ariaHidden={true} />
+ </div>
+ {:else if ratingCount}
+ <span class="rating-container">
+ <StarRating
+ {rating}
+ --fill-color="var(--systemGray2-onDark_IC)"
+ />
+ {ratingCount}
+ </span>
+ {/if}
+ </div>
+
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ </div>
+
+ <div
+ class="artwork-container {currentPlatform}"
+ style:--media-aspect-ratio={mediaAspectRatio}
+ >
+ {#each media as mediaItem}
+ {#if 'videoUrl' in mediaItem}
+ <div class="video-wrapper">
+ <Video
+ {profile}
+ loop
+ video={mediaItem}
+ autoplay={shouldAutoplayVideo}
+ useControls={false}
+ autoplayVisibilityThreshold={0.75}
+ bind:videoPlayerRef={videoPlayerInstance}
+ />
+ </div>
+ {:else}
+ <Artwork
+ {profile}
+ artwork={mediaItem}
+ disableAutoCenter={true}
+ useCropCodeFromArtwork={false}
+ />
+ {/if}
+ {/each}
+ </div>
+ </article>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+ padding: 16px;
+ border-radius: 28px;
+ box-shadow: var(--shadow-medium);
+ background: #fff;
+ transition: box-shadow 210ms ease-out;
+ width: 100%;
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemQuaternary);
+ }
+ }
+
+ article:hover {
+ box-shadow: 0 5px 28px rgba(0, 0, 0, 0.12);
+ }
+
+ .top-container {
+ align-items: center;
+ width: 100%;
+ padding-bottom: 16px;
+ gap: 8px;
+ }
+
+ .top-container,
+ .metadata-container {
+ display: flex;
+ }
+
+ .metadata-container {
+ flex-direction: column;
+ flex-grow: 1;
+ }
+
+ .rating-container {
+ display: flex;
+ align-items: center;
+ font: var(--subhead-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ .rating-container :global(svg) {
+ @media (prefers-contrast: more) and (prefers-color-scheme: dark) {
+ --fill-color: #fff;
+ }
+ }
+
+ .editors-choice-badge-container {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font: var(--caption-1-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ .editors-choice-badge-container :global(svg) {
+ height: 14px;
+ overflow: visible;
+
+ @include rtl {
+ transform: rotateY(180deg);
+ }
+ }
+
+ .editors-choice-badge-container :global(svg path) {
+ fill: var(--systemSecondary);
+ }
+
+ h3 {
+ font: var(--headline);
+ }
+
+ h4 {
+ color: var(--systemSecondary);
+ font: var(--footnote-emphasized);
+ text-transform: uppercase;
+ }
+
+ p {
+ font: var(--callout);
+ color: var(--systemSecondary);
+ }
+
+ .artwork-container {
+ --container-aspect-ratio: 1.333;
+ --artwork-override-object-fit: contain;
+ --artwork-override-height: auto;
+ --artwork-override-width: 100%;
+ --artwork-override-max-height: 100%;
+ --artwork-override-max-width: 100%;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ height: calc(100% * var(--container-aspect-ratio));
+ aspect-ratio: var(--container-aspect-ratio);
+ border-radius: var(--global-border-radius-medium);
+
+ &.iphone {
+ --container-aspect-ratio: 1.444;
+ }
+
+ &.ipad {
+ --container-aspect-ratio: 1.54;
+ }
+
+ &.mac {
+ --container-aspect-ratio: 1.6;
+ }
+
+ &.watch {
+ --container-aspect-ratio: 1.636;
+ }
+
+ &.tv,
+ &.vision {
+ --container-aspect-ratio: 1.77;
+ }
+ }
+
+ // Centers a single item in the grid
+ .artwork-container :global(> :only-child) {
+ justify-self: center;
+ }
+
+ // Aligns the first of two items to the center edge
+ .artwork-container :global(> :nth-child(1):nth-last-child(2)) {
+ justify-self: flex-end;
+ }
+
+ // Aligns the second of two items to the center edge
+ .artwork-container :global(> :nth-child(2):nth-last-child(1)) {
+ justify-self: flex-start;
+ }
+
+ .video-wrapper {
+ display: flex;
+ overflow: hidden;
+ max-height: 100%;
+ width: auto;
+ aspect-ratio: var(--media-aspect-ratio, 16/9);
+ border: 1px solid var(--systemQuaternary);
+ border-radius: 16px;
+ }
+
+ .artwork-container :global(.artwork-component) {
+ display: flex;
+ aspect-ratio: var(--media-aspect-ratio);
+ border-radius: 16px;
+ justify-content: center;
+ align-items: center;
+ width: auto;
+ height: auto;
+ max-width: 100%;
+ max-height: 100%;
+ }
+
+ .artwork-container :global(.artwork-component img) {
+ height: 100%;
+ }
+
+ .artwork-container :global(.video-container) {
+ container-type: normal;
+ }
+
+ .artwork-container :global(video) {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/item/SmallBreakoutItem.svelte b/src/components/jet/item/SmallBreakoutItem.svelte
new file mode 100644
index 0000000..311fbef
--- /dev/null
+++ b/src/components/jet/item/SmallBreakoutItem.svelte
@@ -0,0 +1,187 @@
+<script lang="ts">
+ import {
+ type Artwork as JetArtworkType,
+ type SmallBreakout,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import { isSome } from '@jet/environment/types/optional';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: SmallBreakout;
+
+ $: ({ backgroundColor, iconArtwork, clickAction: action = null } = item);
+
+ $: backgroundColorForCss = backgroundColor
+ ? colorAsString(backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper {action}>
+ <HoverWrapper>
+ <div class="container" style:--background-color={backgroundColorForCss}>
+ {#if iconArtwork}
+ <div class="artwork-container">
+ <AppIcon
+ icon={iconArtwork}
+ profile="app-icon-xlarge"
+ fixedWidth={false}
+ />
+ </div>
+ {/if}
+
+ <div
+ class="text-container"
+ class:with-dark-background={item.details.backgroundStyle ===
+ 'dark'}
+ >
+ {#if item.details?.badge}
+ <LineClamp clamp={1}>
+ <h4>{item.details.badge}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.title}
+ <LineClamp clamp={2}>
+ <h3>{item.details.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.description}
+ <LineClamp clamp={3}>
+ <p>{item.details.description}</p>
+ </LineClamp>
+ {/if}
+
+ {#if isSome(action) && isFlowAction(action)}
+ <span class="link-container">
+ {action.title}
+ <span aria-hidden="true">
+ <SFSymbol name="chevron.forward" />
+ </span>
+ </span>
+ {/if}
+ </div>
+ </div>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .container {
+ width: 100%;
+ max-height: 460px;
+ aspect-ratio: 16/9;
+ background-color: var(--background-color);
+ container-type: inline-size;
+ container-name: container;
+
+ @media (--range-small-up) {
+ aspect-ratio: 13/5;
+ }
+ }
+
+ .artwork-container {
+ --rotation: -30deg;
+ position: absolute;
+ width: 33%;
+ max-width: 430px;
+ inset-inline-end: -10%;
+ transform: translateY(-8%) rotate(var(--rotation));
+
+ @include rtl {
+ --rotation: 30deg;
+ }
+ }
+
+ @container container (min-width: 1150px) {
+ .artwork-container {
+ transform: translateY(-11%) rotate(var(--rotation));
+ }
+ }
+
+ .artwork-container :global(.artwork-component) {
+ --angle: -7px;
+ box-shadow: var(--angle) 5px 12px 0 rgba(0, 0, 0, 0.15);
+
+ @include rtl {
+ --angle: 7px;
+ }
+ }
+
+ .text-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 66%;
+ height: 100%;
+ padding: 0 20px;
+ text-wrap: pretty;
+
+ @media (--range-small-up) {
+ width: 33%;
+ }
+
+ @media (--range-large-up) {
+ width: 33%;
+ }
+ }
+
+ .text-container.with-dark-background {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .link-container {
+ display: flex;
+ gap: 4px;
+ margin-top: 16px;
+ font: var(--title-3-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .link-container :global(svg) {
+ width: 10px;
+ height: 10px;
+ fill: currentColor;
+
+ @include rtl {
+ transform: rotate(180deg);
+ }
+ }
+
+ h3 {
+ text-wrap: balance;
+ font: var(--title-1-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--large-title-emphasized);
+ }
+ }
+
+ h4 {
+ font: var(--subhead-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--headline);
+ }
+ }
+
+ p {
+ margin-top: 8px;
+
+ @media (--range-small-up) {
+ font: var(--title-3);
+ }
+ }
+</style>
diff --git a/src/components/jet/item/SmallLockupItem.svelte b/src/components/jet/item/SmallLockupItem.svelte
new file mode 100644
index 0000000..b235652
--- /dev/null
+++ b/src/components/jet/item/SmallLockupItem.svelte
@@ -0,0 +1,110 @@
+<script lang="ts">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Lockup;
+
+ /**
+ * Controls the `get-button` variant class that is applied to the "View" button
+ *
+ * @default "gray"
+ */
+ export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'gray';
+ export let shouldShowLaunchNativeButton: boolean = false;
+ export let titleLineCount: number = 2;
+ export let appIconProfile: AppIconProfile = 'app-icon-small';
+
+ const i18n = getI18n();
+</script>
+
+<div class="small-lockup-item">
+ <LinkWrapper
+ action={item.clickAction}
+ label={`${$i18n.t('ASE.Web.AppStore.View')} ${
+ item.title ? item.title : null
+ }`}
+ >
+ {#if item.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={item.icon} profile={appIconProfile} />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if item.heading}
+ <LineClamp clamp={1}>
+ <h4 dir="auto">{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={titleLineCount}>
+ <h3 dir="auto">{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p dir="auto">{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <div class="button-container" aria-hidden="true">
+ {#if shouldShowLaunchNativeButton && $$slots['launch-native-button']}
+ <slot name="launch-native-button" />
+ {:else}
+ <span class="get-button {buttonVariant}">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ {/if}
+ </div>
+ </LinkWrapper>
+</div>
+
+<style>
+ .small-lockup-item,
+ .small-lockup-item :global(a) {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ margin-inline-end: 16px;
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ color: var(--title-color);
+ font: var(--title-3-emphasized);
+ }
+
+ h4 {
+ color: var(--eyebrow-color, var(--systemSecondary));
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: var(--eyebrow-blend-mode);
+ }
+
+ p {
+ font: var(--callout);
+ color: var(--subtitle-color, var(--systemSecondary));
+ mix-blend-mode: var(--subtitle-blend-mode);
+ }
+
+ .button-container {
+ margin-inline-start: auto;
+ margin-inline-end: var(--margin-inline-end, 0);
+ mix-blend-mode: var(--button-blend-mode);
+ flex-shrink: 0;
+ }
+</style>
diff --git a/src/components/jet/item/SmallLockupWithOrdinalItem.svelte b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte
new file mode 100644
index 0000000..9fb796c
--- /dev/null
+++ b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte
@@ -0,0 +1,176 @@
+<script lang="ts" context="module">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ interface SmallLockupWithOrdinalItem extends Lockup {
+ ordinal: string;
+ }
+
+ export function isSmallLockupWithOrdinalItem(
+ item: Lockup,
+ ): item is SmallLockupWithOrdinalItem {
+ return !!item?.ordinal;
+ }
+</script>
+
+<script lang="ts">
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import mediaQueries from '~/utils/media-queries';
+
+ export let item: Lockup;
+
+ $: titleLineCount = item.heading || $mediaQueries === 'xsmall' ? 1 : 2;
+
+ const i18n = getI18n();
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <article>
+ {#if item.ordinal}
+ <div class="ordinal">
+ {item.ordinal}
+ </div>
+ {/if}
+
+ {#if item.icon}
+ <div
+ class="app-icon-container"
+ style:--icon-aspect-ratio={item.icon.width / item.icon.height}
+ >
+ <AppIcon
+ icon={item.icon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+ {/if}
+ <div class="metadata-container">
+ {#if item.heading}
+ <LineClamp clamp={1}>
+ <h4>{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={titleLineCount}>
+ <h3 title={item.title}>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ position: relative;
+ aspect-ratio: 0.9;
+ height: 100%;
+ padding: 16px;
+ gap: 10px;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+ border-radius: var(--global-border-radius-xlarge);
+ background: var(--systemPrimary-onDark);
+ box-shadow: var(--shadow-small);
+ container-type: inline-size;
+ container-name: container;
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemQuaternary);
+ }
+
+ @media (--sidebar-visible) and (--range-xsmall-only) {
+ aspect-ratio: 1;
+ }
+
+ @media (--range-medium-up) {
+ aspect-ratio: 1;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ margin-top: 4px;
+ aspect-ratio: var(--icon-aspect-ratio);
+ height: clamp(40px, 40cqi, 100px);
+ width: auto;
+ }
+
+ .metadata-container {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ h3 {
+ text-wrap: balance;
+ font: var(--body-emphasized);
+ line-height: 1.1;
+ color: var(--title-color);
+ }
+
+ h4 {
+ text-transform: uppercase;
+ font: var(--subhead-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ p {
+ font: var(--subhead);
+ color: var(--systemSecondary);
+ }
+
+ .button-container {
+ --get-button-font: var(--subhead-bold);
+ align-content: end;
+ flex-grow: 1;
+ }
+
+ .ordinal {
+ position: absolute;
+ top: 12px;
+ inset-inline-start: 12px;
+ font: var(--title-1-semibold);
+ color: var(--systemTertiary);
+ }
+
+ @container container (width >= 180px) {
+ h3 {
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ @container container (width >= 250px) {
+ h3 {
+ font: var(--title-2-emphasized);
+ margin-bottom: 4px;
+ }
+
+ p {
+ font: var(--body);
+ }
+ }
+
+ @container container (width >= 200px) {
+ .button-container {
+ --get-button-font: unset;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte
new file mode 100644
index 0000000..ce7784b
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte
@@ -0,0 +1,69 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCard,
+ TodayCardMediaBrandedSingleApp,
+ } from '@jet-app/app-store/api/models';
+
+ export interface SmallStoryCardMediaBrandedSingleApp extends TodayCard {
+ media: TodayCardMediaBrandedSingleApp;
+ }
+
+ export function isSmallStoryCardMediaBrandedSingleApp(
+ item: TodayCard,
+ ): item is SmallStoryCardMediaBrandedSingleApp {
+ return !!item.media && item.media.kind === 'brandedSingleApp';
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: SmallStoryCardMediaBrandedSingleApp;
+
+ $: artwork = item.media.artworks?.[0] || item.media.icon;
+</script>
+
+<article>
+ <LinkWrapper action={item.clickAction}>
+ <HoverWrapper element="div">
+ <Artwork {artwork} profile="brick" useCropCodeFromArtwork={false} />
+ </HoverWrapper>
+
+ <div class="text-container">
+ <h4>{item.heading}</h4>
+ <h3>{item.title}</h3>
+ <p>{item.inlineDescription}</p>
+ </div>
+ </LinkWrapper>
+</article>
+
+<style>
+ article {
+ aspect-ratio: 16/9;
+ }
+
+ .text-container {
+ gap: 4px;
+ display: flex;
+ flex-direction: column;
+ margin-top: 8px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ margin-bottom: 2px;
+ font: var(--callout-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ p {
+ font: var(--body-tall);
+ color: var(--systemSecondary);
+ text-wrap: pretty;
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte
new file mode 100644
index 0000000..bcd7333
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte
@@ -0,0 +1,87 @@
+<script lang="ts" context="module">
+ import type {
+ Artwork as ArtworkModel,
+ TodayCard,
+ } from '@jet-app/app-store/api/models';
+
+ export interface SmallStoryCardWithArtwork extends TodayCard {
+ artwork: ArtworkModel;
+ badge: any;
+ }
+
+ export function isSmallStoryCardWithArtworkItem(
+ item: TodayCard,
+ ): item is SmallStoryCardWithArtwork {
+ return !('media' in item) && 'artwork' in item;
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import { colorAsString } from '~/utils/color';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ export let item: SmallStoryCardWithArtwork;
+
+ $: artwork = item.heroMedia?.artworks?.[0] || item.artwork;
+
+ $: gradientColor = artwork.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : 'rgb(0 0 0 / 62%)';
+</script>
+
+<article>
+ <LinkWrapper action={item.clickAction}>
+ <HoverWrapper element="div">
+ <Artwork {artwork} profile="small-story-card-portrait" />
+
+ <GradientOverlay --color={gradientColor} />
+
+ <div class="text-container">
+ {#if item.badge?.title}
+ <h4>{item.badge.title}</h4>
+ {/if}
+
+ {#if item.title}
+ <h3>{@html sanitizeHtml(item.title)}</h3>
+ {/if}
+ </div>
+ </HoverWrapper>
+ </LinkWrapper>
+</article>
+
+<style>
+ article {
+ aspect-ratio: 3/4;
+ }
+
+ .text-container {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ height: 100%;
+ margin-top: 8px;
+ padding: 16px;
+ color: var(--systemPrimary);
+ }
+
+ h3 {
+ z-index: 1;
+ text-wrap: pretty;
+ font: var(--body-bold);
+ color: var(--systemPrimary-onDark);
+ }
+
+ h4 {
+ position: relative;
+ z-index: 1;
+ margin-bottom: 2px;
+ font: var(--caption-2-emphasized);
+ color: var(--systemSecondary-onDark);
+ mix-blend-mode: plus-lighter;
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte
new file mode 100644
index 0000000..5b20e1c
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte
@@ -0,0 +1,156 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCard,
+ TodayCardMediaAppIcon,
+ } from '@jet-app/app-store/api/models';
+
+ export interface TodayCardWithMediAppIcon extends TodayCard {
+ media: TodayCardMediaAppIcon;
+ }
+
+ export function isSmallStoryCardWithMediaAppIcon(
+ item: TodayCard,
+ ): item is TodayCardWithMediAppIcon {
+ return !!item.media && item.media.kind === 'appIcon';
+ }
+</script>
+
+<script lang="ts">
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: TodayCardWithMediAppIcon;
+
+ $: artwork = item.heroMedia?.artworks[0];
+ $: appIcon = item.media.icon;
+ $: backgroundImage = appIcon
+ ? buildSrc(
+ appIcon.template,
+ {
+ crop: 'bb',
+ width: 160,
+ height: 160,
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+ $: backgroundColor = appIcon.backgroundColor
+ ? colorAsString(appIcon.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ <div
+ class="container"
+ style:--background-color={backgroundColor}
+ style:--background-image={`url(${backgroundImage})`}
+ >
+ <div class="protection" />
+
+ {#if artwork}
+ <Artwork {artwork} profile="brick" />
+ {:else}
+ <div class="app-icon-container">
+ <div class="app-icon-normal">
+ <AppIcon
+ icon={appIcon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+
+ <div class="app-icon-glow">
+ <AppIcon
+ icon={appIcon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+ </div>
+ {/if}
+ </div>
+ </HoverWrapper>
+
+ <div class="text-container">
+ <h4>{item.heading}</h4>
+ <h3>{item.title}</h3>
+ </div>
+</LinkWrapper>
+
+<style lang="scss">
+ @use 'amp/stylekit/core/mixins/browser-targets' as *;
+
+ .container {
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: linear-gradient(
+ to bottom,
+ transparent 20%,
+ rgba(0, 0, 0, 0.33) 100%
+ ),
+ var(--background-image), var(--background-color, #000);
+ background-size: cover;
+ background-position: center;
+
+ // Safari has issues rendering the overlaid `backdrop-filter` from `.proection` atop the
+ // background image of `.container`, so in Safari only we are forgoing the use of
+ // `var(--background-image)` and just using colors.
+ @include target-safari {
+ background: linear-gradient(
+ to bottom,
+ transparent 20%,
+ rgba(0, 0, 0, 0.33) 100%
+ ),
+ var(--background-color, #000);
+ }
+ }
+
+ .protection {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ backdrop-filter: blur(80px) saturate(1.5);
+ }
+
+ .app-icon-container {
+ position: relative;
+ width: 80px;
+ }
+
+ .app-icon-normal {
+ position: relative;
+ z-index: 1;
+ filter: drop-shadow(0 0 13px rgba(0, 0, 0, 0.15));
+ }
+
+ .app-icon-glow {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ transform: scale(1.4);
+ filter: blur(25px);
+ }
+
+ .text-container {
+ margin-top: 8px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ margin-bottom: 2px;
+ font: var(--callout-emphasized);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithMediaItem.svelte b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte
new file mode 100644
index 0000000..4901744
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte
@@ -0,0 +1,104 @@
+<script lang="ts" context="module">
+ import { isSome } from '@jet/environment/types/optional';
+ import type {
+ TodayCard,
+ TodayCardMediaWithArtwork,
+ } from '@jet-app/app-store/api/models';
+
+ import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+
+ export interface SmallStoryCardWithMedia extends TodayCard {
+ media: TodayCardMediaWithArtwork;
+ heroMedia: TodayCardMediaWithArtwork;
+ }
+
+ export function isSmallStoryCardWithMediaItem(
+ item: TodayCard,
+ ): item is SmallStoryCardWithMedia {
+ return isSome(item.media);
+ }
+</script>
+
+<script lang="ts">
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: SmallStoryCardWithMedia;
+
+ $: artwork = (() => {
+ if (item.heroMedia) {
+ return item.heroMedia?.artworks?.[0];
+ }
+
+ if (isTodayCardMediaWithArtwork(item.media)) {
+ return item.media.artworks?.[0];
+ }
+
+ return null;
+ })();
+</script>
+
+<article>
+ <LinkWrapper action={item.clickAction}>
+ <HoverWrapper element="div">
+ {#if artwork}
+ <div class="artwork-container">
+ <Artwork
+ {artwork}
+ profile={item.heroMedia
+ ? 'small-story-card'
+ : 'small-story-card-legacy'}
+ useCropCodeFromArtwork={!item.heroMedia}
+ />
+ </div>
+ {/if}
+ </HoverWrapper>
+
+ <div class="text-container">
+ <h4>{item.heading}</h4>
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+
+ {#if item.inlineDescription}
+ <LineClamp clamp={1}>
+ <p>{item.inlineDescription}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </LinkWrapper>
+</article>
+
+<style>
+ .artwork-container {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background-color: var(--color);
+ border-radius: 8px;
+ }
+
+ .text-container {
+ display: flex;
+ margin-top: 8px;
+ gap: 4px;
+ color: var(--systemPrimary);
+ flex-direction: column;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ font: var(--callout-emphasized);
+ color: var(--systemTertiary);
+ }
+
+ p {
+ font: var(--body-tall);
+ color: var(--systemSecondary);
+ text-wrap: pretty;
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte
new file mode 100644
index 0000000..038f504
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte
@@ -0,0 +1,118 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCard,
+ TodayCardMediaRiver,
+ } from '@jet-app/app-store/api/models';
+
+ export interface TodayCardWithMediaRiver extends TodayCard {
+ media: TodayCardMediaRiver;
+ }
+
+ export function isSmallStoryCardWithMediaRiver(
+ item: TodayCard,
+ ): item is TodayCardWithMediaRiver {
+ return !!item.media && item.media.kind === 'river';
+ }
+</script>
+
+<script lang="ts">
+ import type { Opt } from '@jet/environment/types/optional';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import AppIconRiver from '~/components/AppIconRiver.svelte';
+ import {
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+
+ export let item: TodayCardWithMediaRiver;
+
+ $: icons = item.media.lockups.map((lockup) => lockup.icon);
+ $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
+ icons,
+ {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ },
+ );
+
+ let title: Opt<string>;
+ let eyebrow: Opt<string>;
+ $: {
+ eyebrow = item.heading;
+ title = item.title;
+
+ if (item.inlineDescription) {
+ eyebrow = item.title;
+ title = item.inlineDescription;
+ }
+ }
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ <div class="river-container" style={backgroundGradientCssVars}>
+ <AppIconRiver {icons} profile="app-icon" />
+ </div>
+ </HoverWrapper>
+
+ <div class="text-container">
+ {#if eyebrow}
+ <h4>{eyebrow}</h4>
+ {/if}
+
+ {#if title}
+ <h3>{title}</h3>
+ {/if}
+ </div>
+</LinkWrapper>
+
+<style>
+ .river-container {
+ --app-icon-river-icon-width: 48px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ border-radius: 8px;
+ 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%
+ );
+ }
+
+ .river-container :global(.app-icons:last-of-type) {
+ margin-bottom: 0;
+ }
+
+ .text-container {
+ margin-top: 8px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ margin-bottom: 2px;
+ font: var(--callout-emphasized);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/TitledParagraphItem.svelte b/src/components/jet/item/TitledParagraphItem.svelte
new file mode 100644
index 0000000..ad8e4bc
--- /dev/null
+++ b/src/components/jet/item/TitledParagraphItem.svelte
@@ -0,0 +1,175 @@
+<script lang="ts" context="module">
+ import type {
+ ShelfModel,
+ TitledParagraph,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTitledParagraphItem(
+ item: ShelfModel | string,
+ ): item is TitledParagraph {
+ return typeof item !== 'string' && 'text' in item;
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { getNumericDateFromDateString } from '@amp/web-app-components/src/utils/date';
+ import { getJet } from '~/jet/svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: TitledParagraph;
+
+ const i18n = getI18n();
+ const jet = getJet();
+ const isDetailView = item.style === 'detail';
+ const dateForDisplay = jet.localization.timeAgo(
+ new Date(item.secondarySubtitle),
+ );
+ const dateForAttribute = getNumericDateFromDateString(
+ item.secondarySubtitle,
+ );
+
+ let isTruncated = true;
+</script>
+
+<article class:detail={isDetailView} class:overview={!isDetailView}>
+ <div class="container">
+ <p>
+ {#if item.text}
+ {#if !isTruncated || isDetailView}
+ {item.text}
+ {:else}
+ <LineClamp
+ clamp={5}
+ observe
+ on:resize={({ detail }) =>
+ (isTruncated = detail.truncated)}
+ >
+ {@html sanitizeHtml(item.text)}
+ </LineClamp>
+
+ {#if isTruncated}
+ <button on:click={() => (isTruncated = false)}>
+ {$i18n.t('ASE.Web.AppStore.More')}
+ </button>
+ {/if}
+ {/if}
+ {/if}
+ </p>
+
+ <div class="metadata">
+ <h4>{item.primarySubtitle}</h4>
+ <time datetime={dateForAttribute}>{dateForDisplay}</time>
+ </div>
+ </div>
+</article>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ display: flex;
+ flex-direction: column-reverse;
+ font: var(--body-tall);
+ color: var(--systemPrimary);
+ margin: 0 var(--bodyGutter);
+
+ @media (--range-small-up) {
+ flex-direction: row;
+ }
+ }
+
+ .container {
+ display: flex;
+ width: 100%;
+ }
+
+ p {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ white-space: break-spaces;
+ font: var(--body-tall);
+ }
+
+ .metadata {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ margin: 0 0 8px 8px;
+ text-align: end;
+ color: var(--systemSecondary);
+ }
+
+ h4 {
+ font: var(--body-tall);
+ }
+
+ button {
+ --gradient-direction: 270deg;
+ position: absolute;
+ bottom: 0;
+ display: flex;
+ justify-content: end;
+ color: var(--keyColor);
+ inset-inline-end: 0;
+ padding-inline-start: 20px;
+ background: linear-gradient(
+ var(--gradient-direction),
+ var(--pageBg) 72%,
+ transparent 100%
+ );
+
+ @include rtl {
+ --gradient-direction: 90deg;
+ }
+ }
+
+ time {
+ color: var(--systemSecondary);
+ white-space: nowrap;
+ }
+
+ .detail {
+ flex-direction: column-reverse;
+ margin: 0;
+ padding: 16px 0 0;
+ border-top: 1px solid var(--systemGray4);
+ }
+
+ .detail .metadata {
+ gap: 2px;
+ }
+
+ .detail h4 {
+ font: var(--body-emphasized-tall);
+ color: var(--systemPrimary);
+ }
+
+ .overview .container {
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ .overview .metadata {
+ flex-grow: 1;
+ gap: 4px;
+ }
+
+ .overview p {
+ @media (--range-small-up) {
+ width: 66%;
+ }
+
+ @media (--range-large-up) {
+ width: 50%;
+ }
+ }
+
+ .detail .container {
+ justify-content: space-between;
+ }
+</style>
diff --git a/src/components/jet/item/TrailersLockupItem.svelte b/src/components/jet/item/TrailersLockupItem.svelte
new file mode 100644
index 0000000..6b2ee42
--- /dev/null
+++ b/src/components/jet/item/TrailersLockupItem.svelte
@@ -0,0 +1,51 @@
+<script lang="ts">
+ import type { TrailersLockup } from '@jet-app/app-store/api/models';
+ import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: TrailersLockup;
+
+ $: video = item.trailers.videos[0];
+</script>
+
+<article>
+ {#if video}
+ <div class="video-container">
+ <Video
+ {video}
+ shouldSuperimposePosterImage
+ loop={true}
+ useControls={true}
+ profile="app-trailer-lockup-video"
+ />
+ </div>
+ {/if}
+
+ <SmallLockup {item} />
+</article>
+
+<style>
+ /*
+ The video container is explicitly not 16/9 aspect ratio, because a lot trailers have
+ pillarboxing (black bars on the sides), so expand the height of their container which
+ causes those black bars to overflow outside the container, thus cropping them.
+ This follows the iOS pattern.
+ */
+ .video-container {
+ --app-trailer-lockup-video-aspect-ratio: 16/10;
+ aspect-ratio: var(--app-trailer-lockup--video-aspect-ratio);
+ margin-bottom: 16px;
+ overflow: hidden;
+ border-radius: var(--global-border-radius-large);
+ }
+
+ /*
+ Not all trailers are in a landscape aspect ratio (many iPhone trailers are portrait),
+ so for those cases we force them to fit inside a landscape container, centered vertically,
+ by using `object-fit: cover;`.
+ */
+ .video-container :global(video) {
+ aspect-ratio: var(--app-trailer-lockup-video-aspect-ratio);
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/marker-shelf/ProductTopLockup.svelte b/src/components/jet/marker-shelf/ProductTopLockup.svelte
new file mode 100644
index 0000000..e56e5b0
--- /dev/null
+++ b/src/components/jet/marker-shelf/ProductTopLockup.svelte
@@ -0,0 +1,463 @@
+<script lang="ts" context="module">
+ import type {
+ AppPlatform,
+ ShelfBasedProductPage,
+ } from '@jet-app/app-store/api/models';
+
+ /**
+ * The parts of {@linkcode ShelfBasedProductPage} that are required to render
+ * the `MarkerShelf` component
+ */
+ export type MarkerShelfPageRequirements = Pick<
+ ShelfBasedProductPage,
+ | 'badges'
+ | 'banner'
+ | 'developerAction'
+ | 'lockup'
+ | 'shelfMapping'
+ | 'titleOfferDisplayProperties'
+ | 'canonicalURL'
+ | 'appPlatforms'
+ >;
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import { platform } from '@amp/web-apps-utils';
+ import AppIcon, {
+ doesAppIconNeedBorder,
+ } from '~/components/AppIcon.svelte';
+ import Banner from '~/components/jet/item/BannerItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ProductPageArcadeBanner from '~/components/ProductPageArcadeBanner.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { colorAsString, isNamedColor, isRGBColor } from '~/utils/color';
+ import { concatWithMiddot, isString } from '~/utils/string-formatting';
+ import {
+ isPlatformExclusivelySupported,
+ isPlatformSupported,
+ PlatformToExclusivityText,
+ } from '~/utils/app-platforms';
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import ShareArrowButton, {
+ isShareSupported,
+ } from '~/components/ShareArrowButton.svelte';
+ import LaunchNativeButton from '~/components/LaunchNativeButton.svelte';
+
+ export let page: MarkerShelfPageRequirements;
+
+ $: banner = page.banner;
+ $: lockup = page.lockup;
+ $: appPlatforms = page.appPlatforms;
+ $: offerDisplayProperties = lockup.offerDisplayProperties || {};
+ $: ({ expectedReleaseDate } = offerDisplayProperties?.subtitles || {});
+
+ const i18n = getI18n();
+
+ // TODO: replace with `supportsArcade` from Jet
+ // rdar://143706610 (Support `supportsArcade` attribute)
+ $: supportsArcade = offerDisplayProperties.offerType === 'arcadeApp';
+
+ $: backgroundColor = isRGBColor(lockup.icon?.backgroundColor)
+ ? colorAsString(lockup.icon.backgroundColor)
+ : '#fff';
+
+ $: backgroundImage = lockup.icon
+ ? buildSrc(
+ lockup.icon.template,
+ {
+ crop: 'bb',
+ width: 400,
+ height: 400,
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+
+ $: attributes = concatWithMiddot(
+ [
+ expectedReleaseDate && $i18n.t('ASE.Web.AppStore.App.ComingSoon'),
+ expectedReleaseDate && expectedReleaseDate,
+ // Attributes that are not relevant for Arcade Apps:
+ ...(!supportsArcade
+ ? [
+ page.titleOfferDisplayProperties?.isFree &&
+ $i18n.t('ASE.Web.AppStore.Free'),
+ offerDisplayProperties.priceFormatted,
+ offerDisplayProperties.subtitles?.standard,
+ lockup.tertiaryTitle,
+ ]
+ : []),
+ ].filter(isString),
+ $i18n,
+ );
+
+ $: exclusivePlatform = (
+ Object.keys(PlatformToExclusivityText) as AppPlatform[]
+ ).find((platform: AppPlatform) =>
+ isPlatformExclusivelySupported(platform, appPlatforms),
+ );
+ $: exclusivityText = exclusivePlatform
+ ? PlatformToExclusivityText[exclusivePlatform]
+ : null;
+
+ $: shouldShowLaunchNativeButton =
+ platform.ismacOS() &&
+ (lockup.isIOSBinaryMacOSCompatible ||
+ isPlatformSupported('mac', appPlatforms));
+
+ let shouldShowShareButton: boolean = true;
+
+ onMount(() => {
+ shouldShowShareButton = isShareSupported();
+ });
+</script>
+
+<ShelfWrapper withBottomPadding={false} withPaddingTop={false}>
+ <div
+ class="container"
+ style:--background-color={backgroundColor}
+ style:--background-image={`url(${backgroundImage})`}
+ >
+ <div class="rotate" />
+ <div class="blur" />
+
+ <div class="content-container">
+ {#if lockup.icon}
+ <div
+ class="app-icon-contianer"
+ class:without-border={!doesAppIconNeedBorder(lockup.icon)}
+ aria-hidden="true"
+ >
+ <AppIcon
+ icon={lockup.icon}
+ profile="app-icon-large"
+ fixedWidth={false}
+ />
+
+ <div class="glow">
+ <AppIcon
+ icon={lockup.icon}
+ profile="app-icon-large"
+ fixedWidth={false}
+ />
+ </div>
+ </div>
+ {/if}
+
+ <section>
+ {#if supportsArcade}
+ <span
+ class="arcade-logo"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.ArcadeLogo.AccessibilityValue',
+ )}
+ >
+ <AppleArcadeLogo />
+ </span>
+ {:else if lockup.editorialTagline}
+ <h3>{lockup.editorialTagline}</h3>
+ {/if}
+
+ <h1>
+ {lockup.title}
+ </h1>
+
+ <h2 class="subtitle">
+ {lockup.subtitle}
+ </h2>
+
+ {#if exclusivityText}
+ <p class="attributes">
+ {$i18n.t(exclusivityText)}
+ </p>
+ {/if}
+
+ {#if attributes.length > 0}
+ <p class="attributes">
+ {attributes}
+ </p>
+ {/if}
+
+ {#if page.canonicalURL && (shouldShowLaunchNativeButton || shouldShowShareButton)}
+ <div class="buttons-container">
+ {#if shouldShowLaunchNativeButton}
+ <span class="launch-native-button-container">
+ <LaunchNativeButton url={page.canonicalURL} />
+ </span>
+ {/if}
+
+ {#if shouldShowShareButton}
+ <!--
+ If there is no launch native button, then we show a label for
+ the share button, which helps to visually fill out the space.
+ -->
+ <ShareArrowButton
+ url={page.canonicalURL}
+ withLabel={!shouldShowLaunchNativeButton}
+ />
+ {/if}
+ </div>
+ {/if}
+ </section>
+ </div>
+ </div>
+</ShelfWrapper>
+
+{#if banner}
+ <ShelfWrapper withBottomPadding={false} withTopMargin={false}>
+ <Banner item={banner} />
+ </ShelfWrapper>
+{/if}
+
+{#if supportsArcade}
+ <ShelfWrapper
+ withBottomPadding={false}
+ withTopMargin={true}
+ centered={false}
+ >
+ <ProductPageArcadeBanner />
+ </ShelfWrapper>
+{/if}
+
+<style>
+ .container {
+ --blend-mode: plus-lighter;
+ position: relative;
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ height: 200px;
+ color: var(--systemPrimary-onDark);
+ border-bottom: 1px solid var(--systemQuaternary-vibrant);
+ border-bottom-right-radius: 2px;
+ border-bottom-left-radius: 2px;
+ background: linear-gradient(
+ to bottom,
+ transparent 20%,
+ rgba(0, 0, 0, 0.8) 100%
+ ),
+ var(--background-image), var(--background-color, #000);
+ background-size: cover;
+ background-position: center;
+ transition: border-bottom-left-radius 210ms ease-out,
+ border-bottom-right-radius 210ms ease-out;
+ transform: translate(0);
+
+ @media (--range-small-up) {
+ height: 286px;
+ }
+
+ @media (--range-xlarge-up) {
+ border: 1px solid var(--systemQuaternary-vibrant);
+ border-top: none;
+ border-bottom-right-radius: 30px;
+ border-bottom-left-radius: 30px;
+ }
+ }
+
+ .glow {
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ width: 100%;
+ transform: scale(1.5);
+ filter: blur(60px);
+ }
+
+ .blur {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ backdrop-filter: blur(100px) saturate(1.5);
+ }
+
+ .rotate {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ left: 0;
+ width: 100%;
+ filter: brightness(1.3) saturate(0) blur(50px);
+ mix-blend-mode: overlay;
+ height: 500%;
+ background-image: var(--background-image);
+ background-repeat: repeat;
+ opacity: 0;
+ transform-origin: top center;
+ animation: shift-background 60s infinite linear 10s;
+ }
+
+ .content-container {
+ display: flex;
+ flex-direction: row;
+ max-width: 840px;
+ gap: 1em;
+ margin: 0 var(--bodyGutter);
+
+ @media (--range-small-up) {
+ gap: 1.5em;
+ }
+
+ @media (--range-medium-up) {
+ gap: 2em;
+ }
+ }
+
+ .app-icon-contianer {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ width: 128px;
+ flex-shrink: 0;
+
+ @media (--range-small-up) {
+ width: 194px;
+ }
+ }
+
+ .app-icon-contianer:not(.without-border) :global(> .app-icon) {
+ box-shadow: 0 0 30px rgba(0, 0, 0, 0.33);
+ border: 2px solid var(--systemQuaternary-onDark);
+ }
+
+ section {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ .subtitle,
+ .attributes {
+ position: relative;
+ z-index: 2;
+ margin-bottom: 4px;
+ font: var(--body);
+ color: rgba(245.973, 245.973, 245.973, 0.6);
+ text-wrap: pretty;
+ mix-blend-mode: var(--blend-mode);
+
+ @media (--range-small-up) {
+ margin-bottom: 8px;
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .attributes {
+ margin-bottom: 0;
+ font: var(--body);
+ }
+
+ .buttons-container {
+ --share-arrow-size: 27px;
+ --launch-native-button-arrow-size: 7px;
+ --get-button-font: var(--footnote-bold);
+ margin-top: 10px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ @media (--range-small-up) {
+ --share-arrow-size: unset;
+ --launch-native-button-arrow-size: unset;
+ --get-button-font: unset;
+ }
+ }
+
+ h1 {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ margin-bottom: 2px;
+ font: var(--title-2-emphasized);
+ color: white;
+ text-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
+ text-wrap: pretty;
+
+ @media (--sidebar-visible) {
+ font: var(--title-1-emphasized);
+ }
+
+ @media (--range-small-up) {
+ font: var(--header-emphasized);
+ letter-spacing: -0.02em;
+ }
+ }
+
+ h3 {
+ margin-bottom: 0;
+ position: relative;
+ z-index: 2;
+ mix-blend-mode: plus-lighter;
+ font: var(--body-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ .arcade-logo {
+ display: flex;
+ height: 10px;
+ margin-bottom: 4px;
+ position: relative;
+ z-index: 2;
+ mix-blend-mode: plus-lighter;
+
+ @media (--range-small-up) {
+ height: 14px;
+ }
+ }
+
+ .launch-native-button-container {
+ position: relative;
+ z-index: 2;
+ }
+
+ @keyframes shift-background {
+ 0% {
+ background-position: 50% 50%;
+ background-size: 100%;
+ transform: rotate(0deg);
+ opacity: 0;
+ }
+
+ 10% {
+ opacity: 0.5;
+ }
+
+ 20% {
+ background-position: 65% 25%;
+ background-size: 160%;
+ transform: rotate(45deg);
+ }
+
+ 45% {
+ background-position: 90% 60%;
+ background-size: 250%;
+ transform: rotate(160deg);
+ opacity: 0.5;
+ }
+
+ 70% {
+ background-position: 70% 40%;
+ background-size: 200%;
+ transform: rotate(250deg);
+ opacity: 0.5;
+ }
+
+ 100% {
+ background-position: 50% 50%;
+ background-size: 100%;
+ transform: rotate(360deg);
+ opacity: 0;
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte
new file mode 100644
index 0000000..c1e7b2e
--- /dev/null
+++ b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte
@@ -0,0 +1,36 @@
+<script lang="ts" context="module">
+ import type {
+ AccessibilityParagraph,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface AccessibilityDeveloperLinkShelf extends Shelf {
+ items: [AccessibilityParagraph];
+ }
+
+ export function isAccessibilityDeveloperLinkShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityDeveloperLinkShelf {
+ let { contentType, items, title } = shelf;
+
+ return (
+ contentType === 'accessibilityParagraph' &&
+ !title &&
+ Array.isArray(items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import AccessibilityParagraphItem from '../item/AccessibilityParagraphItem.svelte';
+ import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
+
+ export let shelf: AccessibilityDeveloperLinkShelf;
+
+ $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
+</script>
+
+<ShelfWrapper {shelf} centered {withBottomPadding}>
+ <AccessibilityParagraphItem item={shelf.items[0]} />
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte
new file mode 100644
index 0000000..cb2fed8
--- /dev/null
+++ b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte
@@ -0,0 +1,35 @@
+<script lang="ts" context="module">
+ import type {
+ AccessibilityFeatures,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ export interface AccessibilityFeaturesShelf extends Shelf {
+ items: AccessibilityFeatures[];
+ }
+
+ export function isAccessibilityFeaturesShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityFeaturesShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'accessibilityFeatures' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte';
+ import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
+
+ export let shelf: AccessibilityFeaturesShelf;
+
+ $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
+</script>
+
+<ShelfWrapper {shelf} {withBottomPadding}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <AccessibilityFeaturesItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AccessibilityHeaderShelf.svelte b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte
new file mode 100644
index 0000000..990c507
--- /dev/null
+++ b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte
@@ -0,0 +1,182 @@
+<script lang="ts" context="module">
+ import {
+ type Action,
+ type FlowAction,
+ type GenericPage,
+ type AccessibilityParagraph,
+ type Shelf,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ import {
+ isAccessibilityFeaturesShelf,
+ type AccessibilityFeaturesShelf,
+ } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte';
+
+ interface AccessibilityParagraphShelf extends Shelf {
+ items: [AccessibilityParagraph];
+ }
+
+ interface AccessibilityHeaderShelf extends AccessibilityParagraphShelf {
+ items: [AccessibilityParagraph];
+ }
+
+ interface AccessibilityDetailPage extends GenericPage {
+ shelves: (AccessibilityFeaturesShelf | AccessibilityParagraphShelf)[];
+ }
+
+ interface AccessibilityDetailPageFlowAction extends FlowAction {
+ page: 'accessibilityDetails';
+ pageData: AccessibilityDetailPage;
+ }
+
+ export function isAccessibilityHeaderShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityHeaderShelf {
+ let { contentType, items, title } = shelf;
+
+ return (
+ contentType === 'accessibilityParagraph' &&
+ !!title &&
+ Array.isArray(items)
+ );
+ }
+
+ function isAccessibilityParagraphShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityParagraphShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'accessibilityParagraph' && Array.isArray(items);
+ }
+
+ function isAccessibilityDetailFlowAction(
+ action: Action,
+ ): action is AccessibilityDetailPageFlowAction {
+ return isFlowAction(action) && action.page === 'accessibilityDetails';
+ }
+</script>
+
+<script lang="ts">
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import AccessibilityParagraphItem from '~/components/jet/item/AccessibilityParagraphItem.svelte';
+ import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
+
+ export let shelf: AccessibilityHeaderShelf;
+
+ $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const { seeAllAction } = shelf;
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+ const handleModalClose = () => modalComponent?.close();
+ const handleOpenModalClick = (e: Event) => {
+ modalTriggerElement = e.target as HTMLElement;
+ modalComponent?.showModal();
+ };
+
+ const destination =
+ seeAllAction && isAccessibilityDetailFlowAction(seeAllAction)
+ ? seeAllAction
+ : undefined;
+ const pageData = destination?.pageData;
+</script>
+
+<ShelfWrapper {shelf} {withBottomPadding}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ {#if destination}
+ <button on:click={handleOpenModalClick}>
+ <ShelfTitle
+ title={shelf.title}
+ seeAllAction={destination}
+ />
+ </button>
+ {:else}
+ <ShelfTitle title={shelf.title} />
+ {/if}
+ {/if}
+
+ {#if pageData}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleModalClose}
+ title={pageData.title || null}
+ subtitle={null}
+ >
+ <svelte:fragment slot="content">
+ <div class="modal-content-container">
+ {#each pageData.shelves as shelf}
+ <div class="content-section">
+ {#if isAccessibilityParagraphShelf(shelf)}
+ {#each shelf.items as item}
+ <AccessibilityParagraphItem
+ {item}
+ />
+ {/each}
+ {/if}
+
+ {#if isAccessibilityFeaturesShelf(shelf)}
+ {#each shelf.items as item}
+ <AccessibilityFeaturesItem
+ {item}
+ isDetailView={true}
+ />
+ {/each}
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+ {/if}
+ </div>
+
+ <div class="header-container">
+ <div>
+ <AccessibilityParagraphItem item={shelf.items[0]} />
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style>
+ .title-container {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 16px;
+ padding-inline-end: var(--bodyGutter);
+ }
+
+ .header-container {
+ margin: 0 var(--bodyGutter);
+ }
+
+ .header-container div {
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ .modal-content-container {
+ font: var(--body-tall);
+ white-space: normal;
+ }
+
+ .modal-content-container .content-section {
+ padding-top: 20px;
+ border-top: 1px solid var(--defaultLine);
+ }
+
+ .modal-content-container .content-section:not(:first-child) {
+ margin-top: 20px;
+ }
+</style>
diff --git a/src/components/jet/shelf/ActionShelf.svelte b/src/components/jet/shelf/ActionShelf.svelte
new file mode 100644
index 0000000..847438f
--- /dev/null
+++ b/src/components/jet/shelf/ActionShelf.svelte
@@ -0,0 +1,80 @@
+<script lang="ts" context="module">
+ import type { Shelf, Action } from '@jet-app/app-store/api/models';
+
+ interface ActionShelf extends Shelf {
+ items: Action[];
+ }
+
+ export function isActionShelf(shelf: Shelf): shelf is ActionShelf {
+ return shelf.contentType === 'action' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ActionShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="F" let:item>
+ {@const action = item}
+ {@const artwork = item.artwork}
+ {@const title = item.title}
+
+ <div class="container">
+ <LinkWrapper {action}>
+ {#if artwork}
+ <div class="artwork-container" aria-hidden="true">
+ <Artwork
+ {artwork}
+ profile={getNaturalProfile(artwork, [24])}
+ hasTransparentBackground
+ />
+ </div>
+ {/if}
+ {title}
+ </LinkWrapper>
+ </div>
+ </ShelfItemLayout>
+</ShelfWrapper>
+
+<style>
+ .container :global(a) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ background: var(--pageBG);
+ border-radius: var(--global-border-radius-medium);
+ box-shadow: var(--shadow-small);
+ padding: 16px 10px;
+ width: 100%;
+ font: var(--title-3-medium);
+ transition: background-color 210ms ease-out;
+ }
+
+ .container :global(a:hover) {
+ /* stylelint-disable color-function-notation */
+ background-color: rgb(from var(--pageBG) r g b/0.1);
+ /* stylelint-enable color-function-notation */
+
+ @media (prefers-color-scheme: dark) {
+ /* stylelint-disable color-function-notation */
+ background-color: rgb(from var(--pageBG) r g b/0.85);
+ /* stylelint-enable color-function-notation */
+ }
+ }
+
+ .artwork-container {
+ width: 24px;
+ height: 24px;
+ }
+
+ .container :global(.external-link-arrow) {
+ height: 10px;
+ }
+</style>
diff --git a/src/components/jet/shelf/AnnotationShelf.svelte b/src/components/jet/shelf/AnnotationShelf.svelte
new file mode 100644
index 0000000..e11de72
--- /dev/null
+++ b/src/components/jet/shelf/AnnotationShelf.svelte
@@ -0,0 +1,49 @@
+<script lang="ts" context="module">
+ import type { Shelf, Annotation } from '@jet-app/app-store/api/models';
+
+ interface AnnotationShelf extends Shelf {
+ items: Annotation[];
+ }
+
+ export function isAnnotationShelf(shelf: Shelf): shelf is AnnotationShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'annotation' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import Grid from '~/components/Grid.svelte';
+ import CollapsableContent from '~/components/CollapsableContent.svelte';
+ import AnnotationItem from '~/components/jet/item/Annotation/AnnotationItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: AnnotationShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <dl>
+ <Grid items={shelf.items} gridType="F" let:item>
+ <dt>{item.title}</dt>
+
+ {#if item.summary}
+ <CollapsableContent>
+ <svelte:fragment slot="summary">
+ {item.summary}
+ </svelte:fragment>
+
+ <AnnotationItem {item} />
+ </CollapsableContent>
+ {:else}
+ <AnnotationItem {item} />
+ {/if}
+ </Grid>
+ </dl>
+</ShelfWrapper>
+
+<style>
+ dt {
+ color: var(--systemSecondary);
+ margin-bottom: 4px;
+ }
+</style>
diff --git a/src/components/jet/shelf/AppEventDetailShelf.svelte b/src/components/jet/shelf/AppEventDetailShelf.svelte
new file mode 100644
index 0000000..2ae84eb
--- /dev/null
+++ b/src/components/jet/shelf/AppEventDetailShelf.svelte
@@ -0,0 +1,290 @@
+<script lang="ts" context="module">
+ import {
+ type AppEventDetailShelf,
+ isAppEventDetailShelf,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ export { isAppEventDetailShelf };
+</script>
+
+<script lang="ts">
+ import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import mediaQueries from '~/utils/media-queries';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import { colorAsString } from '~/utils/color';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+ import { platform } from '@amp/web-apps-utils';
+ import LaunchNativeButton from '~/components/LaunchNativeButton.svelte';
+
+ export let shelf: AppEventDetailShelf;
+
+ $: item = shelf.items[0];
+ $: ({ appEvent, artwork: productArtwork, video } = item);
+ $: ({ requirements, lockup } = appEvent);
+ $: artwork = video ? video.preview : productArtwork;
+
+ $: backgroundImageUrl = artwork
+ ? buildSrc(
+ artwork.template,
+ {
+ crop: artwork.crop as CropCode,
+ width: 200,
+ height: Math.floor(200 / (artwork.width / artwork.height)),
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+
+ $: backgroundColor = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : '#000';
+ $: hasLightArtwork = appEvent.mediaOverlayStyle === 'light';
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: clickAction = lockup?.clickAction;
+ $: urlToLaunchNatively =
+ clickAction && isFlowAction(clickAction) ? clickAction.pageUrl : null;
+ $: shouldShowLaunchNativeButton =
+ platform.ismacOS() &&
+ lockup?.isIOSBinaryMacOSCompatible &&
+ !!urlToLaunchNatively;
+
+ function makeCSSURL(url: string | null | undefined): string {
+ return url ? `url(${url})` : '';
+ }
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false} centered={false}>
+ <div
+ class="event-detail"
+ style:--background-image-url={makeCSSURL(backgroundImageUrl)}
+ style:--background-color={backgroundColor}
+ >
+ {#if video}
+ <div class="video-container">
+ <Video
+ {video}
+ autoplay
+ loop
+ useControls={false}
+ profile="app-event-detail"
+ />
+ </div>
+ {:else if artwork}
+ <div class="artwork-container">
+ <Artwork {artwork} profile="app-event-detail" />
+ </div>
+ {/if}
+
+ {#if isXSmallViewport}
+ <div class="gradient-container">
+ <GradientOverlay
+ --color={backgroundColor}
+ --height="70%"
+ shouldDarken={!hasLightArtwork}
+ />
+ </div>
+ {:else}
+ <div class="tint-container" />
+ {/if}
+
+ <div class="time-container">
+ <AppEventDate {appEvent} />
+ </div>
+
+ <div
+ class="text-container"
+ class:dark={hasLightArtwork && isXSmallViewport}
+ >
+ <div class="event-details-container">
+ <p class="app-event-kind">{appEvent.kind}</p>
+ <h1 class="app-event-title">{appEvent.title}</h1>
+ <p class="app-event-subtitle">
+ {appEvent.detail}
+ </p>
+ {#if requirements}
+ <span class="requirements">{requirements}</span>
+ {/if}
+ </div>
+
+ {#if lockup}
+ <div class="lockup-container">
+ <SmallLockupItem
+ {shouldShowLaunchNativeButton}
+ item={lockup}
+ buttonVariant="transparent"
+ appIconProfile="app-icon"
+ >
+ <svelte:fragment slot="launch-native-button">
+ {#if urlToLaunchNatively}
+ <LaunchNativeButton url={urlToLaunchNatively} />
+ {/if}
+ </svelte:fragment>
+ </SmallLockupItem>
+ </div>
+ {/if}
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .event-detail {
+ --event-image-desktop-width: 31.64%;
+ --border-radius: 16px;
+ --event-gutter: 16px;
+ border-radius: var(--border-radius);
+ display: grid;
+ grid-template-areas:
+ 'time'
+ 'text';
+ grid-template-rows: 1fr auto;
+ aspect-ratio: 9/16;
+ max-height: 90vh;
+ overflow: hidden;
+ position: relative;
+
+ @media (--range-small-up) {
+ --event-gutter: 20px;
+ aspect-ratio: 16/9;
+ background-image: var(--background-image-url);
+ background-position-x: 50%;
+ background-position-y: 50%;
+ background-size: cover;
+ grid-template-areas:
+ 'image time'
+ 'image text';
+ grid-template-columns: var(--event-image-desktop-width) auto;
+ grid-template-rows: auto 1fr;
+ }
+ }
+
+ .artwork-container,
+ .video-container {
+ z-index: 1;
+
+ /* On "mobile" the artwork should be behind both the time and text */
+ grid-row-start: time;
+ grid-row-end: text;
+ grid-column: 1;
+
+ @media (--range-small-up) {
+ /* On large screens, it should be to the right of the text */
+ grid-area: image;
+ }
+ }
+
+ .video-container {
+ background: var(--background-color);
+ color: transparent;
+ }
+
+ .video-container :global(video) {
+ width: unset;
+ position: absolute;
+ }
+
+ .tint-container {
+ background: var(--systemTertiary-onLight_IC);
+ backdrop-filter: saturate(120%) blur(24px);
+ z-index: 1;
+
+ /* One smaller screens, extend behind just the text */
+ grid-area: text;
+
+ /* On larger screens, extend behind time and text */
+ grid-row-start: time;
+ grid-row-end: text;
+ }
+
+ .time-container {
+ grid-area: time;
+ margin-top: var(--event-gutter);
+ margin-inline-start: var(--event-gutter);
+ }
+
+ .time-container :global(time) {
+ color: var(--systemPrimary-onLight);
+ font: var(--callout-emphasized);
+ padding: 3px 10px;
+ background-color: var(--systemSecondary-onDark);
+ border-radius: var(--global-border-radius-medium);
+ position: relative;
+ z-index: 3;
+
+ @media (--range-small-up) {
+ position: relative;
+ z-index: 3;
+ mix-blend-mode: plus-lighter;
+ }
+ }
+
+ .text-container {
+ --blend-mode: plus-lighter;
+ --text-color: var(--systemPrimary-onDark);
+ padding: var(--event-gutter);
+
+ /* Placement within parent */
+ grid-area: text;
+
+ /* Layout of child elements */
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ justify-content: space-between;
+ color: var(--text-color);
+ }
+
+ .text-container.dark {
+ --blend-mode: normal;
+ --text-color: var(--systemPrimary-onLight);
+ }
+
+ .event-details-container {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .app-event-kind {
+ font: var(--callout-emphasized);
+ mix-blend-mode: var(--blend-mode);
+ z-index: 1;
+ }
+
+ .app-event-title {
+ font: var(--large-title-emphasized);
+ text-wrap: pretty;
+ z-index: 1;
+ }
+
+ .app-event-subtitle {
+ font: var(--title-3);
+ z-index: 1;
+ }
+
+ .requirements {
+ position: relative;
+ z-index: 1;
+ font: var(--body-emphasized);
+ }
+
+ .lockup-container {
+ --title-color: var(--text-color);
+ --subtitle-color: var(--text-color);
+ --eyebrow-color: var(--text-color);
+ --linkColor: var(--text-color);
+ --button-blend-mode: var(--blend-mode);
+ border-top: 1px solid var(--systemQuaternary-onDark);
+ padding-top: 16px;
+ z-index: 1;
+ }
+</style>
diff --git a/src/components/jet/shelf/AppPromotionShelf.svelte b/src/components/jet/shelf/AppPromotionShelf.svelte
new file mode 100644
index 0000000..48590cb
--- /dev/null
+++ b/src/components/jet/shelf/AppPromotionShelf.svelte
@@ -0,0 +1,47 @@
+<script lang="ts" context="module">
+ import type {
+ AppPromotion,
+ AppEvent,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface AppPromotionShelf extends Shelf {
+ items: AppPromotion[];
+ }
+
+ export function isAppPromotionShelf(
+ shelf: Shelf,
+ ): shelf is AppPromotionShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'appPromotion' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import AppEventItem from '~/components/jet/item/AppEventItem.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import mediaQueries from '~/utils/media-queries';
+
+ export let shelf: AppPromotionShelf;
+
+ $: appEventItems = shelf.items.filter(
+ (item): item is AppEvent => item.promotionType === 'appEvent',
+ );
+ $: isArticleContext = shelf.presentationHints?.isArticleContext;
+ $: gridType =
+ isArticleContext && $mediaQueries !== 'small' ? 'Spotlight' : 'B';
+</script>
+
+<ShelfWrapper {shelf} withTopMargin={isArticleContext}>
+ <ShelfItemLayout
+ shelf={{
+ ...shelf,
+ items: appEventItems,
+ }}
+ {gridType}
+ let:item
+ >
+ <AppEventItem {item} {isArticleContext} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AppShowcaseShelf.svelte b/src/components/jet/shelf/AppShowcaseShelf.svelte
new file mode 100644
index 0000000..095acf2
--- /dev/null
+++ b/src/components/jet/shelf/AppShowcaseShelf.svelte
@@ -0,0 +1,29 @@
+<script lang="ts" context="module">
+ import type { AppShowcase, Shelf } from '@jet-app/app-store/api/models';
+
+ interface AppShowcaseShelf extends Shelf {
+ contentType: 'appShowcase';
+ items: [AppShowcase];
+ }
+
+ export function isAppShowcaseShelf(
+ shelf: Shelf,
+ ): shelf is AppShowcaseShelf {
+ return (
+ shelf.contentType === 'appShowcase' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte';
+
+ export let shelf: AppShowcaseShelf;
+
+ $: item = shelf.items[0];
+</script>
+
+<ShelfWrapper {shelf} withTopMargin centered>
+ <SmallLockup item={item.lockup} />
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AppTrailerLockupShelf.svelte b/src/components/jet/shelf/AppTrailerLockupShelf.svelte
new file mode 100644
index 0000000..f516074
--- /dev/null
+++ b/src/components/jet/shelf/AppTrailerLockupShelf.svelte
@@ -0,0 +1,48 @@
+<script lang="ts" context="module">
+ import type {
+ TrailersLockup,
+ MixedMediaLockup,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ type AppTrailerLockupItem = TrailersLockup | MixedMediaLockup;
+
+ interface AppTrailerLockupShelf extends Shelf {
+ contentType: 'appTrailerLockup';
+ items: AppTrailerLockupItem[];
+ }
+
+ export function isAppTrailerLockupShelf(
+ shelf: Shelf,
+ ): shelf is AppTrailerLockupShelf {
+ return (
+ shelf.contentType === 'appTrailerLockup' &&
+ Array.isArray(shelf.items)
+ );
+ }
+
+ function isMixedMediaLockup(
+ item: AppTrailerLockupItem,
+ ): item is MixedMediaLockup {
+ return Array.isArray(item.trailers);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import MixedMediaLockupItem from '~/components/jet/item/MixedMediaLockupItem.svelte';
+ import TrailersLockupItem from '~/components/jet/item/TrailersLockupItem.svelte';
+
+ export let shelf: AppTrailerLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ {#if isMixedMediaLockup(item)}
+ <MixedMediaLockupItem {item} />
+ {:else}
+ <TrailersLockupItem {item} />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ArcadeFooterShelf.svelte b/src/components/jet/shelf/ArcadeFooterShelf.svelte
new file mode 100644
index 0000000..dc46740
--- /dev/null
+++ b/src/components/jet/shelf/ArcadeFooterShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type { Shelf, ArcadeFooter } from '@jet-app/app-store/api/models';
+
+ interface ArcadeFooterShelf extends Shelf {
+ items: [ArcadeFooter];
+ }
+
+ export function isArcadeFooterShelf(
+ shelf: Shelf,
+ ): shelf is ArcadeFooterShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'arcadeFooter' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ArcadeFooterItem from '~/components/jet/item/ArcadeFooterItem.svelte';
+ import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ArcadeFooterShelf;
+
+ $: gridRows = shelf.rowsPerColumn ?? undefined;
+ $: items = shelf.items;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <HorizontalShelf {gridRows} gridType="Spotlight" {items} let:item>
+ <ArcadeFooterItem {item} />
+ </HorizontalShelf>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/BannerShelf.svelte b/src/components/jet/shelf/BannerShelf.svelte
new file mode 100644
index 0000000..84289c9
--- /dev/null
+++ b/src/components/jet/shelf/BannerShelf.svelte
@@ -0,0 +1,35 @@
+<script lang="ts" context="module">
+ import type { Shelf, Banner } from '@jet-app/app-store/api/models';
+
+ interface BannerShelf extends Shelf {
+ contentType: 'banner';
+ items: Banner[];
+ }
+
+ export function isBannerShelf(shelf: Shelf): shelf is BannerShelf {
+ return shelf.contentType === 'banner' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import BannerItem from '~/components/jet/item/BannerItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: BannerShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <div class="banner-items-container">
+ {#each shelf.items as item}
+ <BannerItem {item} />
+ {/each}
+ </div>
+</ShelfWrapper>
+
+<style>
+ .banner-items-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+</style>
diff --git a/src/components/jet/shelf/BrickShelf.svelte b/src/components/jet/shelf/BrickShelf.svelte
new file mode 100644
index 0000000..4bd55e5
--- /dev/null
+++ b/src/components/jet/shelf/BrickShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface BrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isBrickShelf(shelf: Shelf): shelf is BrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'brick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import BrickItem from '~/components/jet/item/BrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: BrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout
+ {shelf}
+ gridTypeForShelf="Brick"
+ gridTypeForGrid="F"
+ let:item
+ >
+ <BrickItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/CategoryBrickShelf.svelte b/src/components/jet/shelf/CategoryBrickShelf.svelte
new file mode 100644
index 0000000..22ca86b
--- /dev/null
+++ b/src/components/jet/shelf/CategoryBrickShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface CategoryBrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isCategoryBrickShelf(
+ shelf: Shelf,
+ ): shelf is CategoryBrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'categoryBrick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import BrickItem from '~/components/jet/item/BrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: CategoryBrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="C" let:item>
+ <BrickItem {item} shouldOverlayDescription />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/EditorialCardShelf.svelte b/src/components/jet/shelf/EditorialCardShelf.svelte
new file mode 100644
index 0000000..efbd71d
--- /dev/null
+++ b/src/components/jet/shelf/EditorialCardShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type { Shelf, EditorialCard } from '@jet-app/app-store/api/models';
+
+ interface EditorialCardShelf extends Shelf {
+ items: EditorialCard[];
+ }
+
+ export function isEditorialCardShelf(
+ shelf: Shelf,
+ ): shelf is EditorialCardShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'editorialCard' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import HeroCarousel from '~/components/hero/Carousel.svelte';
+ import EditorialCardItem from '~/components/jet/item/EditorialCardItem.svelte';
+
+ export let shelf: EditorialCardShelf;
+
+ $: items = shelf.items;
+
+ function deriveBackgroundArtworkFromItem(item: EditorialCard) {
+ return item.artwork;
+ }
+</script>
+
+<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item>
+ <EditorialCardItem {item} />
+</HeroCarousel>
diff --git a/src/components/jet/shelf/EditorialLinkShelf.svelte b/src/components/jet/shelf/EditorialLinkShelf.svelte
new file mode 100644
index 0000000..0946462
--- /dev/null
+++ b/src/components/jet/shelf/EditorialLinkShelf.svelte
@@ -0,0 +1,122 @@
+<script lang="ts" context="module">
+ import type { Shelf, EditorialLink } from '@jet-app/app-store/api/models';
+
+ interface EditorialLinkShelf extends Shelf {
+ contentType: 'smallStoryCard';
+ items: [EditorialLink];
+ }
+
+ export function isEditorialLinkShelf(
+ shelf: Shelf,
+ ): shelf is EditorialLinkShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'editorialLink' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ChevronRightIcon from '~/sf-symbols/chevron.right.svg';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let shelf: EditorialLinkShelf;
+ $: item = shelf.items[0];
+ $: ({ clickAction, descriptionText, summaryText } = item);
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <article>
+ <LinkWrapper
+ action={clickAction}
+ includeExternalLinkArrowIcon={false}
+ label={descriptionText}
+ >
+ <svelte:fragment>
+ <div>
+ <span class="title">{descriptionText}</span>
+ <span class="subtitle">{summaryText}</span>
+ </div>
+
+ <span class="icon-container" aria-hidden="true">
+ <ChevronRightIcon />
+ </span>
+ </svelte:fragment>
+ </LinkWrapper>
+ </article>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+ padding: 16px;
+ margin: 0 var(--bodyGutter);
+ border-radius: var(--global-border-radius-medium);
+ background-color: var(--systemQuinary);
+ transition: background-color 210ms ease-out;
+ }
+
+ article:hover {
+ cursor: pointer;
+ // a fallback for browsers that don't support relative colors (e.g. the `from` syntax)
+ background-color: var(--systemQuinary);
+ // stylelint-disable-next-line color-function-notation
+ background-color: rgb(
+ from var(--systemQuinary) r g b / calc(alpha + 0.02)
+ );
+ }
+
+ article:hover .icon-container {
+ transform: translateX(2px);
+
+ @include rtl {
+ transform: translateX(-2px) rotate(-180deg);
+ }
+ }
+
+ div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .title {
+ font: var(--body-emphasized);
+ }
+
+ .subtitle {
+ color: var(--systemSecondary);
+ }
+
+ .icon-container {
+ position: relative;
+ height: 10px;
+ aspect-ratio: 0.9;
+ transition: transform 210ms ease-out;
+
+ @include rtl {
+ transform: rotate(-180deg);
+ }
+ }
+
+ .icon-container :global(path:not([fill='none'])) {
+ fill: var(--systemPrimary);
+ }
+
+ article :global(a) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/FallbackShelf.svelte b/src/components/jet/shelf/FallbackShelf.svelte
new file mode 100644
index 0000000..c7e4200
--- /dev/null
+++ b/src/components/jet/shelf/FallbackShelf.svelte
@@ -0,0 +1,39 @@
+<script lang="ts" context="module">
+ import type { Shelf, ShelfModel } from '@jet-app/app-store/api/models';
+
+ interface FallbackShelf extends Shelf {
+ items: ShelfModel[];
+ }
+
+ export function isFallbackShelf(shelf: Shelf): shelf is FallbackShelf {
+ return Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: FallbackShelf;
+
+ const isPlaceholder = shelf.contentType === 'placeholder';
+</script>
+
+<ShelfWrapper withTopBorder>
+ <ShelfItemLayout {shelf} gridType="C">
+ <div class="wip">
+ {isPlaceholder
+ ? `🔄 Placeholder for ${shelf.placeholderContentType}`
+ : `🚧 ${shelf.contentType}`}
+ </div>
+ </ShelfItemLayout>
+</ShelfWrapper>
+
+<style>
+ .wip {
+ background: #f8f8f8;
+ padding: 16px;
+ border-radius: 8px;
+ border: 1px solid #ccc;
+ }
+</style>
diff --git a/src/components/jet/shelf/FramedArtworkShelf.svelte b/src/components/jet/shelf/FramedArtworkShelf.svelte
new file mode 100644
index 0000000..16f7c48
--- /dev/null
+++ b/src/components/jet/shelf/FramedArtworkShelf.svelte
@@ -0,0 +1,98 @@
+<script lang="ts" context="module">
+ import type { FramedArtwork, Shelf } from '@jet-app/app-store/api/models';
+
+ interface FramedArtworkShelf extends Shelf {
+ contentType: 'framedArtwork';
+ items: [FramedArtwork];
+ }
+
+ export function isFramedArtworkShelf(
+ shelf: Shelf,
+ ): shelf is FramedArtworkShelf {
+ return (
+ shelf.contentType === 'framedArtwork' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: FramedArtworkShelf;
+
+ $: item = shelf.items[0];
+ $: ({ artwork, caption, hasRoundedCorners } = item);
+ $: profile = getNaturalProfile(artwork, [1275, 1185, 825, 500, 690]);
+ $: aspectRatio = artwork.width / artwork.height;
+ $: isPortrait = aspectRatio < 1;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <figure
+ class="framed-artwork-item"
+ class:has-rounded-corners={hasRoundedCorners}
+ class:is-portrait={isPortrait}
+ >
+ <div
+ class="artwork-container"
+ style:--aspect-ratio={artwork.width / artwork.height}
+ >
+ <Artwork {artwork} {profile} forceFullWidth={!isPortrait} />
+ </div>
+
+ {#if caption}
+ <figcaption class="caption">
+ {@html sanitizeHtml(caption)}
+ </figcaption>
+ {/if}
+ </figure>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ .framed-artwork-item {
+ border-radius: var(--framed-artwork-border-radius);
+ padding: 0 20px;
+ overflow: hidden;
+
+ @media (--sidebar-visible) {
+ padding: 0 20px;
+ }
+
+ @media (--range-small-only) {
+ padding: 0 var(--bodyGutter);
+ }
+ }
+
+ .framed-artwork-item.has-rounded-corners {
+ --framed-artwork-border-radius: var(--global-border-radius-medium);
+ }
+
+ .artwork-container {
+ border-radius: inherit;
+ }
+
+ .caption {
+ border-bottom-left-radius: var(--framed-artwork-border-radius);
+ border-bottom-right-radius: var(--framed-artwork-border-radius);
+ color: var(--systemSecondary);
+ padding: 8px var(--article-page-padding) 0;
+ }
+
+ .framed-artwork-item.is-portrait {
+ --artwork-override-max-height: 560px;
+ --artwork-override-max-width: 100%;
+ --artwork-override-width: auto;
+ }
+
+ .framed-artwork-item.framed-artwork-item.is-portrait .artwork-container {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ max-height: var(--artwork-override-max-height);
+ aspect-ratio: var(--aspect-ratio);
+ }
+</style>
diff --git a/src/components/jet/shelf/FramedVideoShelf.svelte b/src/components/jet/shelf/FramedVideoShelf.svelte
new file mode 100644
index 0000000..a685d39
--- /dev/null
+++ b/src/components/jet/shelf/FramedVideoShelf.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type { FramedVideo, Shelf } from '@jet-app/app-store/api/models';
+
+ interface FramedVideoShelf extends Shelf {
+ contentType: 'framedArtwork';
+ items: [FramedVideo];
+ }
+
+ export function isFramedVideoShelf(
+ shelf: Shelf,
+ ): shelf is FramedVideoShelf {
+ return (
+ shelf.contentType === 'framedVideo' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { getNaturalProfile } from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let shelf: FramedVideoShelf;
+
+ $: ({ caption, video } = shelf.items[0]);
+ $: aspectRatio = video.preview.width / video.preview.height;
+ $: profile = getNaturalProfile(video.preview, [608, 528, 608, 928, 298]);
+ $: isPortrait = aspectRatio < 1;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <figure class="framed-artwork-item" class:is-portrait={isPortrait}>
+ <div class="artwork-container" style:--aspect-ratio={aspectRatio}>
+ <Video {video} {profile} autoplay />
+ </div>
+
+ {#if caption}
+ <figcaption class="caption">
+ {@html sanitizeHtml(caption)}
+ </figcaption>
+ {/if}
+ </figure>
+</ShelfWrapper>
+
+<style>
+ .framed-artwork-item {
+ border-radius: var(--global-border-radius-medium);
+ padding: 0 20px;
+ overflow: hidden;
+
+ @media (--sidebar-visible) {
+ padding: 0 20px;
+ }
+
+ @media (--range-small-only) {
+ padding: 0 var(--bodyGutter);
+ }
+ }
+
+ .artwork-container {
+ aspect-ratio: var(--aspect-ratio);
+ overflow: hidden;
+ line-height: 0;
+ border-radius: var(--global-border-radius-medium);
+ background-color: var(--systemQuaternary);
+ max-height: 560px;
+ max-width: 100%;
+ margin: 0 auto;
+ }
+
+ .caption {
+ border-bottom-left-radius: var(--global-border-radius-medium);
+ border-bottom-right-radius: var(--global-border-radius-medium);
+ color: var(--systemSecondary);
+ padding: 8px var(--article-page-padding) 0;
+ }
+</style>
diff --git a/src/components/jet/shelf/HeroCarouselShelf.svelte b/src/components/jet/shelf/HeroCarouselShelf.svelte
new file mode 100644
index 0000000..31a0287
--- /dev/null
+++ b/src/components/jet/shelf/HeroCarouselShelf.svelte
@@ -0,0 +1,38 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ HeroCarousel as HeroCarouselModel,
+ HeroCarouselItem as HeroCarouselItemModel,
+ } from '@jet-app/app-store/api/models';
+
+ interface HeroCarouselShelf extends Shelf {
+ items: [HeroCarouselModel];
+ }
+
+ export function isHeroCarouselShelf(
+ shelf: Shelf,
+ ): shelf is HeroCarouselShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'heroCarousel' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import HeroCarousel from '~/components/hero/Carousel.svelte';
+ import HeroCarouselItem from '~/components/jet/item/HeroCarouselItem.svelte';
+ import { isRtl } from '~/utils/locale';
+
+ export let shelf: HeroCarouselShelf;
+
+ $: ({ items: ltrItems, rtlItems } = shelf.items[0]);
+ $: items = isRtl() && rtlItems.length ? rtlItems : ltrItems;
+
+ function deriveBackgroundArtworkFromItem(item: HeroCarouselItemModel) {
+ return item.artwork || item.video?.preview;
+ }
+</script>
+
+<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item>
+ <HeroCarouselItem {item} />
+</HeroCarousel>
diff --git a/src/components/jet/shelf/HorizontalRuleShelf.svelte b/src/components/jet/shelf/HorizontalRuleShelf.svelte
new file mode 100644
index 0000000..3313ff2
--- /dev/null
+++ b/src/components/jet/shelf/HorizontalRuleShelf.svelte
@@ -0,0 +1,54 @@
+<script lang="ts" context="module">
+ import type {
+ HorizontalRule,
+ HorizontalRuleStyle,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ export interface HorizontalRuleShelf extends Shelf {
+ contentType: 'horizontalRule';
+ items: [HorizontalRule];
+ }
+
+ export function isHorizontalRuleShelf(
+ shelf: Shelf,
+ ): shelf is HorizontalRuleShelf {
+ return (
+ shelf.contentType === 'horizontalRule' && Array.isArray(shelf.items)
+ );
+ }
+
+ function horizontalRuleStyleToBorderStyle(
+ style: HorizontalRuleStyle,
+ ): string {
+ return style.toLowerCase();
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let shelf: HorizontalRuleShelf;
+
+ $: item = shelf.items[0];
+ $: borderStyle = horizontalRuleStyleToBorderStyle(item.style);
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <hr
+ style:color={colorAsString(item.color)}
+ style:border-style={borderStyle}
+ />
+</ShelfWrapper>
+
+<style>
+ hr {
+ display: block;
+ height: 1px;
+ border-width: 1px 0 0;
+ border-color: currentColor;
+ margin: 1em var(--bodyGutter);
+ padding: 0;
+ }
+</style>
diff --git a/src/components/jet/shelf/HorizontalShelf.svelte b/src/components/jet/shelf/HorizontalShelf.svelte
new file mode 100644
index 0000000..1addb31
--- /dev/null
+++ b/src/components/jet/shelf/HorizontalShelf.svelte
@@ -0,0 +1,53 @@
+<script lang="ts">
+ import type { Opt } from '@jet/environment';
+ import Shelf from '@amp/web-app-components/src/components/Shelf/Shelf.svelte';
+ import type {
+ ArrowOffset,
+ GridType,
+ } from '@amp/web-app-components/src/components/Shelf/types';
+ import { getI18n } from '~/stores/i18n';
+
+ type T = $$Generic;
+
+ export let items: T[];
+ export let gridType: GridType;
+ export let gridRows: number = 1;
+ export let arrowOffset: Opt<ArrowOffset> = null;
+
+ const i18n = getI18n();
+ // This makes the let:item of type T, because it doesn't know type when it comes back from the Shelf component.
+ function castGenericItem(x: T): T {
+ return x;
+ }
+</script>
+
+<div class="horizontal-shelf" data-test-id="horizontal-shelf">
+ <Shelf translateFn={$i18n.t} {items} {gridType} {gridRows} {arrowOffset}>
+ <svelte:fragment slot="item" let:item let:index let:numberOfItems>
+ <slot item={castGenericItem(item)} {index} {numberOfItems} />
+ </svelte:fragment>
+ </Shelf>
+</div>
+
+<style>
+ .horizontal-shelf :global(.shelf-grid) {
+ --shelfGridPaddingInline: var(--bodyGutter);
+ --shelfGridGutterWidth: var(--bodyGutter);
+ }
+
+ .horizontal-shelf :global(.shelf-grid__list) {
+ @media (--range-xsmall-only) {
+ scroll-padding-inline-start: var(
+ --shelfScrollPaddingInline,
+ var(--bodyGutter)
+ );
+ }
+ }
+
+ .horizontal-shelf
+ :global(.shelf-grid__list--grid-type-Spotlight .shelf-grid__list-item) {
+ @media (--range-xsmall-only) {
+ --standard-lockup-shadow-offset: var(--shelfScrollPaddingInline, 0);
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte
new file mode 100644
index 0000000..bf2e75e
--- /dev/null
+++ b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type {
+ InAppPurchaseLockup,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface InAppPurchaseLockupShelf extends Shelf {
+ items: InAppPurchaseLockup[];
+ }
+
+ export function isInAppPurchaseLockupShelf(
+ shelf: Shelf,
+ ): shelf is InAppPurchaseLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'inAppPurchaseLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import InAppPurchaseLockupComponent from '~/components/jet/item/InAppPurchaseLockup.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: InAppPurchaseLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="InAppPurchaseLockup" let:item>
+ <InAppPurchaseLockupComponent {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeBrickShelf.svelte b/src/components/jet/shelf/LargeBrickShelf.svelte
new file mode 100644
index 0000000..eea1044
--- /dev/null
+++ b/src/components/jet/shelf/LargeBrickShelf.svelte
@@ -0,0 +1,26 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface LargeBrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isLargeBrickShelf(shelf: Shelf): shelf is LargeBrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'largeBrick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import LargeBrickItem from '~/components/jet/item/LargeBrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeBrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="LargeBrick" let:item>
+ <LargeBrickItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte
new file mode 100644
index 0000000..a0dfe9c
--- /dev/null
+++ b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type {
+ LargeHeroBreakout,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface LargeHeroBreakoutShelf extends Shelf {
+ items: LargeHeroBreakout[];
+ }
+
+ export function isLargeHeroBreakoutShelf(
+ shelf: Shelf,
+ ): shelf is LargeHeroBreakoutShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'largeHeroBreakout' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import LargeHeroBreakoutItem from '~/components/jet/item/LargeHeroBreakoutItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeHeroBreakoutShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="Spotlight" let:item>
+ <LargeHeroBreakoutItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeImageLockupShelf.svelte b/src/components/jet/shelf/LargeImageLockupShelf.svelte
new file mode 100644
index 0000000..fd192fb
--- /dev/null
+++ b/src/components/jet/shelf/LargeImageLockupShelf.svelte
@@ -0,0 +1,30 @@
+<script lang="ts" context="module">
+ import type { Shelf, ImageLockup } from '@jet-app/app-store/api/models';
+
+ interface LargeImageLockupShelf extends Shelf {
+ items: ImageLockup[];
+ }
+
+ export function isLargeImageLockupShelf(
+ shelf: Shelf,
+ ): shelf is LargeImageLockupShelf {
+ return (
+ shelf.contentType === 'largeImageLockup' &&
+ Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import LargeImageLockupItem from '~/components/jet/item/LargeImageLockupItem.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeImageLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <LargeImageLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeLockupShelf.svelte b/src/components/jet/shelf/LargeLockupShelf.svelte
new file mode 100644
index 0000000..dedd1fe
--- /dev/null
+++ b/src/components/jet/shelf/LargeLockupShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type { Lockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface LargeLockupShelf extends Shelf {
+ items: Lockup[];
+ }
+
+ export function isLargeLockupShelf(
+ shelf: Shelf,
+ ): shelf is LargeLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'largeLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import LargeLockupItem from '~/components/jet/item/LargeLockupItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="LargeLockup" let:item>
+ <LargeLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeStoryCardShelf.svelte b/src/components/jet/shelf/LargeStoryCardShelf.svelte
new file mode 100644
index 0000000..c1a1e57
--- /dev/null
+++ b/src/components/jet/shelf/LargeStoryCardShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type { Shelf, TodayCard } from '@jet-app/app-store/api/models';
+
+ interface LargeStoryCardShelf extends Shelf {
+ items: TodayCard[];
+ }
+
+ export function isLargeStoryCardShelf(
+ shelf: Shelf,
+ ): shelf is LargeStoryCardShelf {
+ return (
+ shelf.contentType === 'largeStoryCard' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import HeroCarousel from '~/components/hero/Carousel.svelte';
+ import LargeStoryCardItem from '~/components/jet/item/LargeStoryCardItem.svelte';
+
+ export let shelf: LargeStoryCardShelf;
+
+ $: items = shelf.items;
+
+ function deriveBackgroundArtworkFromItem(item: TodayCard) {
+ return item.heroMedia?.artworks[0];
+ }
+</script>
+
+<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item>
+ <LargeStoryCardItem {item} />
+</HeroCarousel>
diff --git a/src/components/jet/shelf/LinkableTextShelf.svelte b/src/components/jet/shelf/LinkableTextShelf.svelte
new file mode 100644
index 0000000..dcfde36
--- /dev/null
+++ b/src/components/jet/shelf/LinkableTextShelf.svelte
@@ -0,0 +1,43 @@
+<script lang="ts" context="module">
+ import type { Shelf, LinkableText } from '@jet-app/app-store/api/models';
+
+ interface LinkableTextShelf extends Shelf {
+ contentType: 'linkableText';
+ items: [LinkableText];
+ }
+
+ export function isLinkableTextShelf(
+ shelf: Shelf,
+ ): shelf is LinkableTextShelf {
+ return (
+ shelf.contentType === 'linkableText' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ export let shelf: LinkableTextShelf;
+</script>
+
+<ShelfWrapper centered withPaddingTop={true}>
+ <div class="banner">
+ <LinkableTextItem item={shelf.items[0]} />
+ </div>
+</ShelfWrapper>
+
+<style>
+ .banner {
+ background: rgba(var(--keyColor-rgb), 0.07);
+ padding: 8px 16px;
+ text-align: center;
+ border-radius: var(--global-border-radius-small);
+ }
+
+ .banner :global(a) {
+ color: var(--keyColor);
+ }
+</style>
diff --git a/src/components/jet/shelf/MarkerShelf.svelte b/src/components/jet/shelf/MarkerShelf.svelte
new file mode 100644
index 0000000..c719235
--- /dev/null
+++ b/src/components/jet/shelf/MarkerShelf.svelte
@@ -0,0 +1,36 @@
+<script lang="ts" context="module">
+ import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models';
+ import type {
+ Lockup,
+ Shelf,
+ ShelfMarker,
+ } from '@jet-app/app-store/api/models';
+
+ export interface MarkerShelf extends Shelf {
+ contentType: 'marker';
+ marker: ShelfMarker;
+ items: Lockup[];
+ }
+
+ export function isMarkerShelf(shelf: Shelf): shelf is MarkerShelf {
+ const { contentType, marker, items } = shelf;
+
+ return (
+ contentType === 'marker' &&
+ typeof marker === 'string' &&
+ Array.isArray(items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ProductTopLockup from '~/components/jet/marker-shelf/ProductTopLockup.svelte';
+
+ export let shelf: MarkerShelf;
+
+ export let page: ShelfBasedProductPage;
+</script>
+
+{#if shelf.marker === 'productTopLockup'}
+ <ProductTopLockup {page} />
+{/if}
diff --git a/src/components/jet/shelf/MediumImageLockupShelf.svelte b/src/components/jet/shelf/MediumImageLockupShelf.svelte
new file mode 100644
index 0000000..f7b1316
--- /dev/null
+++ b/src/components/jet/shelf/MediumImageLockupShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type { ImageLockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface MediumImageLockupShelf extends Shelf {
+ items: ImageLockup[];
+ }
+
+ export function isMediumImageLockupShelf(
+ shelf: Shelf,
+ ): shelf is MediumImageLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'mediumImageLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import MediumImageLockupItem from '~/components/jet/item/MediumImageLockupItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: MediumImageLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <MediumImageLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/MediumLockupShelf.svelte b/src/components/jet/shelf/MediumLockupShelf.svelte
new file mode 100644
index 0000000..186acb2
--- /dev/null
+++ b/src/components/jet/shelf/MediumLockupShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Lockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface MediumLockupShelf extends Shelf {
+ items: Lockup[];
+ }
+
+ export function isMediumLockupShelf(
+ shelf: Shelf,
+ ): shelf is MediumLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'mediumLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import MediumLockupItem from '~/components/jet/item/MediumLockupItem.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: MediumLockupShelf;
+
+ $: isArticleContext = shelf.presentationHints?.isArticleContext;
+ $: gridType = isArticleContext ? 'Spotlight' : 'MediumLockup';
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} {gridType} rowsPerColumnOverride={2} let:item>
+ <MediumLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/MediumStoryCardShelf.svelte b/src/components/jet/shelf/MediumStoryCardShelf.svelte
new file mode 100644
index 0000000..35c3ec3
--- /dev/null
+++ b/src/components/jet/shelf/MediumStoryCardShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+
+ import MediumStoryCardItem, {
+ type Item as MediumStoryCardItemModel,
+ } from '~/components/jet/item/MediumStoryCardItem.svelte';
+
+ interface MediumStoryCardShelf extends Shelf {
+ items: MediumStoryCardItemModel[];
+ }
+
+ export function isMediumStoryCardShelf(
+ shelf: Shelf,
+ ): shelf is MediumStoryCardShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'mediumStoryCard' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: MediumStoryCardShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <MediumStoryCardItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/PageHeaderShelf.svelte b/src/components/jet/shelf/PageHeaderShelf.svelte
new file mode 100644
index 0000000..59c99b2
--- /dev/null
+++ b/src/components/jet/shelf/PageHeaderShelf.svelte
@@ -0,0 +1,34 @@
+<script lang="ts" context="module">
+ import type { PageHeader, Shelf } from '@jet-app/app-store/api/models';
+
+ interface PageHeaderShelf extends Shelf {
+ items: [PageHeader];
+ }
+
+ export function isPageHeaderShelf(shelf: Shelf): shelf is PageHeaderShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'pageHeader' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+
+ export let shelf: PageHeaderShelf;
+
+ $: [item] = shelf.items;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div class="shelf-title-wrapper" slot="title">
+ <ShelfTitle title={item.title} subtitle={item.subtitle} />
+ </div>
+</ShelfWrapper>
+
+<style>
+ .shelf-title-wrapper {
+ --shelf-title-font: var(--title-1-emphasized);
+ display: contents;
+ }
+</style>
diff --git a/src/components/jet/shelf/ParagraphShelf.svelte b/src/components/jet/shelf/ParagraphShelf.svelte
new file mode 100644
index 0000000..777338e
--- /dev/null
+++ b/src/components/jet/shelf/ParagraphShelf.svelte
@@ -0,0 +1,52 @@
+<script lang="ts" context="module">
+ import type { Paragraph, Shelf } from '@jet-app/app-store/api/models';
+
+ interface ParagraphShelf extends Shelf {
+ contentType: 'paragraph';
+ items: Paragraph[];
+ }
+
+ export function isParagraphShelf(shelf: Shelf): shelf is ParagraphShelf {
+ return shelf.contentType === 'paragraph' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ParagraphShelfItem from '~/components/jet/item/ParagraphShelfItem.svelte';
+
+ export let shelf: ParagraphShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ <h2>{shelf.title}</h2>
+ {/if}
+ </div>
+
+ <div class="content-container">
+ {#each shelf.items as item}
+ <ParagraphShelfItem {item} />
+ {/each}
+ </div>
+</ShelfWrapper>
+
+<style>
+ h2 {
+ color: var(--systemPrimary);
+ font: var(--title-2-emphasized);
+ text-wrap: pretty;
+ margin: 16px 0;
+ }
+
+ .title-container,
+ .content-container {
+ margin: 0 var(--bodyGutter);
+ }
+
+ /* Whenever this shelf is nested in a modal, we don't want to add extra margin since the modal provides its own */
+ :global(.modal-content) .content-container {
+ margin: unset;
+ }
+</style>
diff --git a/src/components/jet/shelf/PosterLockupShelf.svelte b/src/components/jet/shelf/PosterLockupShelf.svelte
new file mode 100644
index 0000000..101c1d6
--- /dev/null
+++ b/src/components/jet/shelf/PosterLockupShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Shelf, PosterLockup } from '@jet-app/app-store/api/models';
+
+ interface PosterLockupShelf extends Shelf {
+ items: PosterLockup[];
+ }
+
+ export function isPosterLockupShelf(
+ shelf: Shelf,
+ ): shelf is PosterLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'posterLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import mediaQueries from '~/utils/media-queries';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import PosterLockupItem from '~/components/jet/item/PosterLockupItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: PosterLockupShelf;
+
+ $: gridType = $mediaQueries === 'xsmall' ? 'Spotlight' : 'PosterLockup';
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} {gridType} let:item>
+ <PosterLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/PrivacyFooterShelf.svelte b/src/components/jet/shelf/PrivacyFooterShelf.svelte
new file mode 100644
index 0000000..dccade6
--- /dev/null
+++ b/src/components/jet/shelf/PrivacyFooterShelf.svelte
@@ -0,0 +1,40 @@
+<script lang="ts" context="module">
+ import type { PrivacyFooter, Shelf } from '@jet-app/app-store/api/models';
+
+ interface PrivacyFooterShelf extends Shelf {
+ items: [PrivacyFooter];
+ }
+
+ export function isPrivacyFooterShelf(
+ shelf: Shelf,
+ ): shelf is PrivacyFooterShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'privacyFooter' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: PrivacyFooterShelf;
+
+ $: bodyText = shelf.items[0].bodyText;
+</script>
+
+<ShelfWrapper {shelf} centered>
+ <p>
+ <LinkableTextItem item={bodyText} />
+ </p>
+</ShelfWrapper>
+
+<style>
+ p {
+ font: var(--body-tall);
+ }
+
+ p :global(a) {
+ color: var(--keyColor);
+ }
+</style>
diff --git a/src/components/jet/shelf/PrivacyHeaderShelf.svelte b/src/components/jet/shelf/PrivacyHeaderShelf.svelte
new file mode 100644
index 0000000..5ace666
--- /dev/null
+++ b/src/components/jet/shelf/PrivacyHeaderShelf.svelte
@@ -0,0 +1,145 @@
+<script lang="ts" context="module">
+ import {
+ type Action,
+ type FlowAction,
+ type GenericPage,
+ type PrivacyHeader,
+ type Shelf,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ import {
+ isPrivacyTypeShelf,
+ type PrivacyTypeShelf,
+ } from '~/components/jet/shelf/PrivacyTypeShelf.svelte';
+
+ interface PrivacyHeaderShelf extends Shelf {
+ items: [PrivacyHeader];
+ }
+
+ interface PrivacyDetailPage extends GenericPage {
+ shelves: (PrivacyTypeShelf | PrivacyHeaderShelf)[];
+ }
+
+ interface PrivacyDetailPageFlowAction extends FlowAction {
+ page: 'privacyDetail';
+ pageData: PrivacyDetailPage;
+ }
+
+ export function isPrivacyHeaderShelf(
+ shelf: Shelf,
+ ): shelf is PrivacyHeaderShelf {
+ let { contentType, items } = shelf;
+ return contentType === 'privacyHeader' && Array.isArray(items);
+ }
+
+ function isPrivacyDetailFlowAction(
+ action: Action,
+ ): action is PrivacyDetailPageFlowAction {
+ return isFlowAction(action) && action.page === 'privacyDetail';
+ }
+</script>
+
+<script lang="ts">
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import PrivacyHeaderItem from '~/components/jet/item/PrivacyHeaderItem.svelte';
+ import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { APP_PRIVACY_MODAL_ID } from '~/utils/metrics';
+
+ export let shelf: PrivacyHeaderShelf;
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const { seeAllAction } = shelf;
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+ const handleModalClose = () => modalComponent?.close();
+ const handleOpenModalClick = (e: Event) => {
+ modalTriggerElement = e.target as HTMLElement;
+ modalComponent?.showModal();
+ };
+
+ const destination =
+ seeAllAction && isPrivacyDetailFlowAction(seeAllAction)
+ ? seeAllAction
+ : undefined;
+ const pageData = destination?.pageData;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ <button on:click={handleOpenModalClick}>
+ <ShelfTitle title={shelf.title} seeAllAction={destination} />
+ </button>
+ {/if}
+
+ {#if pageData}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ {translateFn}
+ on:close={handleModalClose}
+ title={pageData.title || null}
+ subtitle={null}
+ targetId={APP_PRIVACY_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <ul class="modal-content-container">
+ {#each pageData.shelves as shelf}
+ {#if isPrivacyHeaderShelf(shelf)}
+ {#each shelf.items as item}
+ <PrivacyHeaderItem {item} />
+ {/each}
+ {/if}
+
+ {#if isPrivacyTypeShelf(shelf)}
+ {#each shelf.items as item}
+ <PrivacyTypeItem
+ {item}
+ isDetailView={true}
+ />
+ {/each}
+ {/if}
+ {/each}
+ </ul>
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+ {/if}
+ </div>
+
+ <div class="header-container">
+ <div>
+ <PrivacyHeaderItem item={shelf.items[0]} />
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style>
+ .title-container {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 16px;
+ padding-inline-end: var(--bodyGutter);
+ }
+
+ .header-container {
+ margin: 0 var(--bodyGutter);
+ }
+
+ .header-container div {
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ .modal-content-container {
+ font: var(--body-tall);
+ white-space: normal;
+ }
+</style>
diff --git a/src/components/jet/shelf/PrivacyTypeShelf.svelte b/src/components/jet/shelf/PrivacyTypeShelf.svelte
new file mode 100644
index 0000000..3817251
--- /dev/null
+++ b/src/components/jet/shelf/PrivacyTypeShelf.svelte
@@ -0,0 +1,29 @@
+<script lang="ts" context="module">
+ import type { PrivacyType, Shelf } from '@jet-app/app-store/api/models';
+
+ export interface PrivacyTypeShelf extends Shelf {
+ items: PrivacyType[];
+ }
+
+ export function isPrivacyTypeShelf(
+ shelf: Shelf,
+ ): shelf is PrivacyTypeShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'privacyType' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte';
+
+ export let shelf: PrivacyTypeShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <PrivacyTypeItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ProductBadgeShelf.svelte b/src/components/jet/shelf/ProductBadgeShelf.svelte
new file mode 100644
index 0000000..cded0b7
--- /dev/null
+++ b/src/components/jet/shelf/ProductBadgeShelf.svelte
@@ -0,0 +1,59 @@
+<script lang="ts" context="module">
+ import type { Badge, Shelf } from '@jet-app/app-store/api/models';
+
+ interface ProductBadgeShelf extends Shelf {
+ items: Badge[];
+ }
+
+ export function isProductBadgeShelf(
+ shelf: Shelf,
+ ): shelf is ProductBadgeShelf {
+ const { contentType, items } = shelf || {};
+ return contentType === 'productBadge' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductBadgeItem from '~/components/jet/item/ProductBadgeItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductBadgeShelf;
+
+ $: shelf.items = shelf.items.filter(
+ (item) => item.type !== 'friendsPlaying',
+ );
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false} withTopMargin={true}>
+ <div class="inforibbon-shelf-wrapper">
+ <ShelfItemLayout {shelf} gridType="ProductBadge" let:item>
+ <ProductBadgeItem {item} />
+ </ShelfItemLayout>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ .inforibbon-shelf-wrapper {
+ padding-bottom: 16px;
+ }
+
+ .inforibbon-shelf-wrapper :global(ul) {
+ display: grid;
+
+ /*
+ Here we are overriding the grid template styles from `ShelfItemLayout -> Grid`,
+ to make it so the badge row always takes up the full-width of the browser until
+ when not in the XS/mobile view.
+ */
+ @media (--range-small-up) {
+ display: flex;
+ justify-content: space-between;
+ }
+ }
+
+ // prevent collapse of focus outlines
+ .inforibbon-shelf-wrapper :global(a) {
+ display: block;
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductCapabilityShelf.svelte b/src/components/jet/shelf/ProductCapabilityShelf.svelte
new file mode 100644
index 0000000..6a4307a
--- /dev/null
+++ b/src/components/jet/shelf/ProductCapabilityShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ProductCapability,
+ } from '@jet-app/app-store/api/models';
+
+ interface ProductCapabilityShelf extends Shelf {
+ items: ProductCapability[];
+ }
+
+ export function isProductCapabilityShelf(
+ shelf: Shelf,
+ ): shelf is ProductCapabilityShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'productCapability' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductCapabilityItem from '../item/ProductCapabilityItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductCapabilityShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="SearchLink" let:item>
+ <ProductCapabilityItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ProductDescriptionShelf.svelte b/src/components/jet/shelf/ProductDescriptionShelf.svelte
new file mode 100644
index 0000000..7cddcee
--- /dev/null
+++ b/src/components/jet/shelf/ProductDescriptionShelf.svelte
@@ -0,0 +1,95 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ProductDescription,
+ } from '@jet-app/app-store/api/models';
+
+ interface ProductDescriptionShelf extends Shelf {
+ items: [ProductDescription];
+ }
+
+ export function isProductDescriptionShelf(
+ shelf: Shelf,
+ ): shelf is ProductDescriptionShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'productDescription' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let shelf: ProductDescriptionShelf;
+
+ const i18n = getI18n();
+ const description = shelf.items[0]?.paragraph.text;
+ const handleMoreClick = () => (isOpen = true);
+ let isOpen = false;
+
+ function handleLineClampResize(event: CustomEvent) {
+ if (!event.detail.truncated) {
+ isOpen = true;
+ }
+ }
+</script>
+
+<ShelfWrapper centered>
+ <article>
+ <p>
+ {#if isOpen}
+ {@html sanitizeHtml(description)}
+ {:else}
+ <LineClamp observe clamp={5} on:resize={handleLineClampResize}>
+ {@html sanitizeHtml(description)}
+ </LineClamp>
+ {/if}
+
+ {#if !isOpen}
+ <button on:click={handleMoreClick}>
+ {$i18n.t('ASE.Web.AppStore.More')}
+ </button>
+ {/if}
+ </p>
+ </article>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ p {
+ white-space: break-spaces;
+ font: var(--body-tall);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ button {
+ --gradient-direction: 270deg;
+ display: flex;
+ justify-content: end;
+ position: absolute;
+ bottom: 0;
+ inset-inline-end: 0;
+ padding-inline-start: 20px;
+ color: var(--keyColor);
+ background: linear-gradient(
+ var(--gradient-direction),
+ var(--pageBg) 72%,
+ transparent 100%
+ );
+
+ @include rtl {
+ --gradient-direction: 90deg;
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductMediaShelf.svelte b/src/components/jet/shelf/ProductMediaShelf.svelte
new file mode 100644
index 0000000..f57fee7
--- /dev/null
+++ b/src/components/jet/shelf/ProductMediaShelf.svelte
@@ -0,0 +1,269 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ProductMedia,
+ AppPlatform,
+ MediaType,
+ MediaPlatform,
+ } from '@jet-app/app-store/api/models';
+
+ interface ProductMediaShelf extends Shelf, ProductMedia {
+ items: ProductMedia['items'];
+ expandedMedia?: ProductMediaShelf[];
+ }
+
+ export function isProductMediaShelf(
+ shelf: Shelf,
+ ): shelf is ProductMediaShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'productMediaItem' && Array.isArray(items);
+ }
+
+ const platformToIconNameMap: Record<AppPlatform, string> = {
+ phone: 'iphone.gen2',
+ pad: 'ipad.gen2',
+ tv: 'tv',
+ watch: 'applewatch',
+ mac: 'macbook.gen2',
+ messages: 'message',
+ vision: 'visionpro',
+ };
+
+ const platformToDescriptionMap: Record<AppPlatform, string> = {
+ phone: 'AppStore.AppPlatform.Phone',
+ pad: 'AppStore.AppPlatform.Pad',
+ tv: 'AppStore.AppPlatform.TV',
+ watch: 'AppStore.AppPlatform.Watch',
+ mac: 'AppStore.AppPlatform.Mac',
+ messages: 'AppStore.AppPlatform.Messages',
+ vision: 'AppStore.AppPlatform.Vision',
+ };
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductMediaVisionItem from '~/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte';
+ import ProductMediaPhoneItem from '~/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte';
+ import ProductMediaMacItem from '~/components/jet/item/ProductMedia/ProductMediaMacItem.svelte';
+ import ProductMediaPadItem from '~/components/jet/item/ProductMedia/ProductMediaPadItem.svelte';
+ import ProductMediaWatchItem from '~/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte';
+ import ProductMediaTVItem from '~/components/jet/item/ProductMedia/ProductMediaTVItem.svelte';
+ import ChevronDown from '~/sf-symbols/chevron.down.svg';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { slide } from 'svelte/transition';
+ import { getJet } from '~/jet';
+
+ export let shelf: ProductMediaShelf;
+ export let isExpandedMedia: boolean = false;
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ let appPlatform: AppPlatform | undefined;
+ let allPlatforms: MediaPlatform[] | undefined;
+ let mediaType: MediaType | undefined;
+ let hasPortraitMedia: boolean = false;
+ let shouldDisplayExpandedMedia: boolean = false;
+
+ $: {
+ if (shelf.contentsMetadata.type === 'productMedia') {
+ ({ hasPortraitMedia, allPlatforms } = shelf.contentsMetadata);
+ ({ appPlatform, mediaType } = shelf.contentsMetadata.platform);
+ }
+ }
+
+ $: allPlatformsDescription = allPlatforms
+ ?.map(({ appPlatform }) =>
+ $i18n.t(platformToDescriptionMap[appPlatform]),
+ )
+ ?.join($i18n.t('AppStore.AppPlatform.Component.Separator'));
+
+ $: shouldShowPlatform =
+ isExpandedMedia ||
+ shouldDisplayExpandedMedia ||
+ allPlatforms?.length === 1;
+
+ const displayExpandedMedia = () => {
+ shouldDisplayExpandedMedia = true;
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ actionDetails: { type: 'platformSelect' },
+ targetType: 'button',
+ targetId: 'productMediaShelf',
+ });
+ };
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={!shelf.expandedMedia}>
+ {#if appPlatform === 'vision'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotVision" let:item>
+ <ProductMediaVisionItem {item} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'phone' || appPlatform === 'messages'}
+ <ShelfItemLayout
+ {shelf}
+ gridType={hasPortraitMedia ? 'ScreenshotPhone' : 'ScreenshotLarge'}
+ let:item
+ >
+ <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'pad'}
+ <ShelfItemLayout
+ {shelf}
+ gridType={hasPortraitMedia ? 'ScreenshotPad' : 'ScreenshotLarge'}
+ let:item
+ >
+ <ProductMediaPadItem {item} {hasPortraitMedia} {mediaType} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'mac'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item>
+ <ProductMediaMacItem {item} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'tv'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item>
+ <ProductMediaTVItem {item} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'watch'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotPhone" let:item>
+ <ProductMediaWatchItem {item} {mediaType} />
+ </ShelfItemLayout>
+ {:else}
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} />
+ </ShelfItemLayout>
+ {/if}
+
+ {#if appPlatform && shouldShowPlatform}
+ <div class="platform-description">
+ <div class="icon" aria-hidden="true">
+ <SFSymbol name={platformToIconNameMap[appPlatform]} />
+ </div>
+ <div class="platform-label">
+ {$i18n.t(platformToDescriptionMap[appPlatform])}
+ </div>
+ </div>
+ {/if}
+</ShelfWrapper>
+
+{#if shelf.expandedMedia && allPlatforms && allPlatforms.length > 1}
+ <div class="expanded-media">
+ {#if !shouldDisplayExpandedMedia}
+ <button
+ class="expanded-media-header"
+ on:click={displayExpandedMedia}
+ >
+ <div class="all-platforms">
+ <div class="all-platforms-icons">
+ {#each allPlatforms as platform}
+ <div class="icon" aria-hidden="true">
+ <SFSymbol
+ name={platformToIconNameMap[
+ platform.appPlatform
+ ]}
+ />
+ </div>
+ {/each}
+ </div>
+ <div class="all-platforms-names">
+ {allPlatformsDescription}
+ </div>
+ </div>
+ <div class="chevron-container icon" aria-hidden="true">
+ <ChevronDown />
+ </div>
+ </button>
+ {/if}
+ {#if shouldDisplayExpandedMedia}
+ <div class="expanded-media-content" transition:slide>
+ {#each shelf.expandedMedia as expandedMediaShelf}
+ <svelte:self
+ shelf={expandedMediaShelf}
+ isExpandedMedia={true}
+ />
+ {/each}
+ </div>
+ {/if}
+ </div>
+{/if}
+
+{#if !isExpandedMedia}
+ <div class="divider" />
+{/if}
+
+<style>
+ .expanded-media {
+ margin: 15px 0;
+ }
+
+ .expanded-media-header {
+ width: 100%;
+ padding-inline: var(--bodyGutter);
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .platform-description {
+ display: inline-flex;
+ align-items: center;
+ font: var(--body-reduced-semibold);
+ color: var(--systemSecondary);
+ margin-top: 15px;
+ gap: 10px;
+ margin-inline: var(--bodyGutter);
+ }
+
+ .all-platforms {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ color: var(--systemSecondary);
+ }
+
+ .all-platforms-icons {
+ display: inline-flex;
+ gap: 10px;
+ }
+
+ .all-platforms-names {
+ font: var(--body-reduced-semibold);
+ }
+
+ .icon :global(svg) {
+ overflow: visible;
+ height: 16px;
+ max-width: 25px;
+ fill: var(--systemSecondary);
+ position: relative;
+ display: flex;
+ }
+
+ .divider {
+ margin: 10px var(--bodyGutter);
+ border-bottom: 1px solid var(--systemGray4);
+ }
+
+ .chevron-container {
+ top: 2px;
+ }
+
+ .expanded-media-content :global(.shelf:last-of-type) {
+ padding-bottom: 0;
+ }
+
+ .expanded-media-header .all-platforms,
+ .expanded-media-header .chevron-container :global(svg) {
+ transition-duration: 210ms;
+ transition-timing-function: ease-out;
+ transition-property: color, fill;
+ }
+
+ .expanded-media-header:hover .all-platforms,
+ .expanded-media-header:hover .chevron-container :global(svg) {
+ color: var(--systemPrimary);
+ fill: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductPageLinkShelf.svelte b/src/components/jet/shelf/ProductPageLinkShelf.svelte
new file mode 100644
index 0000000..7b41e80
--- /dev/null
+++ b/src/components/jet/shelf/ProductPageLinkShelf.svelte
@@ -0,0 +1,59 @@
+<script lang="ts" context="module">
+ import type { Shelf, ProductPageLink } from '@jet-app/app-store/api/models';
+
+ interface ProductPageLinkShelf extends Shelf {
+ items: ProductPageLink[];
+ }
+
+ export function isProductPageLinkShelf(
+ shelf: Shelf,
+ ): shelf is ProductPageLinkShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'productPageLink' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ProductPageLinkItem from '~/components/jet/item/ProductPageLinkItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductPageLinkShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <div class="product-page-link-shelf">
+ {#each shelf.items as item}
+ <li class="product-page-link-item">
+ <ProductPageLinkItem {item} />
+ </li>
+ {/each}
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ $product-page-link-border: 1px solid var(--systemGray4);
+
+ .product-page-link-shelf {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ column-gap: 20px;
+
+ @media (--range-xsmall-down) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ }
+
+ @media (--range-xsmall-down) {
+ .product-page-link-item:first-child {
+ border-top: $product-page-link-border;
+ }
+
+ .product-page-link-item {
+ width: 100%;
+ border-bottom: $product-page-link-border;
+ padding: 0 var(--bodyGutter);
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductRatingsShelf.svelte b/src/components/jet/shelf/ProductRatingsShelf.svelte
new file mode 100644
index 0000000..8f09ab5
--- /dev/null
+++ b/src/components/jet/shelf/ProductRatingsShelf.svelte
@@ -0,0 +1,29 @@
+<script lang="ts" context="module">
+ import { type Ratings, type Shelf } from '@jet-app/app-store/api/models';
+
+ interface ProductRatingsShelf extends Shelf {
+ items: Ratings[];
+ }
+
+ export function isProductRatingsShelf(
+ shelf: Shelf,
+ ): shelf is ProductRatingsShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'productRatings' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductRatingsItem from '~/components/jet/item/ProductRatingsItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductRatingsShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <ProductRatingsItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ProductReviewShelf.svelte b/src/components/jet/shelf/ProductReviewShelf.svelte
new file mode 100644
index 0000000..6bc4ecb
--- /dev/null
+++ b/src/components/jet/shelf/ProductReviewShelf.svelte
@@ -0,0 +1,38 @@
+<script lang="ts" context="module">
+ import type { ProductReview, Shelf } from '@jet-app/app-store/api/models';
+
+ interface ProductReviewShelf extends Shelf {
+ items: ProductReview[];
+ }
+
+ export function isProductReviewShelf(
+ shelf: Shelf,
+ ): shelf is ProductReviewShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'productReview' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import EditorsChoiceReviewItem, {
+ isEditorsChoiceReviewItem,
+ } from '~/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte';
+ import UserReviewItem, {
+ isUserReviewItem,
+ } from '~/components/jet/item/ProductReview/UserReviewItem.svelte';
+
+ export let shelf: ProductReviewShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ {#if isUserReviewItem(item)}
+ <UserReviewItem {item} />
+ {:else if isEditorsChoiceReviewItem(item)}
+ <EditorsChoiceReviewItem {item} />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/QuoteShelf.svelte b/src/components/jet/shelf/QuoteShelf.svelte
new file mode 100644
index 0000000..3a14f4f
--- /dev/null
+++ b/src/components/jet/shelf/QuoteShelf.svelte
@@ -0,0 +1,80 @@
+<script lang="ts" context="module">
+ import type { Quote, Shelf } from '@jet-app/app-store/api/models';
+
+ interface QuoteShelf extends Shelf {
+ contentType: 'quote';
+ items: [Quote];
+ }
+
+ export function isQuoteShelf(shelf: Shelf): shelf is QuoteShelf {
+ return shelf.contentType === 'quote' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: QuoteShelf;
+
+ $: item = shelf.items[0];
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div class="outer">
+ <div class="inner">
+ <blockquote>
+ {item.text}
+ </blockquote>
+ <span>{item.credit}</span>
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .outer {
+ display: flex;
+ margin-bottom: 24px;
+ padding: 0 var(--bodyGutter);
+ gap: 6px;
+ }
+
+ .outer::before {
+ content: '❝';
+ font-size: 40px;
+ line-height: 2.2rem;
+ color: var(--systemSecondary);
+
+ @include rtl {
+ content: '❞';
+ }
+ }
+
+ .inner {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ blockquote {
+ font: var(--large-title-emphasized);
+ text-wrap: pretty;
+ }
+
+ blockquote::after {
+ content: '❞';
+ color: var(--systemSecondary);
+
+ @include rtl {
+ content: '❝';
+ }
+ }
+
+ span {
+ font: var(--title-3);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/shelf/ReviewsContainerShelf.svelte b/src/components/jet/shelf/ReviewsContainerShelf.svelte
new file mode 100644
index 0000000..a55fe40
--- /dev/null
+++ b/src/components/jet/shelf/ReviewsContainerShelf.svelte
@@ -0,0 +1,84 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ReviewsContainer,
+ } from '@jet-app/app-store/api/models';
+
+ export interface ReviewsContainerShelf extends Shelf {
+ items: [ReviewsContainer];
+ }
+
+ export function isReviewsContainerShelf(
+ shelf: Shelf,
+ ): shelf is ReviewsContainerShelf {
+ return (
+ shelf.contentType === 'reviewsContainer' &&
+ Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte';
+
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import Grid from '~/components/Grid.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getJet } from '~/jet/svelte';
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ export let shelf: ReviewsContainerShelf;
+
+ $: reviewsContainer = shelf.items[0];
+ $: ({ productAction, ratings } = reviewsContainer);
+
+ $: numberOfRatings = jet.localization.formattedCount(
+ ratings.totalNumberOfRatings,
+ );
+</script>
+
+<ShelfWrapper {shelf}>
+ <header slot="title">
+ {#if productAction}
+ <div class="product-action">
+ <LinkWrapper action={productAction}>
+ {productAction.title}
+ </LinkWrapper>
+ </div>
+ {/if}
+
+ <ShelfTitle title={shelf.title ?? ''} />
+
+ <Grid gridType="A" items={[1]}>
+ <div class="rating">
+ <RatingComponent
+ averageRating={ratings.ratingAverage}
+ ratingCount={ratings.totalNumberOfRatings}
+ ratingCountText={$i18n.t(
+ 'ASE.Web.AppStore.Ratings.CountText',
+ {
+ numberOfRatings,
+ },
+ )}
+ ratingCountsList={ratings.ratingCounts}
+ totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')}
+ />
+ </div>
+ </Grid>
+ </header>
+</ShelfWrapper>
+
+<style>
+ .product-action {
+ --linkColor: var(--keyColor);
+ margin: 0 var(--bodyGutter) 6px;
+ }
+
+ .rating {
+ --ratingBarColor: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/shelf/ReviewsShelf.svelte b/src/components/jet/shelf/ReviewsShelf.svelte
new file mode 100644
index 0000000..8304444
--- /dev/null
+++ b/src/components/jet/shelf/ReviewsShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ Review as ReviewModel,
+ } from '@jet-app/app-store/api/models';
+
+ export interface ReviewsShelf extends Shelf {
+ items: ReviewModel[];
+ }
+
+ export function isReviewsShelf(shelf: Shelf): shelf is ReviewsShelf {
+ return shelf.contentType === 'reviews' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ReviewItem from '~/components/jet/item/ReviewItem.svelte';
+
+ export let shelf: ReviewsShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <ReviewItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/RibbonBarShelf.svelte b/src/components/jet/shelf/RibbonBarShelf.svelte
new file mode 100644
index 0000000..44a8ae9
--- /dev/null
+++ b/src/components/jet/shelf/RibbonBarShelf.svelte
@@ -0,0 +1,135 @@
+<script lang="ts" context="module">
+ import type { Shelf, RibbonBarItem } from '@jet-app/app-store/api/models';
+
+ interface RibbonBarShelf extends Shelf {
+ items: RibbonBarItem[];
+ }
+
+ export function isRibbonBarShelf(shelf: Shelf): shelf is RibbonBarShelf {
+ return shelf.contentType === 'ribbonBar' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let shelf: RibbonBarShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false} withPaddingTop={false}>
+ <div class="scroll">
+ <ul>
+ {#each shelf.items as ribbonBarItem}
+ {@const action = ribbonBarItem.clickAction}
+ {@const artwork = ribbonBarItem.artwork}
+ {@const title = ribbonBarItem.title}
+ <li>
+ <LinkWrapper {action}>
+ {#if artwork}
+ <div
+ class="artwork-container"
+ style:--aspect-ratio={artwork.width /
+ artwork.height}
+ >
+ {#if isSystemImageArtwork(artwork)}
+ <SystemImage {artwork} />
+ {:else}
+ <Artwork
+ {artwork}
+ profile={getNaturalProfile(artwork, [
+ 17,
+ ])}
+ hasTransparentBackground
+ />
+ {/if}
+ </div>
+ {/if}
+ {title}
+ </LinkWrapper>
+ </li>
+ {/each}
+ </ul>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .scroll {
+ --gradient-direction: 90deg;
+ overflow-x: auto;
+ scrollbar-width: none;
+ padding-inline-start: var(--bodyGutter);
+ margin-inline-end: var(--bodyGutter);
+ // A small gradient that fades out the ribbon, to indicate that there is more
+ mask-image: linear-gradient(
+ var(--gradient-direction),
+ black calc(100% - 8px),
+ transparent 100%
+ );
+
+ @include rtl {
+ --gradient-direction: -90deg;
+ }
+ }
+
+ ul {
+ font: var(--body-emphasized);
+ display: flex;
+ gap: 4px;
+ padding-bottom: 16px;
+ padding-top: 13px;
+ }
+
+ li {
+ display: flex;
+ margin-inline-end: 8px;
+ flex-shrink: 0;
+ }
+
+ li:last-of-type {
+ padding-inline-end: 8px;
+ }
+
+ li :global(a) {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ background: var(--pageBG);
+ border-radius: var(--global-border-radius-small);
+ padding: 6px 10px;
+
+ &::after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: var(--global-border-radius-small);
+ box-shadow: var(--shadow-small);
+ z-index: calc(var(--z-default) - 1);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemGray5-default_IC);
+ }
+ }
+
+ .artwork-container {
+ --artwork-override-height: 17px;
+ flex-shrink: 0;
+ aspect-ratio: var(--aspect-ratio);
+ height: 17px;
+ }
+</style>
diff --git a/src/components/jet/shelf/SearchLinkShelf.svelte b/src/components/jet/shelf/SearchLinkShelf.svelte
new file mode 100644
index 0000000..6b29780
--- /dev/null
+++ b/src/components/jet/shelf/SearchLinkShelf.svelte
@@ -0,0 +1,26 @@
+<script lang="ts" context="module">
+ import type { Shelf, SearchLink } from '@jet-app/app-store/api/models';
+
+ interface SearchLinkShelf extends Shelf {
+ items: SearchLink[];
+ }
+
+ export function isSearchLinkShelf(shelf: Shelf): shelf is SearchLinkShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'searchLink' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import SearchLinkItem from '~/components/jet/item/SearchLinkItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SearchLinkShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="SearchLink" let:item>
+ <SearchLinkItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SearchResultShelf.svelte b/src/components/jet/shelf/SearchResultShelf.svelte
new file mode 100644
index 0000000..9c15d3e
--- /dev/null
+++ b/src/components/jet/shelf/SearchResultShelf.svelte
@@ -0,0 +1,49 @@
+<script lang="ts" context="module">
+ import type {
+ AppSearchResult,
+ Shelf,
+ SearchResult,
+ AppEventSearchResult,
+ } from '@jet-app/app-store/api/models';
+
+ import AppSearchResultItem, {
+ isAppSearchResult,
+ isAppEventSearchResult,
+ } from '~/components/jet/item/SearchResult/AppSearchResultItem.svelte';
+
+ /**
+ * All sub-classes of {@linkcode SearchResult} that this component can handle rendering
+ */
+ type RenderableSearchResult = AppSearchResult | AppEventSearchResult;
+
+ interface SearchResultShelf extends Shelf {
+ items: SearchResult[];
+ }
+
+ export function isSearchResultShelf(
+ shelf: Shelf,
+ ): shelf is SearchResultShelf {
+ return (
+ shelf.contentType === 'searchResult' && Array.isArray(shelf.items)
+ );
+ }
+
+ export function isRenderableInSearchResultsShelf(
+ item: SearchResult,
+ ): item is RenderableSearchResult {
+ return isAppSearchResult(item) || isAppEventSearchResult(item);
+ }
+</script>
+
+<script lang="ts">
+ import Grid from '~/components/Grid.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SearchResultShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <Grid gridType="SearchResult" items={shelf.items} let:item>
+ <AppSearchResultItem {item} />
+ </Grid>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/Shelf.svelte b/src/components/jet/shelf/Shelf.svelte
new file mode 100644
index 0000000..6cbb0f6
--- /dev/null
+++ b/src/components/jet/shelf/Shelf.svelte
@@ -0,0 +1,320 @@
+<script lang="ts">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+
+ // Components for specific types of `Shelf`
+ import AccessibilityHeaderShelf, {
+ isAccessibilityHeaderShelf,
+ } from '~/components/jet/shelf/AccessibilityHeaderShelf.svelte';
+ import AccessibilityFeaturesShelf, {
+ isAccessibilityFeaturesShelf,
+ } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte';
+ import AccessibilityDeveloperLinkShelf, {
+ isAccessibilityDeveloperLinkShelf,
+ } from './AccessibilityDeveloperLinkShelf.svelte';
+ import ActionShelf, {
+ isActionShelf,
+ } from '~/components/jet/shelf/ActionShelf.svelte';
+ import AnnotationShelf, {
+ isAnnotationShelf,
+ } from '~/components/jet/shelf/AnnotationShelf.svelte';
+ import AppEventDetailShelf, {
+ isAppEventDetailShelf,
+ } from '~/components/jet/shelf/AppEventDetailShelf.svelte';
+ import AppPromotionShelf, {
+ isAppPromotionShelf,
+ } from '~/components/jet/shelf/AppPromotionShelf.svelte';
+ import AppShowcaseShelf, {
+ isAppShowcaseShelf,
+ } from '~/components/jet/shelf/AppShowcaseShelf.svelte';
+ import AppTrailerLockupShelf, {
+ isAppTrailerLockupShelf,
+ } from '~/components/jet/shelf/AppTrailerLockupShelf.svelte';
+ import ArcadeFooterShelf, {
+ isArcadeFooterShelf,
+ } from '~/components/jet/shelf/ArcadeFooterShelf.svelte';
+ import { isBannerShelf } from '~/components/jet/shelf/BannerShelf.svelte';
+ import BrickShelf, {
+ isBrickShelf,
+ } from '~/components/jet/shelf/BrickShelf.svelte';
+ import CategoryBrickShelf, {
+ isCategoryBrickShelf,
+ } from '~/components/jet/shelf/CategoryBrickShelf.svelte';
+ import EditorialCardShelf, {
+ isEditorialCardShelf,
+ } from '~/components/jet/shelf/EditorialCardShelf.svelte';
+ import EditorialLinkShelf, {
+ isEditorialLinkShelf,
+ } from '~/components/jet/shelf/EditorialLinkShelf.svelte';
+ import FramedArtworkShelf, {
+ isFramedArtworkShelf,
+ } from '~/components/jet/shelf/FramedArtworkShelf.svelte';
+ import FramedVideoShelf, {
+ isFramedVideoShelf,
+ } from '~/components/jet/shelf/FramedVideoShelf.svelte';
+ import HeroCarouselShelf, {
+ isHeroCarouselShelf,
+ } from '~/components/jet/shelf/HeroCarouselShelf.svelte';
+ import HorizontalRuleShelf, {
+ isHorizontalRuleShelf,
+ } from '~/components/jet/shelf/HorizontalRuleShelf.svelte';
+ import InAppPurchaseLockupShelf, {
+ isInAppPurchaseLockupShelf,
+ } from '~/components/jet/shelf/InAppPurchaseLockupShelf.svelte';
+ import LargeHeroBreakoutShelf, {
+ isLargeHeroBreakoutShelf,
+ } from '~/components/jet/shelf/LargeHeroBreakoutShelf.svelte';
+ import LargeBrickShelf, {
+ isLargeBrickShelf,
+ } from '~/components/jet/shelf/LargeBrickShelf.svelte';
+ import LargeImageLockupShelf, {
+ isLargeImageLockupShelf,
+ } from '~/components/jet/shelf/LargeImageLockupShelf.svelte';
+ import LargeLockupShelf, {
+ isLargeLockupShelf,
+ } from '~/components/jet/shelf/LargeLockupShelf.svelte';
+ import LargeStoryCardShelf, {
+ isLargeStoryCardShelf,
+ } from '~/components/jet/shelf/LargeStoryCardShelf.svelte';
+ import LinkableTextShelf, {
+ isLinkableTextShelf,
+ } from '~/components/jet/shelf/LinkableTextShelf.svelte';
+ import {
+ isMarkerShelf,
+ type MarkerShelf as MarkerShelfModel,
+ } from '~/components/jet/shelf/MarkerShelf.svelte';
+ import MediumImageLockupShelf, {
+ isMediumImageLockupShelf,
+ } from '~/components/jet/shelf/MediumImageLockupShelf.svelte';
+ import MediumLockupShelf, {
+ isMediumLockupShelf,
+ } from '~/components/jet/shelf/MediumLockupShelf.svelte';
+ import MediumStoryCardShelf, {
+ isMediumStoryCardShelf,
+ } from '~/components/jet/shelf/MediumStoryCardShelf.svelte';
+ import ProductBadgeShelf, {
+ isProductBadgeShelf,
+ } from '~/components/jet/shelf/ProductBadgeShelf.svelte';
+ import PageHeaderShelf, {
+ isPageHeaderShelf,
+ } from '~/components/jet/shelf/PageHeaderShelf.svelte';
+ import ParagraphShelf, {
+ isParagraphShelf,
+ } from '~/components/jet/shelf/ParagraphShelf.svelte';
+ import PosterLockupShelf, {
+ isPosterLockupShelf,
+ } from '~/components/jet/shelf/PosterLockupShelf.svelte';
+ import ProductMediaShelf, {
+ isProductMediaShelf,
+ } from '~/components/jet/shelf/ProductMediaShelf.svelte';
+ import ProductDescriptionShelf, {
+ isProductDescriptionShelf,
+ } from '~/components/jet/shelf/ProductDescriptionShelf.svelte';
+ import ProductRatingsShelf, {
+ isProductRatingsShelf,
+ } from '~/components/jet/shelf/ProductRatingsShelf.svelte';
+ import ProductReviewShelf, {
+ isProductReviewShelf,
+ } from '~/components/jet/shelf/ProductReviewShelf.svelte';
+ import RibbonBarShelf, {
+ isRibbonBarShelf,
+ } from '~/components/jet/shelf/RibbonBarShelf.svelte';
+ import SearchLinkShelf, {
+ isSearchLinkShelf,
+ } from '~/components/jet/shelf/SearchLinkShelf.svelte';
+ import SearchResultShelf, {
+ isSearchResultShelf,
+ } from '~/components/jet/shelf/SearchResultShelf.svelte';
+ import SmallBreakoutShelf, {
+ isSmallBreakoutShelf,
+ } from '~/components/jet/shelf/SmallBreakoutShelf.svelte';
+ import SmallBrickShelf, {
+ isSmallBrickShelf,
+ } from '~/components/jet/shelf/SmallBrickShelf.svelte';
+ import SmallLockupShelf, {
+ isSmallLockupShelf,
+ } from '~/components/jet/shelf/SmallLockupShelf.svelte';
+ import SmallStoryCardShelf, {
+ isSmallStoryCardShelf,
+ } from '~/components/jet/shelf/SmallStoryCardShelf.svelte';
+ import PrivacyHeaderShelf, {
+ isPrivacyHeaderShelf,
+ } from '~/components/jet/shelf/PrivacyHeaderShelf.svelte';
+ import PrivacyFooterShelf, {
+ isPrivacyFooterShelf,
+ } from '~/components/jet/shelf/PrivacyFooterShelf.svelte';
+ import PrivacyTypeShelf, {
+ isPrivacyTypeShelf,
+ } from '~/components/jet/shelf/PrivacyTypeShelf.svelte';
+ import ProductCapabilityShelf, {
+ isProductCapabilityShelf,
+ } from '~/components/jet/shelf/ProductCapabilityShelf.svelte';
+ import ProductPageLinkShelf, {
+ isProductPageLinkShelf,
+ } from './ProductPageLinkShelf.svelte';
+ import QuoteShelf, {
+ isQuoteShelf,
+ } from '~/components/jet/shelf/QuoteShelf.svelte';
+ import ReviewsContainerShelf, {
+ isReviewsContainerShelf,
+ } from '~/components/jet/shelf/ReviewsContainerShelf.svelte';
+ import ReviewsShelf, {
+ isReviewsShelf,
+ } from '~/components/jet/shelf/ReviewsShelf.svelte';
+ import TitledParagraphShelf, {
+ isTitledParagraphShelf,
+ } from '~/components/jet/shelf/TitledParagraphShelf.svelte';
+ import TodayCardShelf, {
+ isTodayCardShelf,
+ } from '~/components/jet/shelf/TodayCardShelf.svelte';
+ import UberShelf, {
+ isUberShelf,
+ } from '~/components/jet/shelf//UberShelf.svelte';
+ import FallbackShelf, {
+ isFallbackShelf,
+ } from '~/components/jet/shelf/FallbackShelf.svelte';
+
+ interface $$Slots {
+ /**
+ * If the `shelf` is recognized to be a {@linkcode MarkerShelfModel}, this
+ * slot is rendered with the `shelf` as data rather than rendering the
+ * shelf directly.
+ *
+ * This is done because "marker" shelves need the whole "page" definition to
+ * be rendered, which is not available at this level of the UI. Rather than
+ * having to pass that data down to this level, we yield rendering back to
+ * the "parent" component that can provide that data directly.
+ */
+ 'marker-shelf': {
+ shelf: MarkerShelfModel;
+ };
+ }
+
+ export let shelf: Shelf;
+</script>
+
+<!--
+@component
+Render a generic `Shelf`
+
+This component is responsible for rendering any kind of `Shelf` that
+the App Store is capable of rendering. It primarily does this by trying
+to narrow the generic `Shelf` down to a more-specific type and then
+rendering a component specifically made for it
+-->
+
+{#if isAccessibilityHeaderShelf(shelf)}
+ <AccessibilityHeaderShelf {shelf} />
+{:else if isAccessibilityFeaturesShelf(shelf)}
+ <AccessibilityFeaturesShelf {shelf} />
+{:else if isAccessibilityDeveloperLinkShelf(shelf)}
+ <AccessibilityDeveloperLinkShelf {shelf} />
+{:else if isActionShelf(shelf)}
+ <ActionShelf {shelf} />
+{:else if isAnnotationShelf(shelf)}
+ <AnnotationShelf {shelf} />
+{:else if isAppEventDetailShelf(shelf)}
+ <AppEventDetailShelf {shelf} />
+{:else if isAppPromotionShelf(shelf)}
+ <AppPromotionShelf {shelf} />
+{:else if isAppShowcaseShelf(shelf)}
+ <AppShowcaseShelf {shelf} />
+{:else if isAppTrailerLockupShelf(shelf)}
+ <AppTrailerLockupShelf {shelf} />
+{:else if isArcadeFooterShelf(shelf)}
+ <ArcadeFooterShelf {shelf} />
+{:else if isBannerShelf(shelf)}
+ <!-- a no-op until we determine if we actually want to support these banners -->
+ <!-- <BannerShelf {shelf} /> -->
+{:else if isBrickShelf(shelf)}
+ <BrickShelf {shelf} />
+{:else if isCategoryBrickShelf(shelf)}
+ <CategoryBrickShelf {shelf} />
+{:else if isEditorialCardShelf(shelf)}
+ <EditorialCardShelf {shelf} />
+{:else if isEditorialLinkShelf(shelf)}
+ <EditorialLinkShelf {shelf} />
+{:else if isFramedArtworkShelf(shelf)}
+ <FramedArtworkShelf {shelf} />
+{:else if isFramedVideoShelf(shelf)}
+ <FramedVideoShelf {shelf} />
+{:else if isHeroCarouselShelf(shelf)}
+ <HeroCarouselShelf {shelf} />
+{:else if isHorizontalRuleShelf(shelf)}
+ <HorizontalRuleShelf {shelf} />
+{:else if isInAppPurchaseLockupShelf(shelf)}
+ <InAppPurchaseLockupShelf {shelf} />
+{:else if isLargeHeroBreakoutShelf(shelf)}
+ <LargeHeroBreakoutShelf {shelf} />
+{:else if isLargeBrickShelf(shelf)}
+ <LargeBrickShelf {shelf} />
+{:else if isLargeImageLockupShelf(shelf)}
+ <LargeImageLockupShelf {shelf} />
+{:else if isLargeLockupShelf(shelf)}
+ <LargeLockupShelf {shelf} />
+{:else if isLargeStoryCardShelf(shelf)}
+ <LargeStoryCardShelf {shelf} />
+{:else if isLinkableTextShelf(shelf)}
+ <LinkableTextShelf {shelf} />
+{:else if isProductDescriptionShelf(shelf)}
+ <ProductDescriptionShelf {shelf} />
+{:else if isMediumImageLockupShelf(shelf)}
+ <MediumImageLockupShelf {shelf} />
+{:else if isMediumLockupShelf(shelf)}
+ <MediumLockupShelf {shelf} />
+{:else if isMediumStoryCardShelf(shelf)}
+ <MediumStoryCardShelf {shelf} />
+{:else if isPosterLockupShelf(shelf)}
+ <PosterLockupShelf {shelf} />
+{:else if isProductBadgeShelf(shelf)}
+ <ProductBadgeShelf {shelf} />
+{:else if isPageHeaderShelf(shelf)}
+ <PageHeaderShelf {shelf} />
+{:else if isParagraphShelf(shelf)}
+ <ParagraphShelf {shelf} />
+{:else if isPrivacyHeaderShelf(shelf)}
+ <PrivacyHeaderShelf {shelf} />
+{:else if isPrivacyFooterShelf(shelf)}
+ <PrivacyFooterShelf {shelf} />
+{:else if isPrivacyTypeShelf(shelf)}
+ <PrivacyTypeShelf {shelf} />
+{:else if isProductMediaShelf(shelf)}
+ <ProductMediaShelf {shelf} />
+{:else if isProductRatingsShelf(shelf)}
+ <ProductRatingsShelf {shelf} />
+{:else if isProductReviewShelf(shelf)}
+ <ProductReviewShelf {shelf} />
+{:else if isRibbonBarShelf(shelf)}
+ <RibbonBarShelf {shelf} />
+{:else if isSearchLinkShelf(shelf)}
+ <SearchLinkShelf {shelf} />
+{:else if isSearchResultShelf(shelf)}
+ <SearchResultShelf {shelf} />
+{:else if isSmallBreakoutShelf(shelf)}
+ <SmallBreakoutShelf {shelf} />
+{:else if isSmallBrickShelf(shelf)}
+ <SmallBrickShelf {shelf} />
+{:else if isSmallStoryCardShelf(shelf)}
+ <SmallStoryCardShelf {shelf} />
+{:else if isSmallLockupShelf(shelf)}
+ <SmallLockupShelf {shelf} />
+{:else if isProductCapabilityShelf(shelf)}
+ <ProductCapabilityShelf {shelf} />
+{:else if isProductPageLinkShelf(shelf)}
+ <ProductPageLinkShelf {shelf} />
+{:else if isQuoteShelf(shelf)}
+ <QuoteShelf {shelf} />
+{:else if isReviewsContainerShelf(shelf)}
+ <ReviewsContainerShelf {shelf} />
+{:else if isReviewsShelf(shelf)}
+ <ReviewsShelf {shelf} />
+{:else if isTodayCardShelf(shelf)}
+ <TodayCardShelf {shelf} />
+{:else if isTitledParagraphShelf(shelf)}
+ <TitledParagraphShelf {shelf} />
+{:else if isUberShelf(shelf)}
+ <UberShelf {shelf} />
+{:else if isMarkerShelf(shelf)}
+ <slot name="marker-shelf" {shelf} />
+{:else if isFallbackShelf(shelf)}
+ <FallbackShelf {shelf} />
+{/if}
diff --git a/src/components/jet/shelf/SmallBreakoutShelf.svelte b/src/components/jet/shelf/SmallBreakoutShelf.svelte
new file mode 100644
index 0000000..095cf7f
--- /dev/null
+++ b/src/components/jet/shelf/SmallBreakoutShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type {
+ LargeHeroBreakout,
+ Shelf,
+ SmallBreakout,
+ } from '@jet-app/app-store/api/models';
+
+ interface SmallBreakoutShelf extends Shelf {
+ items: SmallBreakout[];
+ }
+
+ export function isSmallBreakoutShelf(
+ shelf: Shelf,
+ ): shelf is SmallBreakoutShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallBreakout' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import SmallBreakoutItem from '~/components/jet/item/SmallBreakoutItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SmallBreakoutShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="Spotlight" let:item>
+ <SmallBreakoutItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SmallBrickShelf.svelte b/src/components/jet/shelf/SmallBrickShelf.svelte
new file mode 100644
index 0000000..34426cf
--- /dev/null
+++ b/src/components/jet/shelf/SmallBrickShelf.svelte
@@ -0,0 +1,26 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface SmallBrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isSmallBrickShelf(shelf: Shelf): shelf is SmallBrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallBrick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import BrickItem from '~/components/jet/item/BrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SmallBrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="C" let:item>
+ <BrickItem {item} shouldOverlayDescription />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SmallLockupShelf.svelte b/src/components/jet/shelf/SmallLockupShelf.svelte
new file mode 100644
index 0000000..e286671
--- /dev/null
+++ b/src/components/jet/shelf/SmallLockupShelf.svelte
@@ -0,0 +1,54 @@
+<script lang="ts" context="module">
+ import type { Lockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface SmallLockupShelf extends Shelf {
+ items: Lockup[];
+ }
+
+ export function isSmallLockupShelf(
+ shelf: Shelf,
+ ): shelf is SmallLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+ import SmallLockupWithOrdinalItem, {
+ isSmallLockupWithOrdinalItem,
+ } from '~/components/jet/item/SmallLockupWithOrdinalItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SmallLockupShelf;
+
+ $: ({ isArticleContext = false } = shelf.presentationHints ?? {});
+ $: itemHasOrdinal = shelf.items.some((item) => item.ordinal);
+ $: gridType = (() => {
+ if (itemHasOrdinal) {
+ return 'SmallLockupWithOrdinal';
+ }
+
+ if (isArticleContext) {
+ return 'Spotlight';
+ }
+
+ return 'SmallLockup';
+ })();
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout
+ {shelf}
+ {gridType}
+ rowsPerColumnOverride={gridType === 'SmallLockup' ? 3 : null}
+ let:item
+ >
+ {#if isSmallLockupWithOrdinalItem(item)}
+ <SmallLockupWithOrdinalItem {item} />
+ {:else}
+ <SmallLockupItem {item} --margin-inline-end="16px" />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SmallStoryCardShelf.svelte b/src/components/jet/shelf/SmallStoryCardShelf.svelte
new file mode 100644
index 0000000..c1a85ad
--- /dev/null
+++ b/src/components/jet/shelf/SmallStoryCardShelf.svelte
@@ -0,0 +1,66 @@
+<script lang="ts" context="module">
+ import type { Shelf, TodayCard } from '@jet-app/app-store/api/models';
+
+ interface SmallStoryCardShelf extends Shelf {
+ contentType: 'smallStoryCard';
+ items: TodayCard[];
+ }
+
+ export function isSmallStoryCardShelf(
+ shelf: Shelf,
+ ): shelf is SmallStoryCardShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallStoryCard' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import SmallStoryCardWithMediaItem, {
+ isSmallStoryCardWithMediaItem,
+ } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte';
+ import SmallStoryCardWithArtworkItem, {
+ isSmallStoryCardWithArtworkItem,
+ } from '~/components/jet/item/SmallStoryCardWithArtworkItem.svelte';
+ import SmallStoryCardWithMediaRiver, {
+ isSmallStoryCardWithMediaRiver,
+ } from '~/components/jet/item/SmallStoryCardWithMediaRiver.svelte';
+ import SmallStoryCardWithMediaAppIcon, {
+ isSmallStoryCardWithMediaAppIcon,
+ } from '~/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte';
+ import SmallStoryCardMediaBrandedSingleApp, {
+ isSmallStoryCardMediaBrandedSingleApp,
+ } from '~/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+
+ export let shelf: SmallStoryCardShelf;
+
+ $: ({ isArticleContext = false } = shelf.presentationHints ?? {});
+ $: gridType = (() => {
+ if (isArticleContext) {
+ return 'SmallStoryCard';
+ }
+
+ if (shelf.items.some(isSmallStoryCardWithArtworkItem)) {
+ return 'D';
+ }
+
+ return 'B';
+ })();
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={!isArticleContext}>
+ <ShelfItemLayout {shelf} {gridType} let:item>
+ {#if isSmallStoryCardWithMediaRiver(item)}
+ <SmallStoryCardWithMediaRiver {item} />
+ {:else if isSmallStoryCardWithMediaAppIcon(item)}
+ <SmallStoryCardWithMediaAppIcon {item} />
+ {:else if isSmallStoryCardMediaBrandedSingleApp(item)}
+ <SmallStoryCardMediaBrandedSingleApp {item} />
+ {:else if isSmallStoryCardWithMediaItem(item)}
+ <SmallStoryCardWithMediaItem {item} />
+ {:else if isSmallStoryCardWithArtworkItem(item)}
+ <SmallStoryCardWithArtworkItem {item} />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/TitledParagraphShelf.svelte b/src/components/jet/shelf/TitledParagraphShelf.svelte
new file mode 100644
index 0000000..41c1d74
--- /dev/null
+++ b/src/components/jet/shelf/TitledParagraphShelf.svelte
@@ -0,0 +1,118 @@
+<script lang="ts" context="module">
+ import {
+ type Action,
+ type FlowAction,
+ type GenericPage,
+ type Shelf,
+ type TitledParagraph,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ interface TitledParagraphShelf extends Shelf {
+ items: [TitledParagraph];
+ }
+
+ interface VersionHistoryPage extends FlowAction {
+ page: 'versionHistory';
+ pageData: GenericPage;
+ }
+
+ export function isTitledParagraphShelf(
+ shelf: Shelf,
+ ): shelf is TitledParagraphShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'titledParagraph' && Array.isArray(items);
+ }
+
+ function isVersionHistoryFlowAction(
+ action: Action,
+ ): action is VersionHistoryPage {
+ return isFlowAction(action) && action.page === 'versionHistory';
+ }
+</script>
+
+<script lang="ts">
+ import { createEventDispatcher, type SvelteComponent } from 'svelte';
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import TitledParagraphItem, {
+ isTitledParagraphItem,
+ } from '~/components/jet/item/TitledParagraphItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getJetPerform } from '~/jet';
+ import { VERSION_HISTORY_MODAL_ID } from '~/utils/metrics';
+
+ const perform = getJetPerform();
+ export let shelf: TitledParagraphShelf;
+
+ let modalComponent: SvelteComponent;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const { seeAllAction } = shelf;
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+ const handleModalClose = () => modalComponent.close();
+ const handleOpenModalClick = (e: Event) => {
+ modalTriggerElement = e.target as HTMLElement;
+ modalComponent?.showModal();
+ perform(destination);
+ };
+
+ const destination =
+ seeAllAction && isVersionHistoryFlowAction(seeAllAction)
+ ? seeAllAction
+ : undefined;
+
+ const pageData = destination?.pageData;
+</script>
+
+<ShelfWrapper {shelf}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ <button on:click={handleOpenModalClick}>
+ <ShelfTitle title={shelf.title} seeAllAction={destination} />
+ </button>
+ {/if}
+
+ {#if pageData}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleModalClose}
+ title={pageData.title || null}
+ subtitle={null}
+ targetId={VERSION_HISTORY_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <ul>
+ {#each pageData.shelves as shelf}
+ {#each shelf.items || [] as item}
+ {#if isTitledParagraphItem(item)}
+ <li>
+ <TitledParagraphItem {item} />
+ </li>
+ {/if}
+ {/each}
+ {/each}
+ </ul>
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+ {/if}
+ </div>
+
+ {#each shelf.items as item}
+ <TitledParagraphItem {item} />
+ {/each}
+</ShelfWrapper>
+
+<style>
+ .title-container {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 16px;
+ padding-inline-end: var(--bodyGutter);
+ }
+</style>
diff --git a/src/components/jet/shelf/TodayCardShelf.svelte b/src/components/jet/shelf/TodayCardShelf.svelte
new file mode 100644
index 0000000..e872112
--- /dev/null
+++ b/src/components/jet/shelf/TodayCardShelf.svelte
@@ -0,0 +1,187 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ TodayCard as TodayCardModel,
+ } from '@jet-app/app-store/api/models';
+
+ export interface TodayCardShelf extends Shelf {
+ contentType: 'todayCard';
+
+ items: TodayCardModel[];
+ }
+
+ export function isTodayCardShelf(shelf: Shelf): shelf is TodayCardShelf {
+ return shelf.contentType === 'todayCard' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import TodayCard from '~/components/jet/today-card/TodayCard.svelte';
+
+ import { getTodayCardLayoutConfiguration } from '~/context/today-card-layout';
+
+ export let shelf: TodayCardShelf;
+
+ $: ({
+ wrap: { shouldStretchFirstCard: shouldStretchFirstCardWrap },
+ nowrap: { shouldStretchFirstCard: shouldStretchFirstCardNoWrap },
+ } = getTodayCardLayoutConfiguration(shelf));
+</script>
+
+<ShelfWrapper {shelf}>
+ <div>
+ <div
+ class="today-card-row"
+ class:today-card-row__stretch-first-wrap={shouldStretchFirstCardWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__stretch-first-nowrap={shouldStretchFirstCardNoWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__stretch-last-wrap={!shouldStretchFirstCardWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__stretch-last-nowrap={!shouldStretchFirstCardNoWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__1-card={shelf.items.length == 1}
+ class:today-card-row__2-card={shelf.items.length == 2}
+ class:today-card-row__3-card={shelf.items.length == 3}
+ class:today-card-row__4-card={shelf.items.length >= 4}
+ >
+ {#each shelf.items.slice(0, 4) as card}
+ <div class="today-card-wrapper">
+ <TodayCard {card} />
+ </div>
+ {/each}
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+ @use 'amp/stylekit/core/mixins/browser-targets' as *;
+
+ @mixin stretch-card($flex-shrink: 1) {
+ aspect-ratio: unset;
+ justify-self: stretch;
+ align-self: stretch;
+ width: auto;
+ flex-shrink: $flex-shrink;
+ flex-grow: 1;
+ }
+
+ .today-card-row {
+ --card-default-width: 407px;
+ --card-default-height: 534px;
+ --card-row-gap: 16px;
+ min-width: min(var(--card-default-width), 100vw);
+ padding: 0 25px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--card-row-gap);
+
+ @media (--range-medium-up) {
+ padding: 0 40px;
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+ }
+
+ .today-card-wrapper {
+ --artworkShadowInset: 0;
+ --afterShadowBorderRadius: 0px;
+ aspect-ratio: 3 / 4;
+ width: 100%;
+ flex-shrink: 0;
+ max-height: 600px;
+ min-height: 100px;
+
+ > :global(a) {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ @include target-safari {
+ @media screen and (760px <= width) {
+ height: 600px;
+ aspect-ratio: unset;
+ }
+ }
+
+ @media (--range-medium-up) {
+ width: auto;
+ height: var(--card-default-height);
+ aspect-ratio: 3 / 4;
+ }
+ }
+
+ @media (--range-medium-up) {
+ .today-card-row__1-card .today-card-wrapper {
+ @include stretch-card;
+ }
+ }
+
+ @media (--range-medium-up) and (--range-large-down) {
+ .today-card-row__2-card {
+ &.today-card-row__stretch-first-wrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+
+ .today-card-row__3-card {
+ .today-card-wrapper:first-child {
+ flex-basis: 100%;
+
+ @include stretch-card(0);
+ }
+
+ &.today-card-row__stretch-first-wrap
+ .today-card-wrapper:nth-child(2),
+ &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+ }
+
+ @media (--range-medium-up) {
+ .today-card-row__4-card {
+ &.today-card-row__stretch-first-wrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-first-wrap .today-card-wrapper:last-child,
+ &.today-card-row__stretch-last-wrap
+ .today-card-wrapper:nth-child(2),
+ &.today-card-row__stretch-last-wrap
+ .today-card-wrapper:nth-child(3) {
+ flex-basis: calc(
+ 100% - var(--card-default-width) - var(--card-row-gap)
+ );
+
+ @include stretch-card;
+ }
+ }
+ }
+
+ @media (--range-xlarge-up) {
+ .today-card-row__2-card {
+ &.today-card-row__stretch-first-nowrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-last-nowrap
+ .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+
+ .today-card-row__3-card {
+ &.today-card-row__stretch-first-nowrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-last-nowrap
+ .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/UberShelf.svelte b/src/components/jet/shelf/UberShelf.svelte
new file mode 100644
index 0000000..6cdf004
--- /dev/null
+++ b/src/components/jet/shelf/UberShelf.svelte
@@ -0,0 +1,40 @@
+<script lang="ts" context="module">
+ import type { Shelf, Uber } from '@jet-app/app-store/api/models';
+
+ interface UberShelf extends Shelf {
+ contentType: 'uber';
+ items: [Uber];
+ }
+
+ export function isUberShelf(shelf: Shelf): shelf is UberShelf {
+ return shelf.contentType === 'uber' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: UberShelf;
+
+ $: uber = shelf.items[0];
+ $: artwork = uber.artwork;
+</script>
+
+{#if artwork}
+ <ShelfWrapper withPaddingTop={false} withBottomPadding={false}>
+ <div class="artwork-container">
+ <Artwork {artwork} profile="uber-shelf" />
+ </div>
+ </ShelfWrapper>
+{/if}
+
+<style>
+ .artwork-container {
+ border-bottom: 1px solid var(--systemQuaternary-onDark);
+
+ @media (--range-xlarge-only) {
+ border: 1px solid var(--systemQuaternary-onDark);
+ }
+ }
+</style>
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>
diff --git a/src/components/jet/web-navigation/CategoryTabItem.svelte b/src/components/jet/web-navigation/CategoryTabItem.svelte
new file mode 100644
index 0000000..61f2570
--- /dev/null
+++ b/src/components/jet/web-navigation/CategoryTabItem.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+ import { createEventDispatcher } from 'svelte';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import Item from '@amp/web-app-components/src/components/Navigation/Item.svelte';
+ import ItemContent from '@amp/web-app-components/src/components/Navigation/ItemContent.svelte';
+
+ const dispatch = createEventDispatcher();
+
+ export let item: any;
+ export let selected: boolean = false;
+ export let translateFn: (key: string) => string;
+ $$props; // lets the other props automatically passed to navigation item components enter without being delcared explicitly
+
+ const itemClicked = (): void => {
+ dispatch('selectItem', item);
+ };
+
+ $: backgroundImage = item.artwork
+ ? buildSrc(
+ item.artwork.template,
+ {
+ crop: 'bb',
+ width: 40,
+ height: 40,
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+</script>
+
+<Item {item} {selected} {translateFn}>
+ <!-- svelte-ignore a11y-click-events-have-key-events -->
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
+ <a
+ href={item.url}
+ class="navigation-item__link"
+ role="button"
+ aria-pressed={selected}
+ on:click|preventDefault={itemClicked}
+ >
+ <ItemContent label={item.label}>
+ <div
+ slot="icon"
+ aria-hidden={true}
+ class="icon"
+ style:--background-image={`url(${backgroundImage})`}
+ />
+ </ItemContent>
+ </a>
+</Item>
+
+<style>
+ .icon {
+ display: flex;
+ align-self: center;
+ width: 20px;
+ height: 20px;
+ background: var(--keyColor);
+ mask: var(--background-image) center / contain no-repeat;
+
+ @media (--sidebar-visible) {
+ width: 18px;
+ height: 18px;
+ }
+ }
+</style>
diff --git a/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte
new file mode 100644
index 0000000..f0fe666
--- /dev/null
+++ b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte
@@ -0,0 +1,88 @@
+<script lang="ts">
+ import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation';
+
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import PlatformSelectorItem from '~/components/jet/web-navigation/PlatformSelectorItem.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import Menu from '~/components/Menu.svelte';
+ import { getJet } from '~/jet';
+
+ export let platformSelectors: WebNavigationLink[];
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ $: activeSelector = platformSelectors.find((selector) => selector.isActive);
+
+ const handleShowMenu = () => {
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ actionType: 'open',
+ targetType: 'button',
+ targetId: 'PlatformSelector',
+ });
+ };
+</script>
+
+{#if activeSelector}
+ <nav>
+ <Menu options={platformSelectors} forcedXPosition={25} {handleShowMenu}>
+ <svelte:fragment slot="trigger">
+ <span
+ class="platform-selector-text"
+ id="platform-selector-text"
+ aria-labelledby="app-store-icon-contianer platform-selector-text"
+ aria-haspopup="menu"
+ >
+ {$i18n.t(
+ 'ASE.Web.AppStore.Navigation.PlatformSelectorText',
+ {
+ platform: activeSelector.action.title,
+ },
+ )}
+
+ <SFSymbol name="chevron.down" />
+ </span>
+ </svelte:fragment>
+
+ <svelte:fragment slot="option" let:option>
+ <PlatformSelectorItem platformSelector={option} />
+ </svelte:fragment>
+ </Menu>
+ </nav>
+{/if}
+
+<style>
+ nav {
+ --menu-item-padding: 0;
+ --menu-item-margin: 0 0 8px 0;
+ --menu-popover-padding: 8px;
+ --menu-common-padding: 8px;
+ --menu-trigger-padding: 0;
+ --menu-popover-background-color: var(--pageBg);
+ --menu-popover-box-shadow: 10px 10px 10px 0
+ var(--systemQuaternary-onLight);
+ --menu-popover-border-radius: 14px;
+ --menu-popover-border: 1px solid var(--systemQuaternary);
+ --menu-popover-z-index: calc(var(--z-web-chrome) + 1);
+ }
+
+ .platform-selector-text {
+ display: flex;
+ align-items: center;
+ gap: var(--platform-selector-trigger-gap, 4px);
+ font: var(--title-2);
+ white-space: nowrap;
+ }
+
+ .platform-selector-text :global(svg) {
+ height: 0.7em;
+ position: relative;
+ top: 2px;
+ fill: var(--systemPrimary);
+ }
+
+ nav :global(.menu-popover) {
+ width: 211px;
+ }
+</style>
diff --git a/src/components/jet/web-navigation/PlatformSelectorItem.svelte b/src/components/jet/web-navigation/PlatformSelectorItem.svelte
new file mode 100644
index 0000000..9b72fda
--- /dev/null
+++ b/src/components/jet/web-navigation/PlatformSelectorItem.svelte
@@ -0,0 +1,97 @@
+<script lang="ts">
+ import { isSome } from '@jet/environment/types/optional';
+ import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation';
+ import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent';
+
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let platformSelector: WebNavigationLink;
+
+ const i18n = getI18n();
+
+ $: ({ action, isActive } = platformSelector);
+ $: ({ artwork } = action);
+</script>
+
+<FlowAction destination={action}>
+ <span class="platform-selector" class:is-active={isActive}>
+ {#if isSome(artwork) && isSystemImageArtwork(artwork)}
+ <div class="icon-container">
+ <SystemImage {artwork} />
+ </div>
+ {/if}
+
+ <span
+ class="platform-title"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.Navigation.AX.PlatformSelectorItem',
+ {
+ platform: action.title,
+ },
+ )}
+ >
+ {action.title}
+ </span>
+
+ {#if action.destination && isSearchResultsPageIntent(action.destination)}
+ <span aria-hidden={true} class="search-icon-container">
+ <SFSymbol name="magnifyingglass" />
+ </span>
+ {/if}
+ </span>
+</FlowAction>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ .platform-selector {
+ display: flex;
+ border-radius: var(--global-border-radius-medium);
+ padding: 8px;
+ margin-bottom: 4px;
+ gap: 10px;
+ transition: background-color 175ms ease-in;
+ }
+
+ .platform-selector:not(.is-active):hover {
+ background-color: rgba(45, 45, 45, 0.035);
+
+ @media (prefers-color-scheme: dark) {
+ background-color: rgba(45, 45, 45, 0.35);
+ }
+ }
+
+ .platform-selector.is-active {
+ background-color: var(--systemQuinary);
+ }
+
+ .icon-container {
+ display: flex;
+ justify-content: center;
+ padding-inline-end: 2px;
+ }
+
+ .icon-container :global(svg) {
+ max-height: 16px;
+ width: 23px;
+ }
+
+ .search-icon-container {
+ display: flex;
+ }
+
+ .search-icon-container :global(svg) {
+ fill: var(--systemSecondary);
+ width: 16px;
+ }
+
+ .platform-title {
+ font: var(--body);
+ flex-grow: 1;
+ }
+</style>
diff --git a/src/components/navigation/Navigation.svelte b/src/components/navigation/Navigation.svelte
new file mode 100644
index 0000000..0114d4c
--- /dev/null
+++ b/src/components/navigation/Navigation.svelte
@@ -0,0 +1,423 @@
+<script lang="ts">
+ import { writable } from 'svelte/store';
+ import { isSome } from '@jet/environment/types/optional';
+ import type {
+ WebNavigation,
+ WebNavigationLink,
+ } from '@jet-app/app-store/api/models/web-navigation';
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+ import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent';
+
+ import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte';
+ import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
+
+ import AppStoreLogo from '~/components/icons/AppStoreLogo.svg';
+ import PlatformSelectorDropdown from '~/components/jet/web-navigation/PlatformSelectorDropdown.svelte';
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import SearchInput from '~/components/navigation/SearchInput.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+
+ import { getJetPerform } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+ import {
+ type NavigationItemWithTab,
+ navigationIdFromLink,
+ makeNavLinks,
+ } from '~/components/navigation/navigation-items';
+ import mediaQueries from '~/utils/media-queries';
+
+ import { fade, type EasingFunction } from 'svelte/transition';
+ import { circOut } from 'svelte/easing';
+ import { flyAndBlur } from '~/utils/transition';
+ import { makeCategoryTabsIntent } from '@jet-app/app-store/api/intents/category-tabs-intent';
+ import { getJet } from '~/jet';
+ import { getPlatformFromPage } from '~/utils/seo/common';
+ import type { NavigationId } from '@amp/web-app-components/src/types';
+
+ const i18n = getI18n();
+ const perform = getJetPerform();
+ const jet = getJet();
+
+ const categoryTabsCache: Record<string, WebNavigationLink[]> = {};
+ let categoryTabLinks: WebNavigationLink[] = [];
+ let currentTabStore = writable<NavigationId | null>(null);
+
+ export let webNavigation: WebNavigation;
+
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: searchAction = webNavigation.searchAction as WebSearchFlowAction;
+ // Mobile first means the inline items are hidden
+ // However, we still want the list visible in SSR (which is fine for mobile
+ // since the menu won't be expanded by default)
+ $: inlinePlatformItems =
+ isXSmallViewport || typeof window === 'undefined'
+ ? webNavigation.platforms
+ : [];
+
+ $: if (webNavigation && typeof window !== 'undefined') {
+ fetchCategoryTabs(webNavigation);
+ }
+
+ async function fetchCategoryTabs(nav: WebNavigation) {
+ const platform = getPlatformFromPage({
+ webNavigation: nav,
+ });
+
+ if (!platform) {
+ categoryTabLinks = [];
+ return;
+ }
+
+ if (categoryTabsCache[platform]) {
+ categoryTabLinks = updateActiveStates(categoryTabsCache[platform]);
+ } else {
+ try {
+ const data = await jet.dispatch(
+ makeCategoryTabsIntent({
+ platform,
+ }),
+ );
+
+ categoryTabsCache[platform] = data;
+ categoryTabLinks = updateActiveStates(data);
+ } catch (error) {
+ categoryTabLinks = [];
+ }
+ }
+
+ updateCurrentTab();
+ }
+
+ function updateActiveStates(
+ tabs: WebNavigationLink[],
+ ): WebNavigationLink[] {
+ return tabs.map((link) => ({
+ ...link,
+ isActive: link.action?.destination?.id
+ ? window.location.pathname.includes(link.action.destination.id)
+ : false,
+ }));
+ }
+
+ function updateCurrentTab() {
+ const allLinks: WebNavigationLink[] = [
+ ...categoryTabLinks,
+ ...webNavigation.tabs,
+ ];
+
+ const activeLink = allLinks.find((link) => link.isActive);
+ currentTabStore.set(
+ activeLink ? navigationIdFromLink(activeLink) : null,
+ );
+ }
+
+ function handleMenuItemClick(event: CustomEvent<NavigationItemWithTab>) {
+ const navigationItem = event.detail;
+ const tab = navigationItem.tab;
+
+ perform(tab.action);
+ }
+
+ const BASE_DELAY = 80;
+ const BASE_DURATION = 150;
+ const DURATION_SPREAD = 300;
+
+ // Returns an eased duration for a list item based on its index, e.g. items later in the list
+ // get longer durations, between BASE_DURATION and BASE_DURATION + DURATION_SPREAD.
+ function getEasedDuration({
+ i,
+ totalNumberOfItems,
+ easing = circOut,
+ }: {
+ i: number;
+ totalNumberOfItems: number;
+ easing?: EasingFunction;
+ }) {
+ const t = i / (totalNumberOfItems - 1);
+ return BASE_DURATION + easing(t) * DURATION_SPREAD;
+ }
+</script>
+
+<div class="navigation-wrapper">
+ <Navigation
+ translateFn={$i18n.t}
+ items={makeNavLinks(webNavigation.tabs, {
+ shouldShowSearchTab: $sidebarIsHidden,
+ })}
+ personalizedItemsHeader={$i18n.t(
+ 'ASE.Web.AppStore.Navigation.Categories.Title',
+ )}
+ personalizedItems={makeNavLinks(categoryTabLinks, {
+ shouldShowSearchTab: $sidebarIsHidden,
+ })}
+ currentTab={currentTabStore}
+ libraryItems={[]}
+ on:menuItemClick={handleMenuItemClick}
+ >
+ <div slot="logo" class="platform-selector-container">
+ <span
+ id="app-store-icon-contianer"
+ class="app-store-icon-container"
+ role="img"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.Navigation.AX.AppStoreLogo',
+ )}
+ >
+ <AppStoreLogo focusable={false} />
+ </span>
+
+ {#if !$sidebarIsHidden && !isXSmallViewport}
+ <PlatformSelectorDropdown
+ platformSelectors={webNavigation.platforms}
+ />
+ {/if}
+ </div>
+
+ <svelte:fragment slot="search">
+ <div class="search-input-container">
+ <SearchInput {searchAction} />
+ </div>
+ </svelte:fragment>
+
+ <div slot="after-navigation-items" class="platform-selector-inline">
+ {#if isXSmallViewport}
+ <h3 in:fade out:fade={{ delay: 250, duration: BASE_DURATION }}>
+ {$i18n.t('ASE.Web.AppStore.Navigation.PlatformHeading')}
+ </h3>
+ {/if}
+
+ <ul>
+ {#each inlinePlatformItems as platformSelector, i (platformSelector.action.title)}
+ {@const { action, isActive } = platformSelector}
+ {@const artwork = action.artwork}
+ {@const totalNumberOfItems = inlinePlatformItems.length}
+ <li
+ in:flyAndBlur={{
+ y: -50,
+ delay: i * BASE_DELAY,
+ duration: getEasedDuration({
+ i,
+ totalNumberOfItems,
+ }),
+ }}
+ out:flyAndBlur={{
+ y: i * -5,
+ delay:
+ // This delay is calculated in a negative/backwards manner,
+ // which makes it so the items build out from the bottom to the top.
+ (totalNumberOfItems - i - 1) * (BASE_DELAY / 2),
+ duration: BASE_DURATION,
+ }}
+ >
+ <FlowAction destination={action}>
+ <span class="platform" class:is-active={isActive}>
+ {#if isSome(artwork) && isSystemImageArtwork(artwork)}
+ <div
+ class="icon-container"
+ aria-hidden="true"
+ >
+ <SystemImage {artwork} />
+ </div>
+ {/if}
+
+ <span class="platform-title">
+ {action.title}
+ </span>
+
+ {#if action.destination && isSearchResultsPageIntent(action.destination)}
+ <span
+ aria-hidden={true}
+ class="search-icon-container"
+ >
+ <SFSymbol name="magnifyingglass" />
+ </span>
+ {/if}
+ </span>
+ </FlowAction>
+ </li>
+ {/each}
+ </ul>
+ </div>
+ </Navigation>
+</div>
+
+<style lang="scss">
+ .navigation-wrapper {
+ display: contents;
+ }
+
+ .platform-selector-container {
+ --header-gap: 3px;
+ --platform-selector-trigger-gap: var(--header-gap);
+ display: flex;
+ gap: var(--header-gap);
+ position: relative;
+
+ @media (--sidebar-visible) {
+ padding: 19px 25px 14px;
+ }
+ }
+
+ // Japanese and Catalonian both require scaling down the platform selector in order to make it
+ // fit cleanly in the sidebar, due to their longer character lengths.
+ .platform-selector-container:lang(ja),
+ .platform-selector-container:lang(ca) {
+ --scale-factor: 0.1;
+ z-index: 3;
+ transform: scale(calc(1 - var(--scale-factor)));
+ transform-origin: center left;
+
+ & :global(dialog) {
+ top: 60px;
+ // Since the `dialog` is a child of `platform-selector-container, we re-scale it back
+ // to it's original size by applying the inverse scale transformation.
+ transform: scale(calc(1 + var(--scale-factor)));
+ transform-origin: center left;
+ }
+ }
+
+ .app-store-icon-container {
+ display: flex;
+ align-items: center;
+ gap: var(--header-gap);
+ font: var(--title-1);
+ font-weight: 600;
+ }
+
+ .app-store-icon-container :global(svg) {
+ height: 18px;
+ position: relative;
+ top: 0.33px;
+ width: auto;
+
+ @media (--sidebar-visible) and (--range-xsmall-only) {
+ height: 22px;
+ width: auto;
+ }
+ }
+
+ .search-input-container {
+ margin: 0 25px;
+ }
+
+ .navigation-wrapper :global(.navigation__header) {
+ @media (--sidebar-visible) {
+ display: flex;
+ flex-direction: column;
+ }
+ }
+
+ .navigation-wrapper :global(.navigation-item__link) {
+ height: 100%;
+ display: flex;
+ }
+
+ .navigation-wrapper :global(.navigation-item__icon) {
+ --navigation-item-icon-size: 32px;
+ width: var(--navigation-item-icon-size);
+ height: var(--navigation-item-icon-size);
+ display: flex;
+ justify-content: center;
+
+ @media (--sidebar-visible) {
+ --navigation-item-icon-size: 24px;
+ }
+ }
+
+ // Our SVG icons for the landing pages are sized differently than other Onyx apps,
+ // so we have to reach into the navigation component and style them so they look
+ // visually similar to the other Onyx apps
+ .navigation-wrapper :global(.navigation-item__icon svg) {
+ color: var(--keyColor);
+ width: 20px;
+
+ @media (--sidebar-visible) {
+ width: 18px;
+ }
+ }
+
+ // Below is styling for the "inline" version of the Platform Selector
+ .platform-selector-inline {
+ margin: 8px 32px;
+ }
+
+ ul {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ }
+
+ h3 {
+ color: var(--systemTertiary);
+ font: var(--body-emphasized);
+ margin: 0 0 10px;
+ padding-top: 20px;
+
+ @media (--sidebar-visible) {
+ font: var(--footnote-emphasized);
+ margin: 0 0 6px;
+ padding-top: 7px;
+ }
+ }
+
+ .platform {
+ display: flex;
+ gap: 10px;
+ padding: 8px 0;
+ color: var(--systemTertiary);
+
+ @media (prefers-color-scheme: dark) {
+ color: var(--systemSecondary);
+ }
+ }
+
+ .platform,
+ .platform :global(svg) {
+ transition: color 210ms ease-out;
+ }
+
+ .platform:not(.is-active):hover,
+ .platform:not(.is-active):hover :global(svg) {
+ color: var(--systemPrimary);
+ }
+
+ .platform.is-active {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ }
+
+ .platform.is-active :global(svg) {
+ color: currentColor;
+ }
+
+ .icon-container {
+ display: flex;
+ }
+
+ .icon-container :global(svg) {
+ color: var(--systemTertiary);
+ width: 18px;
+ max-height: 16px;
+
+ @media (prefers-color-scheme: dark) {
+ color: var(--systemSecondary);
+ }
+ }
+
+ .search-icon-container {
+ display: flex;
+ }
+
+ .search-icon-container :global(svg) {
+ fill: var(--systemSecondary);
+ width: 16px;
+ }
+
+ .platform-title {
+ font: var(--body);
+ flex-grow: 1;
+ }
+</style>
diff --git a/src/components/navigation/SearchInput.svelte b/src/components/navigation/SearchInput.svelte
new file mode 100644
index 0000000..a04fa4b
--- /dev/null
+++ b/src/components/navigation/SearchInput.svelte
@@ -0,0 +1,82 @@
+<script lang="ts" context="module">
+ import type { ComponentProps } from 'svelte';
+ import { writable } from 'svelte/store';
+
+ import SearchInput from '@amp/web-app-components/src/components/SearchInput/SearchInput.svelte';
+
+ type UnusedSearchInputProps = Pick<
+ ComponentProps<SearchInput>,
+ 'currentTab' | 'menuItem'
+ >;
+
+ // `SearchInput` requires a bunch of properties that are unnecessary
+ // for our use-case; they're defined here to keep them grouped together
+ const UNNEEDED_SEARCH_INPUT_PROPS: UnusedSearchInputProps = {
+ currentTab: writable(null),
+ menuItem: {
+ id: { type: 'search' },
+ // @ts-expect-error the `menuItem` is not relevant to us; we don't
+ // need to provide an icon for this
+ icon: null,
+ },
+ };
+</script>
+
+<script lang="ts">
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+ import { makeCanonicalSearchResultsPageUrl } from '@jet-app/app-store/common/search/search-page-url';
+
+ import { getJet } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ export let searchAction: WebSearchFlowAction;
+ export let big: boolean = false;
+
+ function dispatchSearchAction(event: CustomEvent<{ term: string }>) {
+ const { term } = event.detail;
+
+ searchAction.destination.term = term;
+
+ searchAction.pageUrl = makeCanonicalSearchResultsPageUrl(
+ jet.objectGraph,
+ searchAction.destination,
+ );
+
+ jet.perform(searchAction);
+ }
+</script>
+
+<div class="search-input-wrapper" class:big>
+ <SearchInput
+ {...UNNEEDED_SEARCH_INPUT_PROPS}
+ defaultValue={searchAction?.destination?.term}
+ translateFn={(key) => $i18n.t(key)}
+ on:makeSearchQueryFromInput={dispatchSearchAction}
+ />
+</div>
+
+<style>
+ .search-input-wrapper {
+ --searchBoxIconFill: var(--keyColor);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ .search-input-wrapper.big :global(.search-input__text-field) {
+ height: 48px;
+ padding-inline-start: 40px;
+ font: var(--title-2);
+ border-radius: 8px;
+ }
+
+ .search-input-wrapper.big :global(.search-svg) {
+ width: 16px;
+ height: auto;
+ inset: 16px 0 0 13px;
+ }
+</style>
diff --git a/src/components/navigation/Skeleton.svelte b/src/components/navigation/Skeleton.svelte
new file mode 100644
index 0000000..508e523
--- /dev/null
+++ b/src/components/navigation/Skeleton.svelte
@@ -0,0 +1,85 @@
+<script lang="ts">
+ import { writable } from 'svelte/store';
+
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+
+ import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte';
+ import AppStoreLogo from '~/components/icons/AppStoreLogo.svg';
+ import SearchInput from '~/components/navigation/SearchInput.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+
+ $: searchAction = {} as WebSearchFlowAction;
+</script>
+
+<div class="navigation-wrapper">
+ <Navigation
+ translateFn={$i18n.t}
+ items={[]}
+ currentTab={writable(null)}
+ libraryItems={[]}
+ personalizedItems={[]}
+ >
+ <div slot="logo" class="platform-selector-container">
+ <span class="app-store-icon-container">
+ <AppStoreLogo />
+ </span>
+ </div>
+
+ <svelte:fragment slot="search">
+ <div class="search-input-container">
+ <SearchInput {searchAction} />
+ </div>
+ </svelte:fragment>
+ </Navigation>
+</div>
+
+<style lang="scss">
+ .navigation-wrapper {
+ display: contents;
+ }
+
+ .platform-selector-container {
+ @media (--sidebar-visible) {
+ padding: 19px 25px 14px;
+ }
+ }
+
+ .app-store-icon-container {
+ display: flex;
+ align-items: center;
+ padding: 2px 0;
+ }
+
+ .app-store-icon-container :global(svg) {
+ height: 18px;
+ position: relative;
+ top: 0.33px;
+ width: auto;
+
+ @media (--sidebar-visible) and (--range-xsmall-only) {
+ height: 22px;
+ width: auto;
+ }
+ }
+
+ .search-input-container {
+ margin: 0 25px;
+ }
+
+ .navigation-wrapper :global(.navigation-item__link) {
+ height: 100%;
+ display: flex;
+ }
+
+ .navigation-wrapper :global(.navigation-item__icon) {
+ --navigation-item-icon-size: 32px;
+ width: var(--navigation-item-icon-size);
+ height: var(--navigation-item-icon-size);
+
+ @media (--sidebar-visible) {
+ --navigation-item-icon-size: 24px;
+ }
+ }
+</style>
diff --git a/src/components/navigation/navigation-items.ts b/src/components/navigation/navigation-items.ts
new file mode 100644
index 0000000..8692765
--- /dev/null
+++ b/src/components/navigation/navigation-items.ts
@@ -0,0 +1,79 @@
+import {
+ isSome,
+ unwrapOptional as unwrap,
+} from '@jet/environment/types/optional';
+
+import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types';
+import type { NavigationId } from '@amp/web-app-components/src/types';
+import type {
+ WebNavigation,
+ WebNavigationLink,
+} from '@jet-app/app-store/api/models/web-navigation';
+
+import {
+ isSystemImageArtwork,
+ getIconNameFromTemplate,
+} from '~/components/SystemImage.svelte';
+import { getIconComponentByName } from '../SFSymbol.svelte';
+import type { Artwork } from '@jet-app/app-store/api/models';
+import CategoryTabItem from '~/components/jet/web-navigation/CategoryTabItem.svelte';
+
+/**
+ * A {@linkcode NavigationItem} that includes the original {@linkcode WebNavigationLink}
+ * it was defined from, which is needed for the
+ */
+export interface NavigationItemWithTab extends NavigationItem {
+ tab: WebNavigationLink;
+ artwork?: Artwork;
+ isActive?: boolean;
+}
+
+export function navigationIdFromLink(link: WebNavigationLink): NavigationId {
+ const intent = unwrap(link.action.destination);
+
+ return {
+ type: intent.$kind,
+ // `intent.$kind` will be unique for each `Intent` used here as they are
+ // each a different `LandingPageIntent`
+ resourceId: link.action.pageUrl ?? intent.$kind,
+ };
+}
+
+/**
+ * Transform the "tabs" in the `WebNavigation` into a shape that works with our
+ * shared navigation side-bar components.
+ */
+export function makeNavLinks(
+ tabs: WebNavigationLink[],
+ { shouldShowSearchTab = false },
+): NavigationItemWithTab[] {
+ return tabs
+ .filter((tab) => {
+ const isSearchTab =
+ tab.action?.destination?.['$kind'].includes('search_Intent');
+
+ // Allows for filtering our the search tab, which we use when the sidebar is visible,
+ // since there is a search input in the sidebar
+ return isSearchTab ? shouldShowSearchTab : true;
+ })
+ .map((tab) => {
+ const { action, artwork: tabArtwork } = tab;
+ const { artwork } = action || {};
+ const hasSystemImageArtwork =
+ isSome(artwork) && isSystemImageArtwork(artwork);
+
+ return {
+ id: navigationIdFromLink(tab),
+ label: unwrap(tab.action.title),
+ url: action.pageUrl ?? undefined,
+ icon: hasSystemImageArtwork
+ ? getIconComponentByName(
+ getIconNameFromTemplate(artwork.template),
+ )
+ : undefined,
+ artwork: tabArtwork,
+ component: !hasSystemImageArtwork ? CategoryTabItem : undefined,
+ tab,
+ };
+ });
+}
diff --git a/src/components/pages/AppEventDetailPage.svelte b/src/components/pages/AppEventDetailPage.svelte
new file mode 100644
index 0000000..a2b798e
--- /dev/null
+++ b/src/components/pages/AppEventDetailPage.svelte
@@ -0,0 +1,44 @@
+<script lang="ts" context="module">
+ import type { DefaultPageRequirements } from './DefaultPage.svelte';
+</script>
+
+<script lang="ts">
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+
+ export let page: DefaultPageRequirements;
+</script>
+
+<div class="app-event-detail-page-container">
+ <div class="shelf-container">
+ {#each page.shelves as shelf}
+ <ShelfComponent {shelf} />
+ {/each}
+ </div>
+</div>
+
+<style>
+ .app-event-detail-page-container {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ padding: 0 var(--bodyGutter);
+ }
+
+ .shelf-container {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto;
+
+ @media (--range-small-up) {
+ justify-content: center;
+ }
+ }
+
+ .shelf-container :global(.shelf) {
+ margin: 0;
+ padding: var(--bodyGutter) 0 0;
+ }
+</style>
diff --git a/src/components/pages/ArticlePage.svelte b/src/components/pages/ArticlePage.svelte
new file mode 100644
index 0000000..32cacb0
--- /dev/null
+++ b/src/components/pages/ArticlePage.svelte
@@ -0,0 +1,141 @@
+<script lang="ts" context="module">
+ import type { ArticlePage } from '@jet-app/app-store/api/models';
+
+ import type { DefaultPageRequirements } from './DefaultPage.svelte';
+
+ /**
+ * Just the `Page` props that we actually need to render this component
+ */
+ export type ArticlePageRequirements = DefaultPageRequirements & {
+ card: ArticlePage['card'];
+ footerLockup: ArticlePage['footerLockup'];
+ };
+</script>
+
+<script lang="ts">
+ import TodayCard from '~/components/jet/today-card/TodayCard.svelte';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import FooterLockupItem from '~/components/jet/item/FooterLockupItem.svelte';
+ export let page: ArticlePageRequirements;
+
+ $: ({ card } = page);
+</script>
+
+<div class="article-page-container" data-testid="article-page-container">
+ <div class="article-layout">
+ {#if card}
+ <div class="card-container">
+ <TodayCard {card} suppressClickAction />
+ </div>
+ {/if}
+
+ <div class="story-container">
+ {#each page.shelves as shelf}
+ {#if !shelf.isHidden}
+ <ShelfComponent {shelf} />
+ {/if}
+ {/each}
+
+ {#if page.footerLockup}
+ <div class="footer-lockup-container">
+ <FooterLockupItem item={page.footerLockup} />
+ </div>
+ {/if}
+ </div>
+ </div>
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .article-page-container {
+ flex-grow: 1;
+ width: 100%;
+ margin: 0 auto;
+ }
+
+ .article-layout {
+ --article-page-padding: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--article-page-padding);
+ max-width: 1600px;
+ margin: 0 auto;
+
+ @media (--range-small-up) {
+ padding: 2em var(--bodyGutter);
+ }
+
+ @media (--range-small-only) {
+ --article-page-padding: 40px;
+ }
+
+ @media (--range-medium-up) {
+ align-items: flex-start;
+ flex-direction: row;
+ }
+
+ @media (--range-medium-only) {
+ --article-page-padding: 20px;
+ }
+
+ @media (--range-large-up) {
+ --article-page-padding: 40px;
+ }
+ }
+
+ .card-container {
+ flex-shrink: 0;
+ aspect-ratio: 3/4;
+ width: 100%;
+
+ @media (--range-xsmall-only) {
+ --border-radius: 0;
+ }
+
+ @media (--range-small-only) {
+ aspect-ratio: 16/9;
+ }
+
+ @media (--range-small-up) {
+ width: 100%;
+ }
+
+ @media (--range-medium-up) {
+ position: sticky;
+ top: 2em;
+ aspect-ratio: 3 / 4;
+ height: min(calc(100vh - 80px), calc(33vw * 4 / 3));
+ min-height: 420px;
+ max-height: 700px;
+ width: auto;
+ }
+ }
+
+ .story-container {
+ width: 100%;
+ margin-top: 20px;
+ padding-bottom: var(--bodyGutter);
+
+ @media (--range-small-up) {
+ width: calc(100%);
+ margin-top: 0;
+ }
+
+ @media (--range-medium-up) {
+ min-width: calc(50% - calc(var(--article-page-padding)));
+ }
+ }
+
+ .story-container :global(.shelf:first-of-type) {
+ padding-top: 0;
+ padding-bottom: 13px;
+ }
+
+ .footer-lockup-container {
+ margin: var(--bodyGutter);
+ }
+</style>
diff --git a/src/components/pages/ChartsHubPage.svelte b/src/components/pages/ChartsHubPage.svelte
new file mode 100644
index 0000000..a75cb64
--- /dev/null
+++ b/src/components/pages/ChartsHubPage.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ import type { ChartsHubPage } from '@jet-app/app-store/api/models';
+
+ import TopChartsPage from './TopChartsPage.svelte';
+
+ export let page: ChartsHubPage;
+</script>
+
+{#each page.charts as chart}
+ <TopChartsPage page={chart} />
+{/each}
diff --git a/src/components/pages/DefaultPage.svelte b/src/components/pages/DefaultPage.svelte
new file mode 100644
index 0000000..7905b07
--- /dev/null
+++ b/src/components/pages/DefaultPage.svelte
@@ -0,0 +1,173 @@
+<script lang="ts" context="module">
+ import type {
+ PagePresentationOptions,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ /**
+ * Just the `Page` props that we actually need to render this component
+ */
+ export interface DefaultPageRequirements extends WebRenderablePage {
+ shelves: Shelf[];
+ presentationOptions?: PagePresentationOptions;
+ }
+</script>
+
+<script lang="ts">
+ import type { MarkerShelf } from '~/components/jet/shelf/MarkerShelf.svelte';
+ import { isUberShelf } from '~/components/jet/shelf/UberShelf.svelte';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import { partition } from '~/utils/array';
+ import { carouselMediaStyle } from '~/stores/carousel-media-style';
+ import mediaQueries from '~/utils/media-queries';
+ import { isHeroCarouselShelf } from '../jet/shelf/HeroCarouselShelf.svelte';
+ import { isRtl } from '~/utils/locale';
+
+ interface $$Slots {
+ 'before-shelves': {};
+
+ /**
+ * If {@linkcode ShelfComponent}` recognizes a shelf to be a {@linkcode MarkerShelf},
+ * this slot will be rendered so that the "page" data can be supplied by a "parent"
+ * component
+ */
+ 'marker-shelf': {
+ shelf: MarkerShelf;
+ };
+ }
+
+ export let page: DefaultPageRequirements;
+
+ $: ({ title, presentationOptions = [] } = page);
+
+ // Some shelves are meant to be rendered above the title, rather than below it
+ $: [aboveTitleShelves, belowTitleShelves] = partition(
+ page.shelves,
+ (shelf) => {
+ // Some "uber" shelves might be placed above the title
+ if (isUberShelf(shelf)) {
+ const [uber] = shelf.items;
+ return uber.style === 'above';
+ }
+
+ // Everything else should be below it
+ return false;
+ },
+ );
+
+ $: prefersHiddenPageTitle = presentationOptions.includes(
+ 'prefersHiddenPageTitle',
+ );
+ $: prefersLargeTitle = presentationOptions.includes('prefersLargeTitle');
+ $: prefersOverlayedPageHeader =
+ $mediaQueries === 'xsmall' &&
+ presentationOptions.includes('prefersOverlayedPageHeader');
+ $: isOnDarkBackground =
+ prefersOverlayedPageHeader && $carouselMediaStyle === 'dark';
+
+ $: isTitleDuplicatedInHero = (() => {
+ const firstShelf = page.shelves?.[0];
+
+ if (
+ !firstShelf ||
+ !isHeroCarouselShelf(firstShelf) ||
+ firstShelf.items?.length !== 1
+ ) {
+ return false;
+ }
+
+ const { items: ltrItems, rtlItems } = firstShelf.items?.[0] ?? {};
+ const firstItem = isRtl() && rtlItems?.length ? rtlItems : ltrItems;
+ const firstTitle = firstItem?.[0]?.overlay?.titleText;
+
+ return title === firstTitle;
+ })();
+</script>
+
+<div
+ class="default-page-container"
+ data-testid="default-page-container"
+ class:with-overlaid-title={prefersOverlayedPageHeader}
+ class:with-title-in-hero={isTitleDuplicatedInHero}
+>
+ {#each aboveTitleShelves as shelf}
+ <ShelfComponent {shelf}>
+ <slot name="marker-shelf" slot="marker-shelf" let:shelf {shelf} />
+ </ShelfComponent>
+ {/each}
+
+ {#if title && !prefersHiddenPageTitle && !isTitleDuplicatedInHero}
+ <h1
+ data-test-id="page-title"
+ class:large-title={prefersLargeTitle}
+ class:overlaid={prefersOverlayedPageHeader}
+ class:on-dark-background={isOnDarkBackground}
+ >
+ {title}
+ </h1>
+ {/if}
+
+ <slot name="before-shelves" />
+
+ {#each belowTitleShelves as shelf}
+ {#if !shelf.isHidden}
+ <ShelfComponent {shelf}>
+ <slot
+ name="marker-shelf"
+ slot="marker-shelf"
+ let:shelf
+ {shelf}
+ />
+ </ShelfComponent>
+ {/if}
+ {/each}
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .default-page-container {
+ flex-grow: 1;
+ width: 100%;
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ }
+
+ .default-page-container.with-overlaid-title {
+ margin-top: -13px;
+ }
+
+ .default-page-container.with-title-in-hero {
+ @media (--range-small-up) {
+ margin-top: 10px;
+ }
+ }
+
+ h1 {
+ padding: 11px var(--bodyGutter);
+ font: var(--large-title-emphasized);
+ letter-spacing: -0.5px;
+ word-wrap: break-word;
+ color: var(--systemPrimary, #000);
+ position: relative;
+ z-index: 1;
+ transition: color 210ms ease-in;
+ }
+
+ h1.large-title {
+ font: var(--large-title-emphasized-tall);
+ }
+
+ h1.overlaid {
+ position: absolute;
+ z-index: 3;
+ padding: var(--bodyGutter) var(--bodyGutter) 0;
+ color: var(--systemPrimary-onLight, #000);
+ }
+
+ h1.on-dark-background {
+ color: var(--systemPrimary-onDark);
+ }
+</style>
diff --git a/src/components/pages/ErrorPage.svelte b/src/components/pages/ErrorPage.svelte
new file mode 100644
index 0000000..5756d78
--- /dev/null
+++ b/src/components/pages/ErrorPage.svelte
@@ -0,0 +1,23 @@
+<script lang="ts" context="module">
+ import type { ErrorPage } from '~/jet/models';
+</script>
+
+<script lang="ts">
+ import SharedErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: ErrorPage;
+
+ const i18n = getI18n();
+</script>
+
+<div class="error-page-container">
+ <SharedErrorPage translateFn={$i18n.t} error={page.error} />
+</div>
+
+<style>
+ .error-page-container :global(.page-error) {
+ /* -50px compensates for the global footer */
+ top: calc(50% - 50px);
+ }
+</style>
diff --git a/src/components/pages/ProductPage.svelte b/src/components/pages/ProductPage.svelte
new file mode 100644
index 0000000..30b0ad8
--- /dev/null
+++ b/src/components/pages/ProductPage.svelte
@@ -0,0 +1,77 @@
+<script lang="ts">
+ import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models';
+ import { isFlowAction } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import DefaultPage, {
+ type DefaultPageRequirements,
+ } from '~/components/pages/DefaultPage.svelte';
+ import MarkerShelf from '~/components/jet/shelf/MarkerShelf.svelte';
+ import ProductPageArcadeFooter from '~/components/ProductPageArcadeFooter.svelte';
+ import { getProductPageShelvesWithExpandedMedia } from '~/utils/shelves';
+ import { setAccessibilityLayoutContext } from '~/context/accessibility-layout';
+ import { getJet } from '~/jet';
+ import { isProductPageLinkShelf } from '~/components/jet/shelf/ProductPageLinkShelf.svelte';
+ import { isEulaPageIntent } from '@jet-app/app-store/api/intents/eula-page-intent';
+ export let page: ShelfBasedProductPage & WebRenderablePage;
+
+ const jet = getJet();
+
+ $: ({ presentationOptions, webNavigation } = page);
+
+ $: shelves = getProductPageShelvesWithExpandedMedia(page);
+
+ let defaultPageRequirements: DefaultPageRequirements;
+
+ $: defaultPageRequirements = {
+ shelves,
+ presentationOptions,
+ webNavigation,
+ };
+
+ // Set up accessibility layout context for neighbor shelf detection
+ $: {
+ setAccessibilityLayoutContext({ shelves });
+
+ /**
+ * We suppport "deep linking" to the product page with the License Agreement modal open by
+ * default, based on the presence of the `lic` query parameter. No other modals support
+ * opening via deep link, which is why there isn't a more robust solution for this use case.
+ * Instead, we are just firing off the click action from the license agreement shelf.
+ */
+ if (page.canonicalURL) {
+ const canonicalUrl = new URL(page.canonicalURL);
+ const hasLic = canonicalUrl.searchParams.has('lic');
+
+ if (hasLic && shelves) {
+ const eulaItem = shelves
+ .find(isProductPageLinkShelf)
+ ?.items.find(
+ ({ clickAction }) =>
+ isFlowAction(clickAction) &&
+ clickAction.destination &&
+ isEulaPageIntent(clickAction.destination),
+ );
+
+ if (eulaItem) {
+ jet.perform(eulaItem.clickAction);
+ }
+ }
+ }
+ }
+
+ // TODO: replace with `supportsArcade` from Jet
+ // rdar://143706610 (Support `supportsArcade` attribute)
+ $: supportsArcade =
+ page.lockup.offerDisplayProperties?.offerType === 'arcadeApp';
+</script>
+
+<DefaultPage page={defaultPageRequirements}>
+ <svelte:fragment slot="marker-shelf" let:shelf>
+ <MarkerShelf {shelf} {page} />
+ </svelte:fragment>
+</DefaultPage>
+
+{#if supportsArcade}
+ <ProductPageArcadeFooter />
+{/if}
diff --git a/src/components/pages/SearchLandingPage.svelte b/src/components/pages/SearchLandingPage.svelte
new file mode 100644
index 0000000..3594ece
--- /dev/null
+++ b/src/components/pages/SearchLandingPage.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+ import type { SearchLandingPage } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+ import { unwrapOptional as unwrap } from '@jet/environment/types/optional';
+
+ type SearchPage = SearchLandingPage;
+
+ import DefaultPage from './DefaultPage.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SearchInput from '~/components/navigation/SearchInput.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: SearchPage;
+
+ const i18n = getI18n();
+
+ $: webNavigation = unwrap((page as WebRenderablePage).webNavigation);
+ $: searchAction = webNavigation.searchAction as WebSearchFlowAction;
+ $: hasShelves = !!page.shelves.filter(({ items }) => items?.length).length;
+
+ $: pageWithoutEmptyShelves = {
+ ...page,
+ shelves: hasShelves ? page.shelves : [],
+ title: $i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'),
+ };
+</script>
+
+<DefaultPage page={pageWithoutEmptyShelves}>
+ <ShelfWrapper slot="before-shelves" centered>
+ <SearchInput {searchAction} big />
+ </ShelfWrapper>
+</DefaultPage>
diff --git a/src/components/pages/SearchResultsPage.svelte b/src/components/pages/SearchResultsPage.svelte
new file mode 100644
index 0000000..c17b644
--- /dev/null
+++ b/src/components/pages/SearchResultsPage.svelte
@@ -0,0 +1,113 @@
+<script lang="ts">
+ import type { SearchResultsPage } from '@jet-app/app-store/api/models';
+
+ import type { Size } from '@amp/web-app-components/src/types';
+ import { ShelfConfig } from '@amp/web-app-components/config/components/shelf';
+
+ import DefaultPage from './DefaultPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import mediaQueries from '~/utils/media-queries';
+ import {
+ isSearchResultShelf,
+ isRenderableInSearchResultsShelf,
+ } from '~/components/jet/shelf/SearchResultShelf.svelte';
+ import { getPlatformFromPage } from '~/utils/seo/common';
+
+ export let page: SearchResultsPage;
+
+ const i18n = getI18n();
+
+ $: resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null;
+
+ $: renderableItems = (resultsShelf?.items ?? []).filter(
+ isRenderableInSearchResultsShelf,
+ );
+
+ $: columnConfig = ShelfConfig.get().GRID_VALUES.SearchResult;
+ $: numberOfColumns = columnConfig[$mediaQueries as Size] || 3;
+ $: numberOfRows = Math.ceil(renderableItems.length / numberOfColumns);
+ $: middleRow = Math.floor(numberOfRows / 2);
+ $: insertAt = middleRow * numberOfColumns;
+
+ /**
+ * This is unfortunate but only these three platforms support the transparency link.
+ * This link is enabled via the `transparencyLawEditorialItemId` bag key, but when defining
+ * bag keys, we do not have access to the platform being viewed, so we can't opt-out there.
+ * We could do this platform check in the Jet layer, but adding two forms of opting into this
+ * link felt cumbersome and unintuitive, so we can just do it here.
+ */
+ $: transparencyLink =
+ page.transparencyLink &&
+ ['iphone', 'ipad', 'mac'].includes(
+ getPlatformFromPage(page).toLowerCase(),
+ );
+
+ /**
+ * Here we are building constructing a new array of shelves _if_ there is a result shelf _and_
+ * a transparency link. This creates three shelves:
+ * 1) the search results before the transparency banner in the linkable text shelf
+ * 2) the transparency banner
+ * 3) the search results after the transparency banner
+ */
+ $: shelves = resultsShelf
+ ? transparencyLink && renderableItems.length
+ ? [
+ insertAt > 0 && {
+ ...resultsShelf,
+ items: renderableItems.slice(0, insertAt),
+ title: null,
+ isValid: () => true,
+ },
+ {
+ contentType: 'linkableText',
+ items: [page.transparencyLink],
+ },
+ {
+ ...resultsShelf,
+ items: renderableItems.slice(insertAt),
+ title: null,
+ isValid: () => true,
+ },
+ ]
+ : [{ ...resultsShelf, items: renderableItems, title: null }]
+ : [];
+</script>
+
+<DefaultPage
+ page={{
+ shelves,
+ title: renderableItems.length > 0 ? resultsShelf?.title : null,
+ }}
+>
+ <svelte:fragment slot="before-shelves">
+ {#if renderableItems.length === 0}
+ <div>
+ <h1>
+ {$i18n.t('ASE.Web.AppStore.Search.NoResults.FirstLine')}
+ </h1>
+ <p>
+ {$i18n.t('ASE.Web.AppStore.Search.NoResults.SecondLine', {
+ term: page.searchTermContext?.term,
+ })}
+ </p>
+ </div>
+ {/if}
+ </svelte:fragment>
+</DefaultPage>
+
+<style>
+ div {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 3px;
+ height: 70vh;
+ margin: var(--bodyGutter);
+ }
+
+ p {
+ font: var(--title-3);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/pages/SeeAllPage.svelte b/src/components/pages/SeeAllPage.svelte
new file mode 100644
index 0000000..d401f32
--- /dev/null
+++ b/src/components/pages/SeeAllPage.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ import type { SeeAllPage } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import { getProductPageShelvesForOrdering } from '~/utils/shelves';
+ import { setAccessibilityLayoutContext } from '~/context/accessibility-layout';
+ import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte';
+ import { isProductReviewShelf } from '~/components/jet/shelf/ProductReviewShelf.svelte';
+ import { isProductRatingsShelf } from '~/components/jet/shelf/ProductRatingsShelf.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let page: SeeAllPage & WebRenderablePage;
+
+ $: shelves = getProductPageShelvesForOrdering(page, 'notPurchasedOrdering')
+ .filter((shelf) => {
+ const isShelfForReviewPage =
+ isProductReviewShelf(shelf) || isProductRatingsShelf(shelf);
+
+ return (
+ isSmallLockupShelf(shelf) ||
+ (isShelfForReviewPage && page.seeAllType === 'reviews')
+ );
+ })
+ .map((shelf) => {
+ shelf.isHorizontal = false;
+ shelf.seeAllAction = null;
+ return shelf;
+ });
+
+ $: {
+ setAccessibilityLayoutContext({ shelves });
+ }
+</script>
+
+<DefaultPage page={{ shelves, title: null }}>
+ <svelte:fragment slot="before-shelves">
+ <h1>
+ <LinkWrapper action={page.lockup.clickAction}>
+ {page.lockup.title}
+ </LinkWrapper>
+ </h1>
+ </svelte:fragment>
+</DefaultPage>
+
+<style>
+ h1 {
+ font: var(--title-1);
+ color: var(--keyColor);
+ margin: 13px var(--bodyGutter) 0;
+ }
+
+ h1 :global(a:hover) {
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/components/pages/StaticMessagePage.svelte b/src/components/pages/StaticMessagePage.svelte
new file mode 100644
index 0000000..45c1a36
--- /dev/null
+++ b/src/components/pages/StaticMessagePage.svelte
@@ -0,0 +1,113 @@
+<script lang="ts" context="module">
+ import type { StaticMessagePage } from '~/jet/models';
+</script>
+
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: StaticMessagePage;
+
+ const i18n = getI18n();
+</script>
+
+<div class="static-message-page-container">
+ <div class="static-message-text-wrapper">
+ {#if page.titleLocKey}
+ <h1>{$i18n.t(page.titleLocKey)}</h1>
+ {/if}
+
+ <section>
+ {#if page.contentType === 'win-back' || page.contentType === 'contingent-price'}
+ <p>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Subhead')}
+ </p>
+
+ <p>
+ <b>
+ {$i18n.t('ASE.Web.AppStore.WinBack.DirectionalTitle')}
+ </b>
+ </p>
+
+ <ul>
+ <li>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Update.iOS')}
+ </li>
+ <li>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Update.macOS')}
+ </li>
+ </ul>
+
+ <p>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Body')}
+ </p>
+ {:else if page.contentType === 'carrier'}
+ <p class="carrier__instructions">
+ {$i18n.t('ASE.Web.AppStore.Carrier.Update.iOS')}
+ </p>
+ <p>
+ {$i18n.t('ASE.Web.AppStore.Carrier.Body')}
+ </p>
+ {:else if page.contentType === 'invoice'}
+ <p class="invoice__instructions">
+ {$i18n.t('ASE.Web.AppStore.Invoice.Body')}
+ </p>
+ {/if}
+ </section>
+ </div>
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .static-message-page-container {
+ display: flex;
+ flex-grow: 1;
+ width: 100%;
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ align-items: center;
+ }
+
+ @media (--range-sidebar-visible-up) {
+ .static-message-page-container {
+ height: 100%;
+ }
+ }
+
+ .static-message-text-wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-start;
+ width: auto;
+ margin: 0 auto;
+ }
+
+ .static-message-page-container h1 {
+ padding: 13px var(--bodyGutter) 0;
+ font: var(--header-emphasized);
+ color: var(--systemPrimary, #000);
+ position: relative;
+ z-index: 1;
+ margin-bottom: 16px;
+ }
+
+ .static-message-page-container section {
+ margin: 0 var(--bodyGutter);
+ font: var(--title-3);
+ }
+
+ .static-message-page-container li {
+ list-style-type: disc;
+ }
+
+ .static-message-page-container p,
+ .static-message-page-container ul {
+ margin-bottom: 16px;
+ text-wrap: pretty;
+ }
+
+ .static-message-page-container ul {
+ padding-inline-start: 1em;
+ }
+</style>
diff --git a/src/components/pages/TodayPage.svelte b/src/components/pages/TodayPage.svelte
new file mode 100644
index 0000000..3d38932
--- /dev/null
+++ b/src/components/pages/TodayPage.svelte
@@ -0,0 +1,22 @@
+<!--
+@component
+Page component for the "Today Page"
+
+This is required so that the correct layout of the cards within each `TodayCardShelf`
+can be computed at the page level, as the algorithm for stretching the correct cards
+in each shelf requires knowledge of the previously-rendered shelf
+-->
+<script lang="ts">
+ import type { TodayPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import { setTodayCardLayoutContext } from '~/context/today-card-layout';
+
+ export let page: TodayPage;
+
+ $: {
+ setTodayCardLayoutContext(page);
+ }
+</script>
+
+<DefaultPage {page} />
diff --git a/src/components/pages/TopChartsPage.svelte b/src/components/pages/TopChartsPage.svelte
new file mode 100644
index 0000000..4a3e7b7
--- /dev/null
+++ b/src/components/pages/TopChartsPage.svelte
@@ -0,0 +1,218 @@
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+ import { isSome } from '@jet/environment/types/optional';
+ import type { TopChartsPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import Shelf from '~/components/Shelf/Wrapper.svelte';
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import Menu from '~/components/Menu.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+
+ export let page: TopChartsPage;
+
+ const i18n = getI18n();
+
+ $: ({ categories, categoriesButtonTitle, segments, initialSegmentIndex } =
+ page);
+ $: segment = segments[initialSegmentIndex];
+</script>
+
+<DefaultPage page={{ shelves: segment.shelves, title: page.title }}>
+ <Shelf slot="before-shelves" centered>
+ <header>
+ <div class="dropdown-container">
+ {#if categoriesButtonTitle}
+ <Menu options={categories}>
+ <svelte:fragment slot="trigger">
+ <span class="menu-trigger-contents">
+ {categoriesButtonTitle}
+
+ <SFSymbol name="chevron.down" />
+ </span>
+ </svelte:fragment>
+
+ <svelte:fragment slot="option" let:option>
+ {@const { artwork, chartSelectAction, name } =
+ option}
+
+ <LinkWrapper action={chartSelectAction}>
+ <div
+ class="category-menu-item"
+ class:active={name ===
+ categoriesButtonTitle}
+ >
+ {#if isSome(artwork)}
+ <div class="artwork-container">
+ <Artwork
+ {artwork}
+ profile={getNaturalProfile(
+ artwork,
+ [24],
+ )}
+ />
+ </div>
+ {/if}
+
+ <span>{name}</span>
+ </div>
+ </LinkWrapper>
+ </svelte:fragment>
+ </Menu>
+ {/if}
+ </div>
+
+ <div class="segment-selector" aria-label={categoriesButtonTitle}>
+ {#each segments as segment, index}
+ {@const { segmentSelectAction } = segment}
+ {@const isSelected = initialSegmentIndex === index}
+ {@const filterLabel = $i18n.t(
+ isSelected
+ ? 'ASE.Web.AppStore.SelectedFilterApps.AX.Label'
+ : 'ASE.Web.AppStore.FilterApps.AX.Label',
+ { filterName: segment.shortName },
+ )}
+
+ <LinkWrapper
+ action={segmentSelectAction}
+ label={filterLabel}
+ >
+ <span class="segment" class:selected={isSelected}>
+ {segment.shortName}
+ </span>
+ </LinkWrapper>
+ {/each}
+ </div>
+ </header>
+ </Shelf>
+</DefaultPage>
+
+<style>
+ header {
+ --pill-button-border-radius: 1000px; /* Arbitrary large value for "pill-style" rounded sides */
+ --menu-item-padding: 0;
+ --menu-item-margin: 0 0 8px 0;
+ --menu-popover-padding: 12px 16px;
+ --menu-common-padding: 0;
+ --menu-trigger-border-radius: var(--pill-button-border-radius);
+ --menu-trigger-background-color: var(--systemPrimary-onDark);
+ --menu-trigger-padding: 6px 16px;
+ --menu-trigger-font: var(--body-semibold-tall);
+ --menu-popover-background-color: white;
+ --menu-popover-box-shadow: 10px 10px 10px 0
+ var(--systemQuaternary-onLight);
+ --menu-popover-border-radius: 14px;
+ --menu-popover-border: 1px solid var(--systemQuaternary);
+ --menu-popover-z-index: calc(var(--z-web-chrome) + 1);
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+
+ @media (--range-small-up) {
+ display: grid;
+ align-items: center;
+ justify-items: start;
+ grid-template-columns: 1fr max-content 1fr;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ --menu-trigger-background-color: var(--systemQuaternary);
+ --menu-popover-background-color: var(--systemQuaternary-vibrant);
+ }
+ }
+
+ .segment-selector {
+ display: flex;
+ justify-self: end;
+ gap: 4px;
+ padding: 2px;
+ background: var(--systemQuaternary);
+ border-radius: var(--pill-button-border-radius);
+
+ @media (--range-small-up) {
+ align-items: center;
+ justify-self: center;
+ grid-column: 2;
+ }
+ }
+
+ .segment-selector :global(a) {
+ display: contents;
+ }
+
+ .segment {
+ border-radius: var(--pill-button-border-radius);
+ font: var(--body-semibold-tall);
+ padding: 6px 16px;
+ }
+
+ .segment.selected {
+ background-color: var(--systemPrimary-onDark);
+ color: var(--systemPrimary);
+
+ @media (prefers-color-scheme: dark) {
+ background-color: var(--systemQuaternary);
+ }
+ }
+
+ .dropdown-container {
+ justify-self: start;
+
+ @media (--range-small-up) {
+ grid-column: 1;
+ }
+ }
+
+ .menu-trigger-contents {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .menu-trigger-contents :global(svg) {
+ height: 0.7em;
+ }
+
+ .menu-trigger-contents :global(path:not([fill='none'])) {
+ fill: currentColor;
+ }
+
+ .category-menu-item {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ border-radius: 10px;
+ height: 40px;
+ transition: background 150ms ease-in;
+ }
+
+ .category-menu-item.active {
+ background: var(--systemQuinary);
+ }
+
+ .category-menu-item:not(.active):hover {
+ background: rgba(0, 0, 0, 0.035);
+ }
+
+ .artwork-container {
+ width: 24px;
+ margin-inline-end: 8px;
+ flex-shrink: 0;
+ }
+
+ .category-menu-item span {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .dropdown-container :global(.menu-popover) {
+ max-width: 600px;
+ width: 100%;
+ column-count: 2;
+
+ @media (--range-medium-up) {
+ column-count: 3;
+ }
+ }
+</style>
diff --git a/src/components/pages/VisionProPage.svelte b/src/components/pages/VisionProPage.svelte
new file mode 100644
index 0000000..c87ee09
--- /dev/null
+++ b/src/components/pages/VisionProPage.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import type { GenericPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from './DefaultPage.svelte';
+ import VisionProFooter from '~/components/structure/VisionProFooter.svelte';
+
+ export let page: GenericPage;
+</script>
+
+<DefaultPage {page} />
+
+<VisionProFooter />
diff --git a/src/components/structure/Fonts.svelte b/src/components/structure/Fonts.svelte
new file mode 100644
index 0000000..63af7b6
--- /dev/null
+++ b/src/components/structure/Fonts.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { BASE, getFontURL } from '@amp/web-apps-fonts';
+
+ export let language: string;
+
+ $: fontURL = getFontURL(language);
+</script>
+
+<svelte:head>
+ <link rel="preconnect" href={BASE} crossorigin="anonymous" />
+
+ <link
+ rel="stylesheet"
+ as="style"
+ href={fontURL}
+ type="text/css"
+ referrerpolicy="strict-origin-when-cross-origin"
+ />
+</svelte:head>
diff --git a/src/components/structure/Footer.svelte b/src/components/structure/Footer.svelte
new file mode 100644
index 0000000..ceabfec
--- /dev/null
+++ b/src/components/structure/Footer.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+ import Footer, {
+ type Translate,
+ } from '@amp/web-app-components/src/components/Footer/Footer.svelte';
+ import LocaleSwitcherButton from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte';
+ import { items } from '~/constants/footer-items';
+ import { getLocale } from '~/utils/locale';
+ import {
+ regions,
+ languages,
+ storefrontNameTranslations,
+ } from '~/utils/storefront-data';
+
+ const i18n = getI18n();
+ const locale = getLocale();
+
+ const translate: Translate = (key, options) => $i18n.t(key, options);
+</script>
+
+<section class="footer-container">
+ <Footer footerItems={items} translateFn={translate}>
+ <LocaleSwitcherButton
+ slot="secondary-content"
+ translateFn={translate}
+ {regions}
+ {languages}
+ {locale}
+ {storefrontNameTranslations}
+ defaultRoute="iphone/today"
+ />
+ </Footer>
+</section>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .footer-container {
+ background-color: var(--footerBg);
+ }
+
+ .footer-container :global(footer) {
+ max-width: calc(viewport-content-for(xlarge));
+ margin: 0 auto;
+ }
+</style>
diff --git a/src/components/structure/MetaTags.svelte b/src/components/structure/MetaTags.svelte
new file mode 100644
index 0000000..11b9477
--- /dev/null
+++ b/src/components/structure/MetaTags.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { Organization, WithContext } from 'schema-dts';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import MetaTags from '@amp/web-app-components/src/components/MetaTags/MetaTags.svelte';
+ import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+ import { getLocale } from '@amp/web-app-components/src/utils/internal/locale';
+ import { getPageDir } from '@amp/web-apps-localization/src';
+
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: WebRenderablePage;
+
+ const i18n = getI18n();
+ const locale = getLocale();
+
+ const organizationSchema: WithContext<Organization> = {
+ '@context': 'https://schema.org',
+ '@id': 'https://apps.apple.com/#organization',
+ '@type': 'Organization',
+ name: 'App Store',
+ url: 'https://apps.apple.com',
+ logo: 'https://apps.apple.com/assets/app-store.png',
+ sameAs: [
+ 'https://www.wikidata.org/wiki/Q368215',
+ 'https://twitter.com/AppStore',
+ 'https://www.instagram.com/appstore/',
+ 'https://www.facebook.com/appstore/',
+ ],
+ parentOrganization: {
+ '@type': 'Organization',
+ name: 'Apple',
+ '@id': 'https://www.apple.com/#organization',
+ url: 'https://www.apple.com/',
+ },
+ };
+
+ // This cast of `.seoData` is technically a little risky, but our app fully
+ // defines this property, which should make it fairly safe. Whatever is returned
+ // for the page from the `SEO` dependency on the Object Graph will be the value
+ // reflected here.
+ $: seoData = (page.seoData as Opt<SeoData>) ?? undefined;
+
+ // Provide default title for pages not yet set up with SEO data
+ $: defaultTitle = $i18n.t('ASE.Web.AppStore.Meta.SiteName');
+ $: pageDir = getPageDir(locale.language) ?? 'ltr';
+</script>
+
+<MetaTags
+ {defaultTitle}
+ {locale}
+ {pageDir}
+ {seoData}
+ origin={'https://apps.apple.com/'}
+>
+ <svelte:fragment slot="schemaOrganizationData">
+ {#if import.meta.env.SSR}
+ <svelte:element
+ this="script"
+ id="organization"
+ type="application/ld+json"
+ >
+ {JSON.stringify(organizationSchema)}
+ </svelte:element>
+ {/if}
+ </svelte:fragment>
+</MetaTags>
diff --git a/src/components/structure/VisionProFooter.svelte b/src/components/structure/VisionProFooter.svelte
new file mode 100644
index 0000000..59dcd5b
--- /dev/null
+++ b/src/components/structure/VisionProFooter.svelte
@@ -0,0 +1,142 @@
+<script lang="ts">
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import Grid from '~/components/Grid.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getLocale } from '~/utils/locale';
+
+ const locale = getLocale();
+ const i18n = getI18n();
+
+ let links: Record<string, string>;
+
+ function getAboutAppStoreUrl(storefront: string, language: string) {
+ let storefrontSlug = `${storefront}/`;
+
+ if (storefront === 'us') {
+ storefrontSlug = '';
+ } else if (storefront === 'gb') {
+ // The UK "About App Store" link is https://www.apple.com/uk/app-store/, not https://www.apple.com/gb/app-store/.
+ storefrontSlug = 'uk/';
+ } else if (storefront === 'ae' && language === 'ar') {
+ storefrontSlug = 'ae-ar/';
+ }
+
+ return `https://www.apple.com/${storefrontSlug}app-store/`;
+ }
+
+ $: storefront = locale.storefront;
+ $: links = {
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutAppStore':
+ getAboutAppStoreUrl(storefront, locale.language),
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutPurchases': `https://apps.apple.com/${storefront}/story/id1436214772`,
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.RequestRefund': `https://www.apple.com/${storefront}/shop/goto/help/sales_refunds`,
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.PaymentMethods': `https://support.apple.com/118429`,
+ };
+
+ $: if (storefront === 'fr') {
+ links[
+ 'AppStore.QuickLinks.AboutFrenchAppStore.Title'
+ ] = `https://apps.apple.com/${storefront}/story/1700848501`;
+ }
+</script>
+
+<ShelfWrapper centered={false} withBottomPadding={false}>
+ <section data-test-id="vision-footer">
+ <p class="blurb">
+ {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Blurb')}
+ </p>
+
+ <article class="quick-links-container">
+ <ShelfTitle
+ title={$i18n.t('ASE.Web.AppStore.VisionPro.Footer.LinksTitle')}
+ />
+
+ <navigation>
+ <Grid
+ items={Object.entries(links)}
+ gridType="FooterLink"
+ let:item
+ >
+ {@const [title, href] = item}
+ <a {href}>{$i18n.t(title)}</a>
+ </Grid>
+ </navigation>
+ </article>
+
+ <article class="disclaimer-container">
+ <p>
+ {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Disclaimer')}
+ </p>
+ </article>
+ </section>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ section {
+ font: var(--body-tall);
+ }
+
+ .blurb {
+ flex-grow: 1;
+ width: 100%;
+ max-width: calc(viewport-content-for(xlarge) * 0.66);
+ margin: 40px auto 50px;
+ padding: 0 var(--shelfGridPaddingInline, 40px);
+ text-align: center;
+ }
+
+ .quick-links-container {
+ max-width: viewport-content-for(xlarge);
+ margin: 50px auto;
+ padding: 0 var(--bodyGutter);
+ }
+
+ a {
+ display: block;
+ padding: var(--grid-column-gap-medium) 0 var(--grid-column-gap-medium);
+ word-break: break-all;
+ font: var(--title-2);
+ color: var(--keyColor);
+ border-bottom: 1px solid var(--systemQuinary);
+
+ @media (--range-xsmall-down) {
+ padding: var(--grid-column-gap-xsmall) 0
+ var(--grid-column-gap-xsmall);
+ }
+ }
+
+ @media (--range-medium-up) {
+ .quick-links-container li:nth-child(n + 4) a {
+ border-bottom: none;
+ }
+ }
+
+ @media (--small) {
+ .quick-links-container li:nth-child(n + 5) a {
+ border-bottom: none;
+ }
+ }
+
+ @media (--range-xsmall-down) {
+ .quick-links-container li:last-child a {
+ border-bottom: none;
+ }
+ }
+
+ .disclaimer-container {
+ flex-grow: 1;
+ width: 100%;
+ color: var(--systemTertiary);
+ background-color: var(--footerBg);
+ }
+
+ .disclaimer-container p {
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ padding: 32px var(--bodyGutter, 40px);
+ }
+</style>
diff --git a/src/config/build.ts b/src/config/build.ts
new file mode 100644
index 0000000..d64f14b
--- /dev/null
+++ b/src/config/build.ts
@@ -0,0 +1 @@
+export const BUILD = process.env.VERSION as string;
diff --git a/src/config/components/artwork.ts b/src/config/components/artwork.ts
new file mode 100644
index 0000000..49c0f8e
--- /dev/null
+++ b/src/config/components/artwork.ts
@@ -0,0 +1,163 @@
+import { ASPECT_RATIOS } from '@amp/web-app-components/src/components/Artwork/constants';
+import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
+import type { ArtworkProfileMap } from '@amp/web-app-components/config/components/artwork';
+import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+
+const { HD, ONE, THREE_QUARTERS, HD_ASPECT_RATIO } = ASPECT_RATIOS;
+
+const AppIconSize = {
+ DEFAULT: [48],
+ SMALL: [64],
+ MEDIUM: [100],
+ LARGE: [200],
+ XLARGE: [800],
+};
+
+const cardSizes = [525, 525, 490, 394, 738];
+const brickSizes = [520, 400, 450, 450, 300];
+const heroSizes = [1600, 1240, 920, 920, 490];
+
+export type NamedProfile =
+ | 'app-event-detail'
+ | 'app-event-detail-small'
+ | 'app-icon'
+ | 'app-icon-large'
+ | 'app-icon-medium'
+ | 'app-icon-small'
+ | 'app-icon-xlarge'
+ | 'app-icon-pill'
+ | 'app-icon-large-pill'
+ | 'app-icon-medium-pill'
+ | 'app-icon-small-pill'
+ | 'app-icon-river-pill'
+ | 'app-icon-tv-rect'
+ | 'app-icon-large-tv-rect'
+ | 'app-icon-xlarge-tv-rect'
+ | 'app-icon-medium-tv-rect'
+ | 'app-icon-small-tv-rect'
+ | 'app-icon-river-tv-rect'
+ | 'app-icon-river'
+ | 'app-promotion'
+ | 'app-promotion-in-article'
+ | 'app-trailer-lockup-video'
+ | 'brick'
+ | 'brick-app-icon'
+ | 'card'
+ | 'card-horizontal'
+ | 'category-brick'
+ | 'editorial-story-card'
+ | 'in-app-purchase'
+ | 'large-brick'
+ | 'large-hero'
+ | 'large-hero-portrait'
+ | 'large-hero-portrait-iphone'
+ | 'large-hero-breakout'
+ | 'large-hero-breakout-rtl'
+ | 'large-hero-west'
+ | 'large-hero-east'
+ | 'large-hero-story-card'
+ | 'large-hero-story-card-portrait'
+ | 'large-hero-story-card-rtl'
+ | 'large-image-lockup'
+ | 'poster-lockup'
+ | 'poster-title'
+ | 'medium-story-card'
+ | 'screenshot-vision'
+ | 'screenshot-phone'
+ | 'screenshot-phone_portrait'
+ | 'screenshot-iphone_5_8'
+ | 'screenshot-iphone_5_8_portrait'
+ | 'screenshot-iphone_6_5'
+ | 'screenshot-iphone_6_5_portrait'
+ | 'screenshot-iphone_d74'
+ | 'screenshot-iphone_d74_portrait'
+ | 'screenshot-mac'
+ | 'screenshot-tv'
+ | 'screenshot-pad'
+ | 'screenshot-pad-portrait'
+ | 'screenshot-watch'
+ | 'small-brick'
+ | 'small-story-card-portrait'
+ | 'small-story-card'
+ | 'small-story-card-legacy'
+ | 'uber-shelf';
+
+const PROFILES: ArtworkProfileMap<NamedProfile> = new Map([
+ ['app-event-detail', [[480, 336, 336], 9 / 16, 'sr']],
+ ['app-event-detail-small', [[480, 336, 336], HD_ASPECT_RATIO, 'sr']],
+ ['app-icon', [AppIconSize.DEFAULT, ONE, 'bb']],
+ ['app-icon-large', [AppIconSize.LARGE, ONE, 'bb']],
+ ['app-icon-medium', [AppIconSize.MEDIUM, ONE, 'bb']],
+ ['app-icon-small', [AppIconSize.SMALL, ONE, 'bb']],
+ ['app-icon-xlarge', [AppIconSize.XLARGE, ONE, 'bb']],
+ ['app-icon-pill', [AppIconSize.DEFAULT, 4 / 3, 'sr']],
+ ['app-icon-large-pill', [AppIconSize.LARGE, 4 / 3, 'sr']],
+ ['app-icon-medium-pill', [AppIconSize.MEDIUM, 4 / 3, 'sr']],
+ ['app-icon-small-pill', [AppIconSize.SMALL, 4 / 3, 'sr']],
+ ['app-icon-tv-rect', [AppIconSize.DEFAULT, HD, 'sr']],
+ ['app-icon-large-tv-rect', [AppIconSize.LARGE, HD, 'sr']],
+ ['app-icon-xlarge-tv-rect', [AppIconSize.XLARGE, HD, 'sr']],
+ ['app-icon-medium-tv-rect', [AppIconSize.MEDIUM, HD, 'sr']],
+ ['app-icon-small-tv-rect', [AppIconSize.SMALL, HD, 'bb']],
+ ['app-icon-river-tv-rect', [[128, 128, 128, 88, 88], HD, 'bb']],
+ ['app-icon-river', [[128, 128, 128, 88, 88], ONE, 'bb']],
+ ['app-icon-river-pill', [[128, 128, 128, 88, 88], 4 / 3, 'sr']],
+ ['app-promotion', [[385, 330, 400, 450, 298], 16 / 9, 'sr']],
+ ['app-promotion-in-article', [[800, 600], 16 / 9, 'sr']],
+ ['app-trailer-lockup-video', [[385, 330, 400, 450, 298], 16 / 10, 'sr']],
+ ['brick', [brickSizes, HD, 'sr']],
+ ['brick-app-icon', [[83, 60, 60, 60, 50], ONE, 'bb']],
+ ['card', [cardSizes, THREE_QUARTERS, 'sr']],
+ ['card-horizontal', [[1020], HD, 'sr']],
+ ['category-brick', [[368, 368, 368, 208, 288], HD, 'sr']],
+ ['editorial-story-card', [[385, 400, 450], THREE_QUARTERS, 'sr']],
+ ['in-app-purchase', [[153, 160, 160, 140, 168], ONE, 'sr']],
+ ['large-brick', [[520, 610, 450, 298, 298], HD, 'sr']],
+ ['large-hero', [heroSizes, HD, 'sr']],
+ ['large-hero-portrait', [[430], 9 / 16, 'sr']],
+ ['large-hero-portrait-iphone', [[430], THREE_QUARTERS, 'SH.ApCSC01']],
+ ['large-hero-west', [heroSizes, 2.79, 'grav.west']],
+ ['large-hero-east', [heroSizes, 2.79, 'grav.east']],
+ ['large-hero-story-card', [heroSizes, 2.25, 'CC.ApSHW01']],
+ ['large-hero-story-card-rtl', [heroSizes, 2.25, 'sr']],
+ ['large-hero-story-card-portrait', [cardSizes, 3 / 4, 'CC.ApSHT01']],
+ ['large-hero-breakout', [heroSizes, 8 / 3, 'sr']],
+ ['large-image-lockup', [[790, 610, 919, 298, 298], HD, 'sr']],
+ ['poster-lockup', [[520, 520, 400, 450, 300], HD, 'sr']],
+ ['poster-title', [[400, 300, 200], HD, 'bb']],
+ [
+ 'large-hero-breakout-rtl',
+ [[1600, 1240, 920, 920, 688], 8 / 3, 'gk' as CropCode],
+ ], // the `rtl` version of `large-hero-breakout` assets max out at 1344px wide
+ ['medium-story-card', [[298, 579, 490, 394], THREE_QUARTERS, 'sr']],
+ ['small-brick', [[300, 300, 300, 200, 300], HD, 'sr']],
+ ['small-story-card-portrait', [[182, 232, 215, 192], THREE_QUARTERS, 'sr']],
+ ['screenshot-vision', [[480, 480, 335, 520, 520], HD, 'sr']],
+ ['screenshot-phone', [[313, 643, 313, 480, 643], 20 / 9, 'w']],
+ ['screenshot-phone_portrait', [[230, 230, 157, 300, 300], 9 / 20, 'w']],
+ ['screenshot-iphone_5_8', [[313, 643, 313, 480, 643], 498 / 230, 'w']],
+ [
+ 'screenshot-iphone_5_8_portrait',
+ [[230, 230, 157, 300, 300], 230 / 498, 'w'],
+ ],
+ ['screenshot-iphone_6_5', [[313, 643, 313, 480, 643], 498 / 230, 'w']],
+ [
+ 'screenshot-iphone_6_5_portrait',
+ [[230, 230, 157, 300, 300], 230 / 498, 'w'],
+ ],
+ ['screenshot-iphone_d74', [[313, 643, 313, 480, 643], 466 / 215, 'w']],
+ [
+ 'screenshot-iphone_d74_portrait',
+ [[230, 230, 157, 300, 300], 215 / 466, 'w'],
+ ],
+ ['screenshot-mac', [[313, 643, 313, 480, 643], 16 / 10, 'w']],
+ ['screenshot-tv', [[313, 643, 313, 480, 643], HD, 'w']],
+ ['screenshot-pad', [[313, 643, 313, 480, 643], 4 / 3, 'w']],
+ ['screenshot-pad-portrait', [[480, 528, 313, 480, 643], 3 / 4, 'w']],
+ ['screenshot-watch', [[300, 157, 230, 230, 230], 4 / 5, 'w']],
+ ['small-story-card', [brickSizes, HD, 'CC.ApSHSC01']],
+ ['small-story-card-legacy', [brickSizes, HD, 'SCS.ApDPCS01']],
+ ['uber-shelf', [[1680, 1680, 1320, 1000, 390], 8 / 3, 'sr']],
+]);
+
+ArtworkConfig.set({ PROFILES });
diff --git a/src/config/components/shelf.ts b/src/config/components/shelf.ts
new file mode 100644
index 0000000..515bf0f
--- /dev/null
+++ b/src/config/components/shelf.ts
@@ -0,0 +1,208 @@
+import { ShelfConfig } from '@amp/web-app-components/config/components/shelf';
+
+const defaultShelfConfig = ShelfConfig.get();
+const { GRID_MAX_CONTENT, GRID_VALUES, GRID_ROW_GAP } = defaultShelfConfig;
+
+ShelfConfig.set({
+ ...defaultShelfConfig,
+ GRID_MAX_CONTENT: {
+ ...GRID_MAX_CONTENT,
+ Brick: GRID_MAX_CONTENT.A,
+ FooterLink: GRID_MAX_CONTENT.A,
+ InAppPurchaseLockup: GRID_MAX_CONTENT.C,
+ LargeBrick: GRID_MAX_CONTENT.A,
+ LargeLockup: GRID_MAX_CONTENT.C,
+ MediumLockup: GRID_MAX_CONTENT.A,
+ PosterLockup: GRID_MAX_CONTENT.A,
+ ScreenshotLarge: GRID_MAX_CONTENT.A,
+ ScreenshotVision: GRID_MAX_CONTENT.A,
+ ScreenshotPhone: GRID_MAX_CONTENT.G,
+ ScreenshotPad: GRID_MAX_CONTENT.A,
+ SearchLink: GRID_MAX_CONTENT.A,
+ SearchResult: GRID_MAX_CONTENT.A,
+ SmallLockup: GRID_MAX_CONTENT.A,
+ SmallLockupWithOrdinal: {},
+ SmallStoryCard: GRID_MAX_CONTENT.A,
+ ProductBadge: GRID_MAX_CONTENT.D,
+ },
+ GRID_VALUES: {
+ ...GRID_VALUES,
+ Brick: {
+ ...GRID_VALUES.A,
+ medium: 3,
+ },
+ InAppPurchaseLockup: {
+ xsmall: 3,
+ small: 5,
+ medium: 6,
+ large: 8,
+ xlarge: 8,
+ },
+ LargeBrick: {
+ ...GRID_VALUES.C,
+ small: 2,
+ medium: 2,
+ large: 3,
+ xlarge: 3,
+ },
+ LargeLockup: {
+ xsmall: 2,
+ small: 3,
+ medium: 4,
+ large: 5,
+ xlarge: 6,
+ },
+ MediumLockup: {
+ xsmall: 2,
+ small: 2,
+ medium: 4,
+ large: 5,
+ xlarge: 5,
+ },
+ PosterLockup: {
+ ...GRID_VALUES.A,
+ xsmall: 1,
+ large: 2,
+ },
+ ProductBadge: {
+ ...GRID_VALUES.D,
+ small: 5,
+ medium: 6,
+ },
+ SearchLink: {
+ xsmall: 1,
+ small: 2,
+ medium: 3,
+ large: 3,
+ xlarge: 3,
+ },
+ SearchResult: {
+ xsmall: 1,
+ small: 2,
+ medium: 3,
+ large: 3,
+ xlarge: 3,
+ },
+ FooterLink: {
+ xsmall: 1,
+ small: 2,
+ medium: 3,
+ large: 3,
+ xlarge: 3,
+ },
+ SmallLockup: {
+ xsmall: 2,
+ small: 2,
+ medium: 3,
+ large: 4,
+ xlarge: 4,
+ },
+ SmallLockupWithOrdinal: {
+ xsmall: 2,
+ small: 4,
+ medium: 5,
+ large: 6,
+ xlarge: 6,
+ },
+ SmallStoryCard: {
+ xsmall: 2,
+ small: 2,
+ medium: 2,
+ large: 2,
+ xlarge: 2,
+ },
+ ScreenshotLarge: {
+ xsmall: 1,
+ small: 2,
+ medium: 2,
+ large: 3,
+ xlarge: 3,
+ },
+ ScreenshotVision: {
+ xsmall: 1,
+ small: 1,
+ medium: 2,
+ large: 3,
+ xlarge: 3,
+ },
+ ScreenshotPhone: {
+ xsmall: 2,
+ small: 3,
+ medium: 4,
+ large: 5,
+ xlarge: 5,
+ },
+ ScreenshotPad: {
+ xsmall: 1,
+ small: 3,
+ medium: 4,
+ large: 4,
+ xlarge: 4,
+ },
+ },
+ GRID_ROW_GAP: {
+ ...GRID_ROW_GAP,
+ Brick: GRID_ROW_GAP.None,
+ FooterLink: GRID_ROW_GAP.None,
+ InAppPurchaseLockup: GRID_ROW_GAP.None,
+ LargeBrick: {
+ xsmall: 24,
+ small: 24,
+ medium: 24,
+ large: 24,
+ xlarge: 24,
+ },
+ LargeLockup: {
+ xsmall: 20,
+ small: 20,
+ medium: 20,
+ large: 20,
+ xlarge: 20,
+ },
+ MediumLockup: {
+ xsmall: 24,
+ small: 24,
+ medium: 24,
+ large: 24,
+ xlarge: 24,
+ },
+ PosterLockup: GRID_ROW_GAP.None,
+ ScreenshotLarge: GRID_ROW_GAP.None,
+ ScreenshotVision: GRID_ROW_GAP.None,
+ ScreenshotPhone: GRID_ROW_GAP.None,
+ ScreenshotPad: GRID_ROW_GAP.None,
+ SearchLink: {
+ xsmall: 10,
+ small: 20,
+ medium: 20,
+ large: 20,
+ xlarge: 20,
+ },
+ SearchResult: {
+ xsmall: 24,
+ small: 24,
+ medium: 24,
+ large: 24,
+ xlarge: 24,
+ },
+ SmallLockup: {
+ xsmall: 24,
+ small: 24,
+ medium: 24,
+ large: 24,
+ xlarge: 24,
+ },
+ SmallLockupWithOrdinal: {
+ xsmall: 24,
+ small: 24,
+ medium: 24,
+ large: 24,
+ xlarge: 24,
+ },
+ SmallStoryCard: GRID_ROW_GAP.None,
+ ProductBadge: GRID_ROW_GAP.None,
+ },
+ GRID_COL_GAP: {
+ ProductBadge: { small: '20', medium: '0', large: '0', xlarge: '0' },
+ },
+});
diff --git a/src/config/errorkit.ts b/src/config/errorkit.ts
new file mode 100644
index 0000000..4178680
--- /dev/null
+++ b/src/config/errorkit.ts
@@ -0,0 +1,17 @@
+import { BUILD } from './build';
+import type { ErrorKitConfig } from '@amp/web-apps-logger/src/errorkit';
+
+const APPS_PROD_SUBDOMAIN = ['apps'];
+const PROJECT_ID = 'onyx_apps';
+
+const getSentryEnv = () => {
+ const location =
+ typeof window !== 'undefined' && window.location.host.split('.')[0];
+ return APPS_PROD_SUBDOMAIN.includes(location) ? 'prod' : 'dev';
+};
+
+export const ERROR_KIT_CONFIG: ErrorKitConfig = {
+ project: PROJECT_ID,
+ environment: getSentryEnv(),
+ release: BUILD,
+};
diff --git a/src/config/hlsjs.ts b/src/config/hlsjs.ts
new file mode 100644
index 0000000..0551d94
--- /dev/null
+++ b/src/config/hlsjs.ts
@@ -0,0 +1,25 @@
+declare global {
+ interface Window {
+ Hls?: any;
+ }
+}
+
+/**
+ * Base URL for CDN hosting HLS.js files
+ */
+export const HLSJS_CDN = 'https://js-cdn.music.apple.com/hls.js';
+
+/**
+ * HLS.js version to load.
+ */
+export const HLSJS_VERSION = '2.820.0';
+
+/**
+ * Generate a URL for loading HLS.js.
+ */
+export function generateHLSJSURL(version?: string): URL {
+ // FIXME: Add a local storage override for the HLS.js version
+ version = version ?? HLSJS_VERSION;
+
+ return new URL(`${HLSJS_CDN}/${version}/hls.js/hls.js`);
+}
diff --git a/src/config/media-api/browser.ts b/src/config/media-api/browser.ts
new file mode 100644
index 0000000..91cf7c2
--- /dev/null
+++ b/src/config/media-api/browser.ts
@@ -0,0 +1 @@
+export const MEDIA_API_JWT = import.meta.env.BROWSER_MEDIA_API_JWT ?? '';
diff --git a/src/config/media-api/search-jwt.ts b/src/config/media-api/search-jwt.ts
new file mode 100644
index 0000000..1f7e3d2
--- /dev/null
+++ b/src/config/media-api/search-jwt.ts
@@ -0,0 +1,27 @@
+export function shouldUseSearchJWT(url: URL): boolean {
+ // We should only ever use the "search" JWT on the server
+ if (!import.meta.env.SSR) {
+ return false;
+ }
+
+ // Search API Endpoint
+ if (url.pathname.endsWith('/search')) {
+ return true;
+ }
+
+ // All other endpoints should use the default JWT
+ return false;
+}
+
+/**
+ * Creates the `Authorization` header using the App Store "search JWT"
+ *
+ * Note: this function specifically returns a bad value for a "browser"
+ * build so that the "search JWT" is removed from the browser payload
+ * by dead-code elimination
+ */
+export function makeSearchJWTAuthorizationHeader() {
+ return import.meta.env.SSR
+ ? { Authorization: `Bearer ${import.meta.env.SEARCH_MEDIA_API_JWT}` }
+ : { Authorization: '' };
+}
diff --git a/src/config/metrics.ts b/src/config/metrics.ts
new file mode 100644
index 0000000..b9ff0b4
--- /dev/null
+++ b/src/config/metrics.ts
@@ -0,0 +1,17 @@
+import { BUILD } from './build';
+
+const APP_NAME = 'com.apple.apps';
+const APP_DELEGATE = 'web-appstore-app';
+
+export const config = {
+ baseFields: {
+ appName: APP_NAME,
+ delegateApp: APP_DELEGATE,
+ appVersion: BUILD,
+ resourceRevNum: BUILD,
+ },
+ clickstream: {
+ constraintProfiles: ['AMPWeb'],
+ topic: 'xp_amp_appstore_unidentified',
+ },
+};
diff --git a/src/config/rtcjs.ts b/src/config/rtcjs.ts
new file mode 100644
index 0000000..f9e8a67
--- /dev/null
+++ b/src/config/rtcjs.ts
@@ -0,0 +1,103 @@
+import { platform } from '@amp/web-apps-utils';
+import { HLSJS_CDN, HLSJS_VERSION } from './hlsjs';
+
+declare global {
+ interface Window {
+ rtc?: any;
+ }
+}
+
+export type ReportingOptions = {
+ storeBagURL: string;
+ clientName: string;
+ serviceName: string;
+ applicationName: string;
+ applicationVersion: string;
+ browserName: string;
+ browserMajorVersion: string;
+ browserMinorVersion: string;
+ osName: string;
+ osVersion: string;
+};
+
+/**
+ * Generate a URL for loading HLS.js.
+ */
+export function generateRTCJSURL(version?: string): URL {
+ // FIXME: Add a local storage override for the HLS.js version
+ version = version ?? HLSJS_VERSION;
+
+ return new URL(`${HLSJS_CDN}/${version}/rtc.js/rtc.js`);
+}
+
+export function getRTCNamespace() {
+ if (window.rtc === undefined) {
+ throw new Error('Unable to load RTC library');
+ }
+
+ return window.rtc;
+}
+
+export function getReportingOptions(): ReportingOptions {
+ // FIXME: Add correct information for RTC reporting for Web App Store
+ return {
+ storeBagURL:
+ 'https://mediaservices.cdn-apple.com/store_bags/hlsjs/aasw/v1/rtc_storebag.json',
+
+ // Application
+ clientName: 'AASW',
+ serviceName: 'com.apple.apps.external',
+ applicationName: 'AppleAppStoreVWeb',
+ applicationVersion: 'WebAppStore/1.0.0',
+
+ // Browser
+ browserName: platform.clientName() ?? '',
+ browserMajorVersion: platform.majorVersion()?.toString() ?? '0',
+ browserMinorVersion: platform.minorVersion()?.toString() ?? '0',
+
+ // Operating System
+ osName: platform.osName() ?? '',
+ osVersion: platform.osName() ?? '',
+ } as const;
+}
+
+/**
+ * Generate the configuration used for an `RTCReportingAgent`.
+ *
+ * @see {@link makeReportingAgent}
+ */
+export function generateReportingConfig(rtc: any) {
+ rtc = rtc ?? getRTCNamespace();
+ const options = getReportingOptions();
+ const key = rtc.RTCReportingAgentConfigKeys;
+
+ return {
+ [key.Sender]: 'HLSJS',
+ [key.ClientName]: options.clientName,
+ [key.ServiceName]: options.serviceName,
+ [key.ApplicationName]: options.applicationName,
+ [key.DeviceName]: options.osVersion,
+ [key.ReportingStoreBag]: new rtc.RTCStorebag.RTCReportingStoreBag(
+ options.storeBagURL,
+ options.clientName,
+ options.serviceName,
+ options.applicationName,
+ options.browserName,
+ { iTunesAppVersion: options.applicationVersion },
+ ),
+
+ // Fake out these fields
+ model: options.browserName,
+ firmwareVersion: `${options.browserMajorVersion}.${options.browserMinorVersion}`,
+ };
+}
+
+/**
+ * Create an `RTCReportingAgent` with default configuration from `generateReportingConfig`.
+ *
+ * The reporting agent can be used with HLS.js playback to enable RTC reporting.
+ */
+export function makeReportingAgent(rtc: any): any {
+ rtc = rtc ?? getRTCNamespace();
+ return new rtc.RTCReportingAgent(generateReportingConfig(rtc));
+}
diff --git a/src/constants/footer-items.ts b/src/constants/footer-items.ts
new file mode 100644
index 0000000..6538d71
--- /dev/null
+++ b/src/constants/footer-items.ts
@@ -0,0 +1,24 @@
+import type { FooterItem } from '@amp/web-app-components/src/components/Footer/types';
+
+export const items: FooterItem[] = [
+ {
+ id: 'terms-of-use',
+ url: 'AMP.Shared.Footer.TermsOfUse.URL',
+ locKey: 'AMP.Shared.Footer.TermsOfUse.Text',
+ },
+ {
+ id: 'privacy-policy',
+ url: 'ASE.Web.AppStore.Shared.Footer.PrivacyPolicy.URL',
+ locKey: 'ASE.Web.AppStore.Shared.Footer.PrivacyPolicy.Text',
+ },
+ {
+ id: 'cookie-policy',
+ url: 'AMP.Shared.Footer.CookiePolicy.URL',
+ locKey: 'AMP.Shared.Footer.CookiePolicy.Text',
+ },
+ {
+ id: 'get-help',
+ url: 'ASE.Web.AppStore.Shared.Footer.GetHelp.URL',
+ locKey: 'ASE.Web.AppStore.Shared.Footer.GetHelp.Text',
+ },
+];
diff --git a/src/constants/media-metrics.ts b/src/constants/media-metrics.ts
new file mode 100644
index 0000000..67d30b2
--- /dev/null
+++ b/src/constants/media-metrics.ts
@@ -0,0 +1,18 @@
+type ValueOf<T> = T[keyof T];
+
+export const MetricsActionType = {
+ PLAY: 'play',
+ STOP: 'stop',
+} as const;
+
+export type MetricsActionTypeItem = ValueOf<typeof MetricsActionType>;
+
+export const MetricsActionDetails = {
+ AUTOPLAY: 'autoplay',
+ AUTOPAUSE: 'autopause',
+ PLAY: 'play',
+ COMPLETE: 'complete',
+ PAUSE: 'pause',
+} as const;
+
+export type MetricsActionDetailItem = ValueOf<typeof MetricsActionDetails>;
diff --git a/src/constants/storefront.ts b/src/constants/storefront.ts
new file mode 100644
index 0000000..e73f111
--- /dev/null
+++ b/src/constants/storefront.ts
@@ -0,0 +1,60 @@
+import type {
+ NormalizedLanguage,
+ NormalizedStorefront,
+} from '@jet-app/app-store/api/locale';
+
+export const DEFAULT_STOREFRONT_CODE = 'us' as NormalizedStorefront;
+export const DEFAULT_LANGUAGE_BCP47 = 'en-US' as NormalizedLanguage;
+
+export const EU_STOREFRONTS = [
+ 'at',
+ 'be',
+ 'bg',
+ 'cy',
+ 'cz',
+ 'dk',
+ 'ee',
+ 'fi',
+ 'fr',
+ 'de',
+ 'gr',
+ 'hr',
+ 'hu',
+ 'ie',
+ 'it',
+ 'lv',
+ 'lt',
+ 'lu',
+ 'mt',
+ 'nl',
+ 'pl',
+ 'pt',
+ 'ro',
+ 'sk',
+ 'si',
+ 'es',
+ 'se',
+ 'uk',
+];
+
+export const SUPPORTED_STOREFRONTS_FOR_VISION = new Set<NormalizedStorefront>([
+ 'us',
+ 'cn',
+ 'hk',
+ 'jp',
+ 'sg',
+ 'au',
+ 'ca',
+ 'fr',
+ 'de',
+ 'gb',
+ 'kr',
+ 'ae',
+ 'tw',
+] as NormalizedStorefront[]);
+
+export const UNSUPPORTED_STOREFRONTS_FOR_ARCADE = new Set([
+ 'cn',
+ 'hk',
+ 'mo',
+] as NormalizedStorefront[]);
diff --git a/src/context/accessibility-layout.ts b/src/context/accessibility-layout.ts
new file mode 100644
index 0000000..110100f
--- /dev/null
+++ b/src/context/accessibility-layout.ts
@@ -0,0 +1,93 @@
+import { getContext, setContext } from 'svelte';
+
+import type { Shelf } from '@jet-app/app-store/api/models';
+import { isAccessibilityHeaderShelf } from '~/components/jet/shelf/AccessibilityHeaderShelf.svelte';
+import { isAccessibilityFeaturesShelf } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte';
+import { isAccessibilityDeveloperLinkShelf } from '~/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte';
+
+/**
+ * Describes the layout configuration for accessibility shelves
+ */
+interface AccessibilityLayoutConfiguration {
+ withBottomPadding: boolean;
+}
+
+const ACCESSIBILITY_LAYOUT_FALLBACK: AccessibilityLayoutConfiguration =
+ Object.freeze({
+ withBottomPadding: false,
+ });
+
+type AccessibilityLayoutStore = WeakMap<
+ Shelf,
+ AccessibilityLayoutConfiguration
+>;
+type AccessibilityLayoutStoreContext = AccessibilityLayoutStore | undefined;
+
+const ACCESSIBILITY_LAYOUT_CONTEXT_ID = 'accessibility-layout-context';
+
+/**
+ * Check if a shelf is accessibility-related
+ */
+function isAccessibilityRelated(shelf: Shelf): boolean {
+ return (
+ shelf.contentType === 'accessibilityParagraph' ||
+ shelf.contentType === 'accessibilityFeatures'
+ );
+}
+
+/**
+ * Check if a shelf is one of the target accessibility shelves
+ */
+function isTargetAccessibilityShelf(shelf: Shelf): boolean {
+ return (
+ isAccessibilityHeaderShelf(shelf) ||
+ isAccessibilityFeaturesShelf(shelf) ||
+ isAccessibilityDeveloperLinkShelf(shelf)
+ );
+}
+
+/**
+ * Store the {@linkcode AccessibilityLayoutConfiguration} for each accessibility shelf
+ * in "context", so it can be retrieved at the shelf-component level
+ *
+ * This determines bottom padding based on whether the next shelf is accessibility-related
+ */
+export function setAccessibilityLayoutContext(page: { shelves: Shelf[] }) {
+ const store: AccessibilityLayoutStore = new WeakMap();
+
+ for (let i = 0; i < page.shelves.length; i++) {
+ const shelf = page.shelves[i];
+
+ // Only process target accessibility shelves
+ if (!isTargetAccessibilityShelf(shelf)) {
+ continue;
+ }
+
+ // Check if the next shelf is accessibility-related
+ const nextShelf = page.shelves[i + 1];
+ const hasAccessibilityNext =
+ nextShelf && isAccessibilityRelated(nextShelf);
+
+ store.set(shelf, {
+ withBottomPadding: !hasAccessibilityNext,
+ });
+ }
+
+ setContext<AccessibilityLayoutStoreContext>(
+ ACCESSIBILITY_LAYOUT_CONTEXT_ID,
+ store,
+ );
+}
+
+/**
+ * Retrieve the {@linkcode AccessibilityLayoutConfiguration} for a given accessibility shelf
+ */
+export function getAccessibilityLayoutConfiguration(
+ shelf: Shelf,
+): AccessibilityLayoutConfiguration {
+ const accessibilityLayout = getContext<AccessibilityLayoutStoreContext>(
+ ACCESSIBILITY_LAYOUT_CONTEXT_ID,
+ );
+
+ return accessibilityLayout?.get(shelf) ?? ACCESSIBILITY_LAYOUT_FALLBACK;
+}
diff --git a/src/context/today-card-layout.ts b/src/context/today-card-layout.ts
new file mode 100644
index 0000000..54f66ec
--- /dev/null
+++ b/src/context/today-card-layout.ts
@@ -0,0 +1,98 @@
+import { getContext, setContext } from 'svelte';
+
+import type { TodayPage } from '@jet-app/app-store/api/models';
+import {
+ type TodayCardShelf,
+ isTodayCardShelf,
+} from '~/components/jet/shelf/TodayCardShelf.svelte';
+
+/**
+ * Describes the configuration of the card layout within a {@linkcode TodayCardShelf}
+ */
+interface LayoutConfiguration {
+ wrap: {
+ shouldStretchFirstCard: boolean;
+ };
+ nowrap: {
+ shouldStretchFirstCard: boolean;
+ };
+}
+
+const LAYOUT_CONFIGURATION_FALLBACK: LayoutConfiguration = Object.freeze({
+ wrap: {
+ shouldStretchFirstCard: true,
+ },
+ nowrap: {
+ shouldStretchFirstCard: true,
+ },
+});
+
+type TodayCardLayoutStore = WeakMap<TodayCardShelf, LayoutConfiguration>;
+type TodayCardLayoutStoreContext = TodayCardLayoutStore | undefined;
+
+const TODAY_CARD_LAYOUT_CONTEXT_ID = 'today-card-layout-context';
+
+/**
+ * Store the {@linkcode LayoutConfiguration} for each {@linkcode TodayCardShelf} in a
+ * {@linkcode TodayPage} in "context", so it can be retrieved at the shelf-component level
+ *
+ * This is necessary because the layout of the cards within each shelf of a {@linkcode TodayPage}
+ * is only knowable given information about the shelves that were rendered before it
+ *
+ * The information about the shelf layout is persisted through the "context" API so that the
+ * rendering of a {@linkcode TodayPage} can defer to the "default" page component, which requires
+ * that we pass no additional arguments into each shelf component
+ *
+ * {@linkcode getTodayCardLayoutConfiguration} can be used to look up the {@linkcode LayoutConfiguration}
+ * stored for a given {@linkcode TodayCardShelf}
+ */
+export function setTodayCardLayoutContext(page: Pick<TodayPage, 'shelves'>) {
+ const store: TodayCardLayoutStore = new WeakMap();
+
+ let shouldStretchFirstCardMultiline = false;
+ let shouldStretchFirstCardInline = false;
+
+ for (const shelf of page.shelves) {
+ // Skip any non-`TodayCard` shelves
+ if (!isTodayCardShelf(shelf)) {
+ continue;
+ }
+
+ store.set(shelf, {
+ wrap: {
+ shouldStretchFirstCard: shouldStretchFirstCardMultiline,
+ },
+ nowrap: {
+ shouldStretchFirstCard: shouldStretchFirstCardInline,
+ },
+ });
+
+ // In the multi-line card configuration, shelves with two or three cards in them will
+ // require that the next shelf swaps to stretching the cards at the opposite end
+ if (shelf.items.length === 2 || shelf.items.length === 3) {
+ shouldStretchFirstCardMultiline = !shouldStretchFirstCardMultiline;
+ }
+
+ // In the "inline" card configuration, each shelf should always alternate which end the
+ // card is stretched on
+ shouldStretchFirstCardInline = !shouldStretchFirstCardInline;
+ }
+
+ setContext<TodayCardLayoutStoreContext>(
+ TODAY_CARD_LAYOUT_CONTEXT_ID,
+ store,
+ );
+}
+
+/**
+ * Retrieve the {@linkcode LayoutConfiguration} for a given {@linkcode TodayCardShelf}
+ */
+export function getTodayCardLayoutConfiguration(
+ shelf: TodayCardShelf,
+): LayoutConfiguration {
+ const todayCardLayout = getContext<TodayCardLayoutStoreContext>(
+ TODAY_CARD_LAYOUT_CONTEXT_ID,
+ );
+
+ return todayCardLayout?.get(shelf) ?? LAYOUT_CONFIGURATION_FALLBACK;
+}
diff --git a/src/jet/action-handlers/browser.ts b/src/jet/action-handlers/browser.ts
new file mode 100644
index 0000000..08d1a5a
--- /dev/null
+++ b/src/jet/action-handlers/browser.ts
@@ -0,0 +1,16 @@
+// Browser ONLY logic. Must have the same exports as server.ts
+// See: docs/isomorphic-imports.md
+
+import type { Dependencies } from './types';
+
+import { registerHandler as registerFlowActionHandler } from '~/jet/action-handlers/flow-action';
+import { registerHandler as registerExternalURLActionHandler } from '~/jet/action-handlers/external-url-action';
+import { registerHandler as registerCompoundActionHandler } from '~/jet/action-handlers/compound-action';
+
+export type { Dependencies };
+
+export function registerActionHandlers(dependencies: Dependencies) {
+ registerCompoundActionHandler(dependencies);
+ registerFlowActionHandler(dependencies);
+ registerExternalURLActionHandler(dependencies);
+}
diff --git a/src/jet/action-handlers/compound-action.ts b/src/jet/action-handlers/compound-action.ts
new file mode 100644
index 0000000..9cf1be0
--- /dev/null
+++ b/src/jet/action-handlers/compound-action.ts
@@ -0,0 +1,33 @@
+import type { LoggerFactory } from '@amp/web-apps-logger';
+import type { Jet } from '~/jet';
+import type { CompoundAction } from '~/jet/models';
+
+export type Dependencies = {
+ jet: Jet;
+ logger: LoggerFactory;
+};
+
+export async function registerHandler(dependencies: Dependencies) {
+ const { jet, logger } = dependencies;
+
+ const log = logger.loggerFor('jet/action-handlers/compound-action');
+
+ jet.onAction('compoundAction', async (action: CompoundAction) => {
+ log.info('received CompoundAction:', action);
+
+ const { subactions = [] } = action;
+
+ // Perform actions in sequence
+ for (const action of subactions) {
+ await jet.perform(action).catch((e) => {
+ // Throwing error stops for...of execution
+ // TODO: rdar://73165545 (Error Handling Across App)
+ throw new Error(
+ `an error occurred while handling CompoundAction: ${e}`,
+ );
+ });
+ }
+
+ return 'performed';
+ });
+}
diff --git a/src/jet/action-handlers/external-url-action.ts b/src/jet/action-handlers/external-url-action.ts
new file mode 100644
index 0000000..a9d3769
--- /dev/null
+++ b/src/jet/action-handlers/external-url-action.ts
@@ -0,0 +1,19 @@
+import type { Jet } from '~/jet';
+import type { LoggerFactory } from '@amp/web-apps-logger';
+import type { ExternalUrlAction } from '@jet-app/app-store/api/models';
+
+export type Dependencies = {
+ jet: Jet;
+ logger: LoggerFactory;
+};
+
+export function registerHandler(dependencies: Dependencies) {
+ const { jet, logger } = dependencies;
+
+ const log = logger.loggerFor('jet/action-handlers/external-url-action');
+
+ jet.onAction('ExternalUrlAction', async (action: ExternalUrlAction) => {
+ log.info('received external URL action:', action);
+ return 'performed';
+ });
+}
diff --git a/src/jet/action-handlers/flow-action.ts b/src/jet/action-handlers/flow-action.ts
new file mode 100644
index 0000000..4cdcc5e
--- /dev/null
+++ b/src/jet/action-handlers/flow-action.ts
@@ -0,0 +1,369 @@
+import { isNothing, unwrapOptional } from '@jet/environment';
+import type { Intent } from '@jet/environment/dispatching';
+import type { LoggerFactory } from '@amp/web-apps-logger';
+import { History } from '@amp/web-apps-utils';
+
+import type { FlowAction } from '@jet-app/app-store/api/models';
+import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent';
+import { isChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent';
+
+import type { Jet } from '~/jet';
+import { type Page, assertIsPage, FLOW_ACTION_KIND } from '~/jet/models';
+import { mapException } from '~/utils/error';
+import { stripHost } from '~/utils/url';
+
+import type { ComponentProps } from 'svelte';
+import type AppComponent from '~/App.svelte';
+import { handleModalPresentation } from '~/jet/utils/handle-modal-presentation';
+import { addRejectedIntent } from '../utils/error-metadata';
+
+type AppComponentProps = Partial<ComponentProps<AppComponent>>;
+
+// This action handler is responsible for all routing and related state
+// management.
+//
+// Take care when making modifications here. There are many subtle invariants
+// that must be maintained. They should be documented in comments throughout.
+// It might be best to read the whole file to understand this full context
+// before attempting even a small fix.
+//
+// High level overview:
+//
+// There are two ways for routing state changes to arise in the app:
+//
+// 1. Direct user interaction with the app (a FlowAction)
+// 2. Indirect user interaction via browser back/forward buttons (popstate)
+//
+// FlowAction is the bedrock of navigation in the app. Anytime the user interacts
+// with a button, link, etc. a FlowAction is performed (Jet.perform). When that
+// happens, the Jet runtime eventually invokes the handler in this file
+// (see jet.onAction below) to change the state of the app.
+//
+// This file manages the browser history and thus has the dual responsibility
+// of handling state changes that come from the back and forward buttons. The
+// state stored off when handling a FlowAction is later used by the popstate
+// handler to navigate backwards without needing to re-fetch the previous page.
+//
+// Take note that these two processes are coupled fairly tightly due to the
+// popstate needing data from the previous navigation. This is stored in the
+// State interface. Take care when updating one flow that a modification is
+// likely needed in the other.
+//
+// At the end of both of these processes, a call to updateApp is made. This
+// changes the view model passed down to the top level <App> component. As a
+// result of Svelte's reactivity, this could result in the entire page changing
+// or just a part of it being amended to or removed. Additionally, the `page`
+// passed in (the view model) can also be a promise. In which case, <App> will
+// await it and display a loading spinner until it resolves or rejects.
+//
+// Notable specifics:
+//
+// Handling a FlowAction roughly has the following steps:
+//
+// 1. Extract a "destination" intent from the FlowAction. Recall that Jet
+// actions communicate a user interaction, but return no value. Jet
+// intents can be contained within an action and return data. In this case,
+// the intent derived from a FlowAction is used to retrieve the data for
+// the new page to which the FlowAction sends the user.
+//
+// 2. Dispatch the "destination" intent. Here, we resolve the Promise when
+// the page is ready, but we'll resolve early with an unresolve page
+// promise after 500ms. We take advantage of that the fact that passing a
+// Promise to updateApp will show a loading spinner. We wait 500ms,
+// because we don't want to immediately show a loading spinner or change
+// the page.
+//
+// 3. Update current page state in the history (ex. scroll position) and then
+// push a new history state for the page we're about to display. Note that
+// this must be done after the page Promise resolves, because we need to
+// store the page view model itself and we only know the canonicalURL of
+// it once it resolves. This state is used by popstate to return to the
+// page should the user ever leave and then come back to it.
+//
+// 4. Call updateApp to change the UI presented. At this point, it could be a
+// completed page (in which case step 3 will have already happened). The
+// app will display the new page immediately. Or, it could still be a
+// Promise (in which case step 3 will happen once it resolves and then the
+// page will resolve). The <App> will display a loading spinner until this
+// resolution happens.
+//
+// Handling a popstate event follows a similar pattern, but has some additional
+// complexity.
+//
+// The simple case is that the state that we stored off above in step 3 is
+// available. In which case, returning to the old page only involves calling
+// updateApp with the view model we stored.
+//
+// But, we don't want to store an infinite history as these view models are
+// sizable. We limit history to an arbitrary depth. After the user has
+// navigated beyond that depth, we forget the oldest states. If a user ever
+// were to back button all the way back to them, there would be no view model
+// to restore. But, we do have the URL, so we use that and pretend like we're
+// deeplinking into the app again for the first time. Care must be taken here
+// to not perform a FlowAction, since that would modify the history. popstate
+// events have already modified the browser history to point to the desired
+// new state. So, we manually dispatch the page intent and perform other
+// actions (such as switching the selected tab) ourselves. We then use the page
+// promise as above to call updateApp.
+export type Dependencies = {
+ jet: Jet;
+ logger: LoggerFactory;
+ updateApp: (props: AppComponentProps) => void;
+};
+
+interface State {
+ page: Page;
+}
+
+export function registerHandler(dependencies: Dependencies) {
+ const { jet, logger, updateApp } = dependencies;
+
+ const history = new History<State>(logger, {
+ getScrollablePageElement() {
+ return (
+ document.getElementById('scrollable-page-override') ||
+ document.getElementById('scrollable-page') ||
+ // If we haven't defined a specific scrollable element,
+ // scroll the whole page
+ document.getElementsByTagName('html')?.[0]
+ );
+ },
+ });
+
+ const log = logger.loggerFor('jet/action-handlers/flow-action');
+
+ let isFirstPage = true;
+
+ jet.onAction(FLOW_ACTION_KIND, async (action: FlowAction) => {
+ log.info('received FlowAction:', action);
+ // timer for request time start
+ // TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit)
+ // const pageSpeedMetric = perfkit.makeNewPageSpeedMetric();
+ // pageSpeedMetric.capturePageRequestTime();
+
+ let intent: Intent<unknown>;
+ try {
+ intent = unwrapOptional(action.destination);
+ } catch (e) {
+ log.info(
+ '`FlowAction` received without a destination `Intent`: update the Jet app to attach an `Intent` to this `FlowAction`',
+ );
+
+ return;
+ }
+
+ // If the destination `Intent` must be performed server-side, determine
+ // the destination URL and perform full browser navigation to that location
+ if (!isFirstPage && mustPerformServerSide(intent)) {
+ const { pageUrl } = action;
+
+ if (isNothing(pageUrl)) {
+ log.error(
+ `\`${intent.$kind}\` must be performed server-side, but the action lacks a \`pageUrl\` to navigate to`,
+ );
+ return 'performed';
+ }
+
+ window.location.href = stripHost(pageUrl);
+ return 'performed';
+ }
+
+ // We capture this variable since below it is used asynchronously, but
+ // we updated it at the end of this handler (so it could change before
+ // it's used below).
+ const shouldReplace = isFirstPage;
+
+ // Resolves either when the page is ready or 800ms have elapsed
+ // (we want to show a loading spinner after 800ms)
+ const page = await getPage(intent, action);
+
+ // If the action requires the page to be rendered in a modal.
+ if (action.presentationContext === 'presentModal') {
+ handleModalPresentation(page, log, action.page);
+ return 'performed';
+ }
+
+ // This must happen before history.replaceState/pushState
+ // We call this now, because the next line updates <App> which changes
+ // the DOM. After that point we can't do things like record scroll
+ // position, etc.
+ history.beforeTransition();
+
+ updateApp({
+ page: page.promise.then((page: Page): Page => {
+ const state = {
+ page,
+ };
+
+ const canonicalURL = mapException(
+ () => unwrapOptional(page.canonicalURL),
+ '`page` resolved without a `canonicalURL`, which is required for navigation',
+ );
+
+ // TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit)
+ // perfkit.setPageType(page.pageMetrics?.pageFields?.pageType as string | undefined || 'unknown');
+
+ if (shouldReplace) {
+ history.replaceState(state, canonicalURL);
+ } else {
+ history.pushState(state, canonicalURL);
+ }
+
+ didEnterPage(page);
+ return page;
+ }),
+ isFirstPage,
+ });
+
+ // Future updates won't be for the first page
+ isFirstPage = false;
+
+ return 'performed';
+ });
+
+ history.onPopState(
+ async (url: string, state: State | undefined): Promise<void> => {
+ // NOTE: We don't call history.beforeTransition() anywhere here,
+ // because we don't expect to save any state from the previous page
+ // on back.
+
+ if (state) {
+ const { page } = state;
+
+ log.info('received popstate, so resetting page:', page);
+ didEnterPage(page);
+ updateApp({ page, isFirstPage });
+
+ return;
+ }
+
+ // If the state is missing page data, we have to recompute the view model
+ const routing = await jet.routeUrl(url);
+
+ if (!routing) {
+ log.error(
+ 'received popstate without data, but URL was unroutable:',
+ url,
+ );
+
+ // This probably shouldn't happen (since we only ever push valid
+ // URLs to the history), but if it does, the best we can do is show
+ // an error.
+ didEnterPage(null); // to exit the current page
+ updateApp({
+ page: Promise.reject(new Error('404')),
+ isFirstPage,
+ });
+ return;
+ }
+
+ log.info(
+ 'received popstate without data, so routing URL to:',
+ routing,
+ );
+
+ // We can't perform the FlowAction here, as that would cause a new
+ // history state to be pushed. Since we're in the context of a
+ // popState, that would cause an infinite history loop where the back
+ // button goes back but then immediately pushes again to the history
+ // (so the user doesn't actually go back in history).
+ // See: rdar://92621382 (Navigating more than 10 pages and then going back breaks back button)
+ //
+ // Careful reading will note that this promise will not reject.
+ // Only the page.promise can reject (and we'll hand that to updateApp,
+ // which will display the appropriate error for this case).
+ //
+ // Like in the handling of FlowAction (above), this blocks for at
+ // most 800ms before resolving. Either the page is ready, or we
+ // want to display a loading spinner. updateApp() will show a
+ // spinner if page.promise is not ready.
+ const page = await getPage(routing.intent, routing.action);
+
+ updateApp({
+ page: page.promise.then((page: Page): Page => {
+ // No history.replaceState/pushState like in handling FlowAction
+ // (above) since this is in the context of a popstate. The
+ // history stack, URL bar, etc. have already been updated.
+
+ didEnterPage(page);
+ return page;
+ }),
+ isFirstPage,
+ });
+ },
+ );
+
+ /**
+ * Get a Page by dispatching its intent. Returns a promise that resolves
+ * when the page is ready or after 800ms, whichever is first.
+ *
+ * The promise-inside-an-object-inside-a-promise return type is
+ * intentional. If we just returned Promise<Page>, then this function
+ * would not resolve until the page was ready. But we want it to resolve
+ * after 800ms, even if the page isn't ready.
+ */
+ async function getPage(
+ intent: Intent<unknown>,
+ sourceAction: FlowAction | undefined,
+ ): Promise<{ promise: Promise<Page> }> {
+ const page = (async (): Promise<Page> => {
+ try {
+ let page = await jet.dispatch(intent);
+ log.info('FlowAction destination resolved to:', page);
+
+ assertIsPage(page);
+
+ return page;
+ } catch (e: any) {
+ log.error('FlowAction destination rejected:', e);
+
+ // Provide a way to retry the flow action from <ErrorPage>
+ if (!e.userInfo || e.userInfo.status !== 404) {
+ e.retryFlowAction = sourceAction;
+ }
+
+ e.isFirstPage = isFirstPage;
+ addRejectedIntent(e, intent);
+ throw e;
+ }
+ })();
+
+ // Wait until the page loads (or up to 500ms, then show loading spinner)
+ await Promise.race([
+ page,
+ // Note that this has interplay with <PageResolver>
+ new Promise((resolve) => setTimeout(resolve, 500)),
+ // TODO: rdar://78166703 Add test to ensure catch no-ops
+ //
+ // NOTE: This catch is important. If the page promise rejects, we
+ // want that to flow down into updateApp, where the appropriate
+ // error page will be displayed. If we don't no-op here, we'll
+ // cause the FlowAction to not finish handling (and updateApp will
+ // never be called).
+ ]).catch(() => {});
+
+ // Wrapping in an object to prevent this function's promise from
+ // not resolving until the page is ready. We want to resolve
+ // immediately if it's already been 800ms
+ return { promise: page };
+ }
+
+ function didEnterPage(page: Page | null): void {
+ // Wrapped in an IIFE to avoid blocking anything (or breaking anything
+ // if this fails)
+ (async (): Promise<void> => {
+ try {
+ await jet.didEnterPage(page);
+ } catch (e) {
+ log.error('didEnterPage error:', e);
+ }
+ })();
+ }
+}
+
+/**
+ * Determines if an `Intent` must be performed server-side
+ */
+function mustPerformServerSide(intent: Intent<unknown>): boolean {
+ return isSearchResultsPageIntent(intent) || isChartsPageIntent(intent);
+}
diff --git a/src/jet/bootstrap.ts b/src/jet/bootstrap.ts
new file mode 100644
index 0000000..32b10a0
--- /dev/null
+++ b/src/jet/bootstrap.ts
@@ -0,0 +1,125 @@
+import { makeRouterUsingRegisteredControllers } from '@jet/environment/routing';
+
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { AppStoreIntentDispatcher } from '@jet-app/app-store/foundation/runtime/app-store-intent-dispatcher';
+import { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime';
+
+import {
+ type Dependencies,
+ ObjectGraphType,
+ makeObjectGraph,
+} from '~/jet/dependencies';
+
+import { AppEventPageIntentController } from '@jet-app/app-store/controllers/app-events/app-event-page-intent-controller';
+import { BundlePageIntentController } from '@jet-app/app-store/controllers/product-page/bundle-page-intent-controller';
+import { EditorialPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-page-intent-controller';
+import { EditorialShelfCollectionPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-shelf-collection-page-intent-controller';
+import { GroupingPageIntentController } from '@jet-app/app-store/controllers/grouping/grouping-page-intent-controller';
+import { ProductPageIntentController } from '@jet-app/app-store/controllers/product-page/product-page-intent-controller';
+import { SearchLandingPageIntentController } from '@jet-app/app-store/controllers/search/search-landing-page-intent-controller';
+import { SearchResultsPageIntentController } from '@jet-app/app-store/controllers/search/search-results-controller';
+import { RoutableArticlePageIntentController } from '@jet-app/app-store/controllers/today/routable-article-page-intent-controller';
+import { ArcadeGroupingPageIntentController } from '@jet-app/app-store/controllers/arcade/arcade-grouping-page-intent-controller';
+import { DeveloperPageIntentController } from '@jet-app/app-store/controllers/developer/developer-page-intent-controller';
+import { ChartsPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-page-intent-controller';
+import { ChartsHubPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-hub-page-intent-controller';
+import { SeeAllPageIntentController } from '@jet-app/app-store/controllers/product-page/see-all-intent-controller';
+import { RoutableTodayPageIntentController } from '@jet-app/app-store/controllers/today/routable-today-page-intent-controller';
+import { RoomPageIntentController } from '@jet-app/app-store/controllers/room/room-page-intent-controller';
+import { RoutableArcadeSeeAllPageController } from '@jet-app/app-store/controllers/arcade/routable-arcade-see-all-page-controller';
+import * as landingPageNavigationControllers from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers';
+import { RootRedirectController } from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers';
+import { EulaPageIntentController } from '@jet-app/app-store/controllers/product-page/eula-page-intent-controller';
+import { CategoryTabsIntentController } from '@jet-app/app-store/controllers/web-navigation/category-tabs-intent-controller';
+
+import { ErrorPageIntentController } from '~/jet/intents/error-page-intent-controller';
+import { ChartsPageRedirectIntentController } from '~/jet/intents/charts-page-redirect-intent-controller';
+
+import {
+ RouteUrlIntentController,
+ LintMetricsEventIntentController,
+} from '~/jet/intents';
+import * as staticMessagePageControllers from '~/jet/intents/static-message-pages';
+
+function makeIntentDispatcher(): AppStoreIntentDispatcher {
+ const intentDispatcher = new AppStoreIntentDispatcher();
+
+ intentDispatcher.register(RouteUrlIntentController);
+ intentDispatcher.register(LintMetricsEventIntentController);
+
+ // Route Providers
+ for (const Controller of Object.values(landingPageNavigationControllers)) {
+ // `RootRedirectController` needs to be registered last, due to it's path match of `/{sf}`,
+ // it could inadvertently match a landing page route like `/vision`, so we are skipping it here
+ // and registering it at the bottom of this function.
+ if (Controller !== RootRedirectController) {
+ intentDispatcher.register(Controller);
+ }
+ }
+
+ for (const StaticMessagePageController of Object.values(
+ staticMessagePageControllers,
+ )) {
+ intentDispatcher.register(StaticMessagePageController);
+ }
+
+ intentDispatcher.register(ArcadeGroupingPageIntentController);
+ intentDispatcher.register(BundlePageIntentController);
+ intentDispatcher.register(EditorialPageIntentController);
+ intentDispatcher.register(EditorialShelfCollectionPageIntentController);
+ intentDispatcher.register(GroupingPageIntentController);
+ intentDispatcher.register(new SearchResultsPageIntentController());
+ intentDispatcher.register(SearchLandingPageIntentController);
+ intentDispatcher.register(DeveloperPageIntentController);
+ intentDispatcher.register(RoutableArticlePageIntentController);
+ intentDispatcher.register(RoutableTodayPageIntentController);
+ intentDispatcher.register(RoomPageIntentController);
+ intentDispatcher.register(RoutableArcadeSeeAllPageController);
+ intentDispatcher.register(EulaPageIntentController);
+ intentDispatcher.register(ChartsPageRedirectIntentController);
+ intentDispatcher.register(ErrorPageIntentController);
+
+ // "Charts" Pages; "hub" must come first since so it's URL matches before the "detail" page
+ intentDispatcher.register(ChartsHubPageIntentController);
+ intentDispatcher.register(ChartsPageIntentController);
+
+ // Product Page Routes; order is important due to overlapping URL patterns
+ // The product page itself must come last or it will match the more-specific patterns
+ intentDispatcher.register(AppEventPageIntentController);
+ intentDispatcher.register(SeeAllPageIntentController);
+ intentDispatcher.register(ProductPageIntentController);
+
+ intentDispatcher.register(new CategoryTabsIntentController());
+
+ // We register the root redirect controller last so more specific path patterns can be matched first
+ intentDispatcher.register(RootRedirectController);
+
+ return intentDispatcher;
+}
+
+/**
+ * Bootstraps the Jet runtime for Apps
+ *
+ * @param dependencies dependencies to initialize the Object Graph with
+ */
+export function bootstrap(dependencies: Dependencies): {
+ runtime: AppStoreRuntime;
+ objectGraph: AppStoreObjectGraph;
+} {
+ const intentDispatcher = makeIntentDispatcher();
+
+ const baseObjectGraph = makeObjectGraph(dependencies);
+
+ const router = makeRouterUsingRegisteredControllers(
+ intentDispatcher,
+ baseObjectGraph,
+ );
+ const appObjectGraph = baseObjectGraph
+ .adding(ObjectGraphType.router, router)
+ .adding(ObjectGraphType.dispatcher, intentDispatcher);
+
+ return {
+ runtime: new AppStoreRuntime(intentDispatcher, appObjectGraph),
+ objectGraph: appObjectGraph,
+ };
+}
diff --git a/src/jet/dependencies/bag.ts b/src/jet/dependencies/bag.ts
new file mode 100644
index 0000000..32f6bc7
--- /dev/null
+++ b/src/jet/dependencies/bag.ts
@@ -0,0 +1,290 @@
+import type { Bag as NativeBag, BagKeyDescriptor } from '@jet/environment';
+import type { Opt } from '@jet/environment';
+import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
+
+import type { Locale } from './locale';
+import {
+ EU_STOREFRONTS,
+ SUPPORTED_STOREFRONTS_FOR_VISION,
+ UNSUPPORTED_STOREFRONTS_FOR_ARCADE,
+} from '~/constants/storefront';
+
+export type BagRetrievalMethod = Exclude<keyof NativeBag, 'registerBagKeys'>;
+
+export function makeUnimplementedKeyRequestWarning(
+ method: BagRetrievalMethod,
+ key: string,
+) {
+ return `requested unimplemented \`${method}\` key \`${key}\``;
+}
+
+export class WebBag implements NativeBag {
+ private readonly log: Logger;
+ private readonly locale: Locale;
+
+ constructor(loggerFactory: LoggerFactory, locale: Locale) {
+ this.log = loggerFactory.loggerFor('Bag');
+ this.locale = locale;
+ }
+
+ private provideNoValue(method: BagRetrievalMethod, key: string): null {
+ this.log.warn(makeUnimplementedKeyRequestWarning(method, key));
+
+ return null;
+ }
+
+ registerBagKeys(_keys: BagKeyDescriptor[]): void {
+ // We hardcode, so registration is a no-op
+ }
+
+ double(key: string): Opt<number> {
+ switch (key) {
+ case 'game-controller-recommended-rollout-rate':
+ return 1.0; // set to 1.0 to enable `learn more` button for game controller capability
+ case 'icon-artwork-rollout-rate':
+ return 1.0; // set to 1.0 to enable new icon artwork style
+ default:
+ return this.provideNoValue('double', key);
+ }
+ }
+
+ integer(key: string): Opt<number> {
+ return this.provideNoValue('integer', key);
+ }
+
+ boolean(key: string): Opt<boolean> {
+ switch (key) {
+ case 'enableAppEvents':
+ return true;
+ case 'enable-app-accessibility-labels':
+ return true;
+ case 'enable-app-store-age-ratings':
+ return true;
+ case 'enable-external-purchase':
+ return true;
+ case 'enable-privacy-nutrition-labels':
+ return true;
+ case 'enable-system-app-reviews':
+ return true;
+ case 'enable-vision-platform':
+ return SUPPORTED_STOREFRONTS_FOR_VISION.has(
+ this.locale.activeStorefront,
+ );
+ case 'arcade-enabled':
+ return !UNSUPPORTED_STOREFRONTS_FOR_ARCADE.has(
+ this.locale.activeStorefront,
+ );
+
+ // Enable required `GroupingPage` features
+ case 'enable-featured-categories-on-groupings':
+ case 'enable-category-bricks-on-groupings':
+ return true;
+ case 'enable-seller-info':
+ return true;
+ case 'enable-preview-platform-for-web':
+ return false;
+ case 'enableProductPageVariants':
+ return true;
+ case 'game-center-extend-supported-features':
+ return true;
+ case 'enable-product-page-install-size':
+ return true;
+ case 'enable-icon-artwork':
+ return true;
+ default:
+ return this.provideNoValue('boolean', key);
+ }
+ }
+
+ array(key: string): Opt<unknown> {
+ switch (key) {
+ // URL patterns that are opted into the "edge" domains
+ // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L350
+ case 'apps-media-api-edge-end-points':
+ return [
+ // Including a pattern that matches our "search" API endpoint ensures
+ // that the built URL uses the `apps-media-api-search-edge-host` host
+ // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L352
+ '/search',
+ ];
+ case 'enabled-external-purchase-placements':
+ return ['product-page-banner', 'product-page-info-section'];
+ case 'tabs/standard':
+ return [
+ {
+ id: 'today',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Today',
+ ),
+ 'image-identifier': 'text.rectangle.page',
+ },
+ {
+ id: 'apps',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Apps',
+ ),
+ 'image-identifier': 'app.3.stack.3d.fill',
+ },
+ {
+ id: 'apps-and-games',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.AppsAndGames',
+ ),
+ 'image-identifier': 'rocket.fill',
+ },
+ {
+ id: 'arcade',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Arcade',
+ ),
+ 'image-identifier': 'joystickcontroller.fill',
+ },
+ {
+ id: 'create',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Create',
+ ),
+ 'image-identifier': 'paintbrush.fill',
+ },
+ {
+ id: 'discover',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Discover',
+ ),
+ 'image-identifier': 'star.fill',
+ },
+ {
+ id: 'games',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Games',
+ ),
+ 'image-identifier': 'rocket.fill',
+ },
+ {
+ id: 'work',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Work',
+ ),
+ 'image-identifier': 'paperplane.fill',
+ },
+ {
+ id: 'play',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Play',
+ ),
+ 'image-identifier': 'rocket.fill',
+ },
+ {
+ id: 'develop',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Develop',
+ ),
+ 'image-identifier': 'hammer.fill',
+ },
+ {
+ id: 'categories',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Categories',
+ ),
+ 'image-identifier': 'square.grid.2x2.fill',
+ },
+ {
+ id: 'search',
+ title: this.locale.i18n.t(
+ 'ASE.Web.AppStore.Navigation.LandingPage.Search',
+ ),
+ 'image-identifier': 'magnifyingglass',
+ },
+ ];
+ default:
+ return this.provideNoValue('array', key);
+ }
+ }
+
+ dictionary(key: string): Opt<unknown> {
+ return this.provideNoValue('dictionary', key);
+ }
+
+ url(key: string): Opt<string> {
+ switch (key) {
+ case 'apps-media-api-host':
+ return 'amp-api-edge.apps.apple.com';
+ case 'apps-media-api-edge-host':
+ return 'amp-api-edge.apps.apple.com';
+ case 'apps-media-api-search-edge-host':
+ return 'amp-api-search-edge.apps.apple.com';
+
+ default:
+ return this.provideNoValue('url', key);
+ }
+ }
+
+ string(key: string): Opt<string> {
+ switch (key) {
+ case 'countryCode':
+ return this.locale.activeStorefront;
+
+ case 'language-tag':
+ return this.locale.activeLanguage;
+
+ case 'language':
+ // TODO: rdar://78159789: util for this? What about zh-Hant, etc.
+ return this.locale.activeLanguage.split('-')[0];
+
+ // Some URLs are accessed as strings
+ // TODO: fix this upstream in `ios-appstore-app` so it uses `.url()` instead
+ case 'apps-media-api-edge-host':
+ case 'apps-media-api-search-edge-host':
+ return this.url(key);
+
+ case 'game-controller-learn-more-editorial-item-id':
+ return '1687769242';
+
+ case 'familySubscriptionsLearnMoreEditorialItemId':
+ return '1563279606';
+
+ case 'external-purchase-learn-more-editorial-item-id':
+ if (this.locale.activeStorefront === 'kr') {
+ return 'id1727067165';
+ }
+
+ return 'id1760810284';
+
+ case 'appPrivacyLearnMoreEditorialItemId':
+ return 'id1538632801';
+
+ case 'ageRatingLearnMoreEditorialItemId':
+ return '1825160725';
+
+ case 'accessibility-learn-more-editorial-item-id':
+ return '1814164299';
+
+ case 'external-purchase-product-page-banner-text-variant':
+ return '2';
+ case 'external-purchase-product-page-annotation-variant':
+ return '4';
+
+ case 'transparencyLawEditorialItemId':
+ if (EU_STOREFRONTS.includes(this.locale.activeStorefront)) {
+ return 'id1620909697';
+ }
+
+ return null;
+
+ case 'appPrivacyDefinitionsEditorialItemId':
+ return '1539235847';
+
+ case 'metrics_topic':
+ return 'xp_amp_appstore_unidentified';
+
+ case 'in-app-purchases-learn-more-editorial-item-id':
+ return '1436214772';
+
+ case 'web-navigation-category-tabs-editorial-item-id':
+ return '1842456901';
+
+ default:
+ return this.provideNoValue('string', key);
+ }
+ }
+}
diff --git a/src/jet/dependencies/client.ts b/src/jet/dependencies/client.ts
new file mode 100644
index 0000000..6b8a979
--- /dev/null
+++ b/src/jet/dependencies/client.ts
@@ -0,0 +1,96 @@
+import type { Locale } from './locale';
+
+export class WebClient implements Client {
+ private readonly locale: Locale;
+
+ deviceType: DeviceType = 'web';
+
+ // Tell the App Store Client that we're *really* the "web", even if the `DeviceType`
+ // says otherwise
+ __isReallyWebClient = true as const;
+
+ // TODO: how do we define this for the "client" web, when it can change over time?
+ screenSize: { width: number; height: number } = { width: 0, height: 0 };
+
+ // TODO: how is this used? We can't have a consistent value across multiple sessions
+ guid: string = 'xxx-xx-xxx';
+
+ screenCornerRadius: number = 0;
+
+ newPaymentMethodEnabled = false;
+
+ isActivityAvailable = false;
+
+ isElectrocardiogramInstallationAllowed = false;
+
+ isScandiumInstallationAllowed = false;
+
+ isSidepackingEnabled = false;
+
+ isTinkerWatch = false;
+
+ supportsHEIF: boolean = false;
+
+ isMandrakeSupported: boolean = false;
+
+ isCharonSupported: boolean = false;
+
+ buildType: BuildType;
+
+ maxAppContentRating: number = 1000;
+
+ isIconArtworkCapable: boolean = true;
+
+ constructor(buildType: BuildType, locale: Locale) {
+ this.buildType = buildType;
+ this.locale = locale;
+ }
+
+ get storefrontIdentifier(): string {
+ return this.locale.activeStorefront;
+ }
+
+ deviceHasCapabilities(_capabilities: string[]): boolean {
+ return false;
+ }
+
+ deviceHasCapabilitiesIncludingCompatibilityCheckIsVisionOSCompatibleIOSApp(
+ _capabilities: string[],
+ _supportsVisionOSCompatibleIOSBinary: boolean,
+ ): boolean {
+ return false;
+ }
+
+ isActivePairedWatchSystemVersionAtLeastMajorVersionMinorVersionPatchVersion(
+ _majorVersion: number,
+ _minorVersion: number,
+ _patchVersion: number,
+ ): boolean {
+ return false;
+ }
+
+ canDevicePerformAppActionWithAppCapabilities(
+ _appAction: string,
+ _appCapabilities: string[] | undefined | null,
+ ): boolean {
+ return false;
+ }
+
+ isAutomaticDownloadingEnabled(): boolean {
+ return false;
+ }
+
+ isAuthorizedForUserNotifications(): boolean {
+ return false;
+ }
+
+ deletableSystemAppCanBeInstalledOnWatchWithBundleID(
+ _bundleId: string,
+ ): boolean {
+ return false;
+ }
+
+ isDeviceEligibleForDomain(_domain: string): boolean {
+ return false;
+ }
+}
diff --git a/src/jet/dependencies/console.ts b/src/jet/dependencies/console.ts
new file mode 100644
index 0000000..fe0ba64
--- /dev/null
+++ b/src/jet/dependencies/console.ts
@@ -0,0 +1,26 @@
+import type { LoggerFactory, Logger } from '@amp/web-apps-logger';
+import type { RequiredConsole } from '@jet-app/app-store/foundation/wrappers/console';
+
+export class WebConsole implements RequiredConsole {
+ private readonly logger: Logger;
+
+ constructor(loggerFactory: LoggerFactory) {
+ this.logger = loggerFactory.loggerFor('jet-console');
+ }
+
+ error(...data: unknown[]): void {
+ this.logger.error(...data);
+ }
+
+ info(...data: unknown[]): void {
+ this.logger.info(...data);
+ }
+
+ log(...data: unknown[]): void {
+ this.logger.info(...data);
+ }
+
+ warn(...data: unknown[]): void {
+ this.logger.warn(...data);
+ }
+}
diff --git a/src/jet/dependencies/feature-flags.ts b/src/jet/dependencies/feature-flags.ts
new file mode 100644
index 0000000..e745137
--- /dev/null
+++ b/src/jet/dependencies/feature-flags.ts
@@ -0,0 +1,20 @@
+const ENABLED_FEATURES = new Set([
+ // Make the `ProductPageIntentController` return a `ShelfBasedProductPage` instance
+ 'shelves_2_0_product',
+ // Enable shelf-based "Top Charts" features
+ // 'shelves_2_0_top_charts',
+ // Make the `RibbonBarShelf` contain an array of `RibbonBarItem`s
+ 'shelves_2_0_generic',
+ // Enable AX Metadata
+ 'product_accessibility_support_2025A',
+]);
+
+export class WebFeatureFlags implements FeatureFlags {
+ isEnabled(feature: string): boolean {
+ return ENABLED_FEATURES.has(feature);
+ }
+
+ isGSEUIEnabled(_feature: string): boolean {
+ return false;
+ }
+}
diff --git a/src/jet/dependencies/locale.ts b/src/jet/dependencies/locale.ts
new file mode 100644
index 0000000..e48e935
--- /dev/null
+++ b/src/jet/dependencies/locale.ts
@@ -0,0 +1,99 @@
+import type { Locale as JetLocaleDependency } from '@jet-app/app-store/foundation/dependencies/locale/locale';
+import type {
+ NormalizedLanguage,
+ NormalizedStorefront,
+ NormalizedLocale,
+ UnnormalizedLocale,
+} from '@jet-app/app-store/api/locale';
+import type I18N from '@amp/web-apps-localization';
+import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
+
+import type { Jet } from '~/jet/jet';
+import {
+ DEFAULT_STOREFRONT_CODE,
+ DEFAULT_LANGUAGE_BCP47,
+} from '~/constants/storefront';
+import {
+ type NormalizedLocaleWithDefault,
+ normalizeStorefront,
+ normalizeLanguage,
+} from '~/utils/locale';
+import type { Optional } from '@jet/environment';
+
+/**
+ * Contains information related to the locale of the request currently being
+ * made to the application.
+ *
+ * Typically, localization information is expected to be known when the Jet
+ * instance is initialized. The Web, however, will not know the current
+ * locale and langauge until after routing has already taken place.
+ *
+ * This object exists to contain that lazily-determined locale information,
+ * so that other dependencies can retreive it from here. It is to be created
+ * with the rest of the dependencies and passed to them when they are created.
+ *
+ * Localization information is set in the {@linkcode Jet#setLocale} method
+ */
+export class Locale implements JetLocaleDependency {
+ private readonly logger: Logger;
+
+ private _storefront: NormalizedStorefront | undefined;
+ private _language: NormalizedLanguage | undefined;
+
+ i18n: I18N | undefined;
+
+ constructor(loggerFactory: LoggerFactory) {
+ this.logger = loggerFactory.loggerFor('locale');
+ }
+
+ get activeStorefront(): NormalizedStorefront {
+ if (!this._storefront) {
+ this.logger.warn('`storefront` was accessed before being set');
+ return DEFAULT_STOREFRONT_CODE;
+ }
+
+ return this._storefront;
+ }
+
+ get activeLanguage(): NormalizedLanguage {
+ if (!this._language) {
+ this.logger.warn('`language` was accessed before being set');
+ return DEFAULT_LANGUAGE_BCP47;
+ }
+
+ return this._language;
+ }
+
+ setActiveLocale(locale: NormalizedLocale): void {
+ this._storefront = locale.storefront;
+ this._language = locale.language;
+ }
+
+ normalize({
+ storefront,
+ language,
+ }: UnnormalizedLocale): NormalizedLocaleWithDefault {
+ const {
+ storefront: normalizedStorefront,
+ languages,
+ defaultLanguage,
+ } = normalizeStorefront(storefront);
+
+ return {
+ storefront: normalizedStorefront,
+ ...normalizeLanguage(language || '', languages, defaultLanguage),
+ };
+ }
+
+ deriveLocaleForUrl(locale: NormalizedLocale): {
+ storefront: string;
+ language: Optional<string>;
+ } {
+ const { isDefaultLanguage } = this.normalize(locale);
+
+ return {
+ storefront: locale.storefront,
+ language: isDefaultLanguage ? undefined : locale.language,
+ };
+ }
+}
diff --git a/src/jet/dependencies/localization.ts b/src/jet/dependencies/localization.ts
new file mode 100644
index 0000000..d6961e4
--- /dev/null
+++ b/src/jet/dependencies/localization.ts
@@ -0,0 +1,523 @@
+import type I18N from '@amp/web-apps-localization';
+import type { LoggerFactory, Logger } from '@amp/web-apps-logger';
+import { isNothing } from '@jet/environment';
+
+import type { Locale } from './locale';
+import { abbreviateNumber } from '~/utils/number-formatting';
+import { getFileSizeParts } from '~/utils/file-size';
+import {
+ getPlural,
+ interpolateString,
+} from '@amp/web-apps-localization/src/translator';
+import type { Locale as SupportedLanguageIdentifier } from '@amp/web-apps-localization';
+
+const SECONDS_PER_MINUTE = 60;
+const SECONDS_PER_HOUR = 60 * 60;
+const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24;
+const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365;
+
+export function makeWebDoesNotImplementException(property: keyof Localization) {
+ return new Error(
+ `\`Localization\` method \`${property}\` is not implemented for the "web" platform`,
+ );
+}
+
+/**
+ * Determines if {@linkcode key} appears to be a "client" translation key
+ *
+ * "Client" keys are defined in `SCREAMING_SNAKE_CASE`
+ */
+function isClientLocalizationKey(key: string): boolean {
+ return /^[A-Z_]+$/.test(key);
+}
+
+/**
+ * Transforms an App Store Client-used translation key to the format that we have
+ * a value for.
+ *
+ * This accounts for the fact that the "raw" key used by the App Store Client
+ * is either a "client" key, that we filed an analogue for in our own translations,
+ * or a "server" key that exists in the App Store Client translations under their
+ * own namespace. In either case, we need to perform a transformation on the key as
+ * they use it into a format that we have a value for.
+ */
+function transformKeyToSupportedFormat(key: string): string {
+ return isClientLocalizationKey(key)
+ ? transformClientKeyToSupportedFormat(key)
+ : transformServerKeyToSupportedFormat(key);
+}
+
+/**
+ * Transforms an App Store Client server-side translation key into the format
+ * that we have a value for.
+ *
+ * This handles the fact that the App Store Client namespaces all of
+ * their translation strings under `AppStore.` but does not include
+ * that namespace when referencing the key. Since their tooling implicitly
+ * injects that namespace for them, we have to do the same thing manually.
+
+ * @example
+ * transformServerKeyToSupportedFormat('Account.Purchases');
+ * // "AppStore.Account.Purchases"
+ */
+function transformServerKeyToSupportedFormat(key: string): string {
+ return `AppStore.${key}`;
+}
+
+/**
+ * Capitalizes the first character in {@linkcode input}
+ */
+function capitalizeFirstCharacter(input: string): string {
+ const [first, ...rest] = input;
+
+ return first.toUpperCase() + rest.join('');
+}
+
+/**
+ * Transforms an App Store Client client-side translation key into the format
+ * that we have a value for.
+ *
+ * "Client" keys used by the App Store Client are typically provided by the OS;
+ * this is not available to a web application, we need an alternative to providing
+ * values for these translation keys.
+ *
+ * To accomplish this, we have submitted these keys to the server-side localization
+ * service ourelves, under a specific namespace that designates that they are the
+ * client-side keys that we needed to define. Other formatting changes are made to
+ * the key at the request of the LOC team.
+ *
+ * @example
+ * transformClientKeyToSupportedFormat('ACCOUNT_PURCHASES');
+ * // "ASE.Web.AppStoreClient.Account.Purchases"
+ */
+function transformClientKeyToSupportedFormat(key: string): string {
+ const keyInSrvLocFormat = key
+ .toLowerCase()
+ .split('_')
+ .map((segment) => capitalizeFirstCharacter(segment))
+ .join('.');
+
+ return `ASE.Web.AppStoreClient.${keyInSrvLocFormat}`;
+}
+
+/**
+ * "Web" implementation of the `AppStoreKit` {@linkcode Localization} dependency
+ */
+export class WebLocalization implements Localization {
+ private readonly locale: Locale;
+ private readonly logger: Logger;
+
+ constructor(locale: Locale, loggerFactory: LoggerFactory) {
+ this.locale = locale;
+ this.logger = loggerFactory.loggerFor('jet/dependency/localization');
+ }
+
+ get i18n(): I18N {
+ if (this.locale.i18n) {
+ return this.locale.i18n;
+ }
+
+ throw new Error('`i18n` not yet configured ');
+ }
+
+ /**
+ * The `BCP 47` identifier for the active locale
+ *
+ * @see {@link https://developer.apple.com/documentation/foundation/locale | Foundation Frameworks Locale Documentation}
+ * @see {@link https://en.wikipedia.org/wiki/IETF_language_tag | BCP 47}
+ */
+ get identifier() {
+ return this.locale.activeLanguage;
+ }
+
+ decimal(
+ n: number | null | undefined,
+ decimalPlaces?: number | null | undefined,
+ ): string | null {
+ if (isNothing(n)) {
+ return null;
+ }
+
+ let langCode: string = this.locale.activeLanguage;
+
+ if (!langCode.includes('-')) {
+ langCode = `${this.locale.activeLanguage}-${this.locale.activeStorefront}`;
+ }
+
+ const numberingSystem = new Intl.NumberFormat(
+ langCode,
+ ).resolvedOptions().numberingSystem;
+
+ const formatter = new Intl.NumberFormat(this.locale.activeLanguage, {
+ numberingSystem,
+ minimumFractionDigits: decimalPlaces ?? undefined,
+ maximumFractionDigits: decimalPlaces ?? undefined,
+ });
+
+ return formatter.format(n);
+ }
+
+ string(key: string): string {
+ const keyInSupportedFormat = transformKeyToSupportedFormat(key);
+
+ // `.getUninterpolatedString` is used instead of `.t` here to match
+ // the behavior of the `.stringWithCount` method
+ return this.i18n.getUninterpolatedString(keyInSupportedFormat);
+ }
+
+ stringForPreferredLocale(_key: string, _locale: string | null): string {
+ throw makeWebDoesNotImplementException('stringForPreferredLocale');
+ }
+
+ stringWithCount(key: string, count: number): string {
+ let keyInSupportedFormat = transformKeyToSupportedFormat(key);
+
+ // The App Store Client has some behavior around pluralization that differs
+ // from how the Media Apps localization normally works. In order to handle
+ // this, we have to avoid the default pluralization behavior of the `.i18n.t`
+ // method and do the pluralization ourselves
+ const keyWithPluralizationSuffix = getPlural(
+ count,
+ keyInSupportedFormat,
+ this.identifier as SupportedLanguageIdentifier,
+ );
+
+ // The key difference in pluralization logic is that the `other` case is
+ // actually handled by the "base" key without any suffix.
+ // Therefore, we should only use the pluralized key if it does not reflect
+ // the `other` case
+ if (!keyWithPluralizationSuffix.endsWith('.other')) {
+ keyInSupportedFormat = keyWithPluralizationSuffix;
+ }
+
+ const uninterpolatedValue =
+ this.i18n.getUninterpolatedString(keyInSupportedFormat);
+
+ // Since the `count` might be interpolated into the localization string,
+ // we need to run the interpolation ourselves on uninterpolated value
+ return interpolateString(
+ key,
+ uninterpolatedValue,
+ { count },
+ null,
+ this.identifier as SupportedLanguageIdentifier,
+ );
+ }
+
+ stringWithCounts(_key: string, _counts: number[]): string {
+ throw makeWebDoesNotImplementException('stringWithCounts');
+ }
+
+ uppercased(_value: string): string {
+ throw makeWebDoesNotImplementException('uppercased');
+ }
+
+ /**
+ * Converts a number of bytes into a localized file size string
+ *
+ * @param bytes The number of bytes to convert
+ * @return The localized file size string
+ */
+ fileSize(bytes: number): string | null {
+ let { count, unit } = getFileSizeParts(bytes);
+
+ return this.i18n.t(`ASE.Web.AppStore.FileSize.${unit}`, {
+ count,
+ });
+ }
+
+ formattedCount(count: number | null | undefined): string | null {
+ if (isNothing(count)) {
+ return null;
+ }
+
+ return abbreviateNumber(count, this.locale.activeLanguage);
+ }
+
+ formattedCountForPreferredLocale(
+ count: number | null,
+ locale: string | null,
+ ): string | null {
+ if (isNothing(count)) {
+ return null;
+ }
+
+ return isNothing(locale)
+ ? abbreviateNumber(count, this.locale.activeLanguage)
+ : abbreviateNumber(count, locale);
+ }
+
+ /**
+ * Convert a date into a time ago label, showing how long ago
+ * the date occurred.
+ *
+ * @param date The date object to convert
+ * @return The localized string representing the amount of time that has passed
+ */
+ timeAgo(date: Date | null | undefined): string | null {
+ if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
+ return null;
+ }
+
+ const relativeTimeIntl = new Intl.RelativeTimeFormat(
+ this.locale.activeLanguage,
+ {
+ style: 'narrow',
+ },
+ );
+
+ const now = new Date();
+
+ const secondsAgo = (now.getTime() - date.getTime()) / 1000;
+ const minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE);
+ const hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR);
+ const daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY);
+ const yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR);
+ const isSameYear = now.getFullYear() === date.getFullYear();
+ const isUpcoming = date.getTime() > now.getTime();
+
+ if (secondsAgo < 0 && isUpcoming) {
+ return new Intl.DateTimeFormat(this.locale.activeLanguage, {
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+ }
+
+ if (secondsAgo < 60) {
+ return relativeTimeIntl.format(-secondsAgo, 'seconds');
+ }
+
+ if (minutesAgo < 60) {
+ return relativeTimeIntl.format(-minutesAgo, 'minutes');
+ }
+
+ if (hoursAgo < 24) {
+ return relativeTimeIntl.format(-hoursAgo, 'hours');
+ }
+
+ if (daysAgo < 7) {
+ return relativeTimeIntl.format(-daysAgo, 'days');
+ }
+
+ if (isSameYear) {
+ return new Intl.DateTimeFormat(this.locale.activeLanguage, {
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+ }
+
+ if (yearsAgo >= 0) {
+ return new Intl.DateTimeFormat(this.locale.activeLanguage, {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ }).format(date);
+ }
+
+ // this return statement is here to satisfy typescript, all possible cases are
+ // satisfied by the above conditionals.
+ return null;
+ }
+
+ timeAgoWithContext(
+ _date: Date | null | undefined,
+ _context: DateContext,
+ ): string | null {
+ return null;
+ }
+
+ formatDate(format: string, date: Date | null | undefined): string | null {
+ if (isNothing(date)) {
+ return null;
+ }
+
+ let formatterConfiguration: Intl.DateTimeFormatOptions | undefined;
+
+ switch (format) {
+ case 'MMM d': // e.g. Jan 29
+ formatterConfiguration = {
+ month: 'short',
+ day: 'numeric',
+ };
+ break;
+ case 'MMMM d': // e.g. January 29
+ formatterConfiguration = {
+ month: 'long',
+ day: 'numeric',
+ };
+ break;
+ case 'j:mm': // e.g. 9:00
+ formatterConfiguration = {
+ hour: 'numeric',
+ minute: '2-digit',
+ };
+ break;
+ case 'MMM d, y': // e.g. Jan 29, 2025
+ formatterConfiguration = {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ };
+ break;
+ case 'MMMM d, y': // e.g. "January 29, 2025"
+ formatterConfiguration = {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ };
+ break;
+ case 'EEE j:mm': // e.g. "SAT 9:00PM"
+ formatterConfiguration = {
+ weekday: 'short',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ };
+ break;
+ case 'd، MMM، yyyy': // e.g. "29 Jan 2025"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ break;
+ case 'MMM d, yyyy': // e.g. "Jan 29, 2025"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ break;
+ case 'd MMM yyyy': // e.g. "29 January 2025"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ };
+ break;
+ case 'yyyy MMMM d': // e.g. "2025 January 29"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ };
+ case 'd M yyyy':
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ break;
+ case 'd MMM., yyyy':
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ };
+ break;
+ case 'dd/MM/yyyy': // e.g. "29/01/2025"
+ formatterConfiguration = {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ };
+ break;
+ case 'd MMM , yyyy': // e.g. "29 Jan , 2025"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ break;
+ case 'd. MMM. yyyy.': // e.g. "29. Jan. 2025."
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ break;
+
+ case 'd. MMM yyyy': // e.g. "29. Jan 2025"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ break;
+
+ case 'yyyy. MMM d.': // e.g. "2025. Jan 29."
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ };
+ break;
+
+ case 'd.M.yyyy': // e.g. "29.1.2025"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'numeric',
+ year: 'numeric',
+ };
+ break;
+
+ case 'd/M/yyyy': // e.g. "29/1/2025"
+ formatterConfiguration = {
+ day: 'numeric',
+ month: 'numeric',
+ year: 'numeric',
+ };
+ break;
+ default:
+ this.logger.warn(
+ `\`formatDate\` called with unexpected format \`${format}\``,
+ );
+ return null;
+ }
+
+ return new Intl.DateTimeFormat(
+ this.locale.activeLanguage,
+ formatterConfiguration,
+ ).format(date);
+ }
+
+ formatDateWithContext(
+ format: string,
+ date: Date | null | undefined,
+ _context: DateContext,
+ ): string | null {
+ return this.formatDate(format, date);
+ }
+
+ formatDateInSentence(
+ sentence: string,
+ format: string,
+ date: Date | null | undefined,
+ ): string | null {
+ const formattedDate = this.formatDate(format, date);
+
+ if (isNothing(formattedDate)) {
+ return null;
+ }
+
+ return (
+ sentence
+ // "Server-Side" LOC keys us `@@date@@` to mark the date to replace
+ .replace('@@date@@', formattedDate)
+ // "Client-Side" LOC keys use `%@` to mark the date to replace
+ .replace('%@', formattedDate)
+ );
+ }
+
+ relativeDate(date: Date | null | undefined): string | null {
+ if (isNothing(date)) {
+ return null;
+ }
+
+ return date.toString();
+ }
+
+ formatDuration(_value: number, _unit: TimeUnit): string | null {
+ throw makeWebDoesNotImplementException('formatDuration');
+ }
+}
diff --git a/src/jet/dependencies/make-dependencies.ts b/src/jet/dependencies/make-dependencies.ts
new file mode 100644
index 0000000..f03c7ca
--- /dev/null
+++ b/src/jet/dependencies/make-dependencies.ts
@@ -0,0 +1,45 @@
+import type { LoggerFactory as AppLoggerFactory } from '@amp/web-apps-logger';
+
+import { Random } from '@amp/web-apps-common/src/jet/dependencies/random';
+import { Host } from '@amp/web-apps-common/src/jet/dependencies/host';
+import { WebBag } from './bag';
+import { WebClient } from './client';
+import { WebConsole } from './console';
+import { Locale } from './locale';
+import { WebLocalization } from './localization';
+import { makeProperties } from './properties';
+import { WebMetricsIdentifiers } from './metrics-identifiers';
+import { Net, type FeaturesCallbacks } from './net';
+import { WebStorage } from './storage';
+import { makeUnauthenticatedUser } from './user';
+import { SEO } from './seo';
+
+export type Dependencies = ReturnType<typeof makeDependencies>;
+
+export function makeDependencies(
+ loggerFactory: AppLoggerFactory,
+ fetch: typeof window.fetch,
+ featuresCallbacks?: FeaturesCallbacks,
+) {
+ const locale = new Locale(loggerFactory);
+ return {
+ bag: new WebBag(loggerFactory, locale),
+ client: new WebClient(
+ // TODO: set the right `BuildType` based on the environment where the app is running
+ 'production',
+ locale,
+ ),
+ console: new WebConsole(loggerFactory),
+ host: new Host(),
+ localization: new WebLocalization(locale, loggerFactory),
+ locale,
+ metricsIdentifiers: new WebMetricsIdentifiers(),
+ net: new Net(fetch, featuresCallbacks),
+ properties: makeProperties(),
+ random: new Random(),
+ seo: new SEO(locale),
+ storage: new WebStorage(),
+ user: makeUnauthenticatedUser(),
+ URL,
+ };
+}
diff --git a/src/jet/dependencies/media-token-service.ts b/src/jet/dependencies/media-token-service.ts
new file mode 100644
index 0000000..45cae9e
--- /dev/null
+++ b/src/jet/dependencies/media-token-service.ts
@@ -0,0 +1,11 @@
+import { MEDIA_API_JWT } from '~/config/media-api';
+
+export class WebMediaTokenService implements MediaTokenService {
+ refreshToken(): Promise<string> {
+ return Promise.resolve(MEDIA_API_JWT);
+ }
+
+ resetToken(): void {
+ // No-op; every request uses the same token for the "web" platform
+ }
+}
diff --git a/src/jet/dependencies/metrics-identifiers.ts b/src/jet/dependencies/metrics-identifiers.ts
new file mode 100644
index 0000000..e48c9d1
--- /dev/null
+++ b/src/jet/dependencies/metrics-identifiers.ts
@@ -0,0 +1,13 @@
+export class WebMetricsIdentifiers implements MetricsIdentifiers {
+ async getIdentifierForContext(
+ _metricsIdentifierKeyContext: MetricsIdentifierKeyContext,
+ ): Promise<string | undefined> {
+ return undefined;
+ }
+
+ async getMetricsFieldsForContexts(
+ _metricsIdentifierKeyContexts: MetricsIdentifierKeyContext[],
+ ): Promise<JSONData | undefined> {
+ return undefined;
+ }
+}
diff --git a/src/jet/dependencies/net.ts b/src/jet/dependencies/net.ts
new file mode 100644
index 0000000..dd7fdb9
--- /dev/null
+++ b/src/jet/dependencies/net.ts
@@ -0,0 +1,117 @@
+import type { Network, FetchRequest, FetchResponse } from '@jet/environment';
+import { fromEntries } from '@amp/web-apps-utils';
+
+import {
+ shouldUseSearchJWT,
+ makeSearchJWTAuthorizationHeader,
+} from '~/config/media-api';
+
+const CORRELATION_KEY_HEADER = 'x-apple-jingle-correlation-key';
+
+type FetchFunction = typeof window.fetch;
+
+// TODO: these URLs are also referenced in `bag` definition; we should have a single
+// source-of-truth for these domains
+const MEDIA_API_ORIGINS = [
+ 'https://amp-api.apps.apple.com',
+ 'https://amp-api-edge.apps.apple.com',
+ 'https://amp-api-search-edge.apps.apple.com',
+];
+
+export interface FeaturesCallbacks {
+ getITFEValues(): string | undefined;
+}
+
+export class Net implements Network {
+ private readonly underlyingFetch: FetchFunction;
+ private readonly getITFEValues: () => string | undefined = () => undefined;
+
+ constructor(
+ underlyingFetch: FetchFunction,
+ featuresCallbacks?: FeaturesCallbacks,
+ ) {
+ this.underlyingFetch = underlyingFetch;
+ this.getITFEValues =
+ featuresCallbacks?.getITFEValues ?? this.getITFEValues;
+ }
+
+ async fetch(request: FetchRequest): Promise<FetchResponse> {
+ const requestStartTime = getTimestampMs();
+ const requestURL = new URL(request.url);
+
+ request.headers = request.headers ?? {};
+
+ if (MEDIA_API_ORIGINS.includes(requestURL.origin)) {
+ // Need to fake this for the server due to Kong origin checks.
+ // Has no effect clientside.
+ request.headers['origin'] = 'https://apps.apple.com';
+
+ const itfe = this.getITFEValues?.();
+
+ if (itfe) {
+ // Add ITFE value as query string when set
+ requestURL.searchParams.set('itfe', itfe);
+ }
+ }
+
+ // The App Store Client will have already injected the JWT from the
+ // `media-token-service` ObjectGraph dependency into the headers. However,
+ // some endpoints need a different JWT. Here we determine if that's the
+ // case and override the existing JWT if necessary.
+ if (shouldUseSearchJWT(requestURL)) {
+ request.headers = {
+ ...request.headers,
+ ...makeSearchJWTAuthorizationHeader(),
+ };
+ }
+
+ // TODO: rdar://78158575: timeout
+ const response = await this.underlyingFetch(requestURL.toString(), {
+ ...request,
+ cache: request.cache ?? undefined,
+ credentials: 'include',
+ headers: request.headers ?? undefined,
+ method: request.method ?? undefined,
+ });
+
+ const responseStartTime = getTimestampMs();
+
+ const { ok, redirected, status, statusText, url } = response;
+
+ const headers = fromEntries(response.headers);
+ const body = await response.text();
+
+ const responseEndTime = getTimestampMs();
+
+ return {
+ ok,
+ headers,
+ redirected,
+ status,
+ statusText,
+ url,
+ body,
+ // TODO: rdar://78158575: redirect: 'manual' to get all metrics?
+ metrics: [
+ {
+ clientCorrelationKey: response.headers.get(
+ CORRELATION_KEY_HEADER,
+ ),
+ pageURL: response.url,
+ requestStartTime,
+ responseStartTime,
+ responseEndTime,
+ // TODO: rdar://78158575: responseWasCached?
+ // TODO: rdar://78158575: parseStartTime/parseEndTime
+ },
+ ],
+ };
+ }
+}
+
+/**
+ * Returns the current UTC timestamp in milliseconds.
+ */
+function getTimestampMs(): number {
+ return Date.now();
+}
diff --git a/src/jet/dependencies/object-graph.ts b/src/jet/dependencies/object-graph.ts
new file mode 100644
index 0000000..40ad0a9
--- /dev/null
+++ b/src/jet/dependencies/object-graph.ts
@@ -0,0 +1,59 @@
+import { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { ObjectGraphType } from '@jet-app/app-store/gameservicesui/src/foundation/object-graph-types';
+
+import type { Dependencies } from './make-dependencies';
+import { WebFeatureFlags } from './feature-flags';
+import { WebMediaTokenService } from './media-token-service';
+
+export { ObjectGraphType };
+
+class AppStoreWebObjectGraph extends AppStoreObjectGraph {
+ /**
+ * Configures the ObjectGraph from our `Dependencies` definition
+ *
+ * @param dependencies
+ * @returns
+ */
+ configureWithDependencies(dependencies: Dependencies) {
+ const {
+ bag,
+ client,
+ console,
+ host,
+ locale,
+ localization,
+ metricsIdentifiers,
+ net,
+ properties,
+ random,
+ seo,
+ storage,
+ user,
+ } = dependencies;
+
+ return this.addingClient(client)
+ .addingNetwork(net)
+ .addingHost(host)
+ .addingBag(bag)
+ .addingLoc(localization)
+ .addingMediaToken(new WebMediaTokenService())
+ .addingConsole(console)
+ .addingAppleSilicon(undefined)
+ .addingProperties(properties)
+ .addingLocale(locale)
+ .addingUser(user)
+ .addingFeatureFlags(new WebFeatureFlags())
+ .addingMetricsIdentifiers(metricsIdentifiers)
+ .addingSEO(seo)
+ .addingStorage(storage)
+ .addingRandom(random);
+ }
+}
+
+export function makeObjectGraph(
+ dependencies: Dependencies,
+): AppStoreObjectGraph {
+ const objectGraph = new AppStoreWebObjectGraph('app-store');
+
+ return objectGraph.configureWithDependencies(dependencies);
+}
diff --git a/src/jet/dependencies/properties.ts b/src/jet/dependencies/properties.ts
new file mode 100644
index 0000000..8956d7f
--- /dev/null
+++ b/src/jet/dependencies/properties.ts
@@ -0,0 +1,5 @@
+export function makeProperties(): PackageProperties {
+ return {
+ clientFeatures: {},
+ };
+}
diff --git a/src/jet/dependencies/seo.ts b/src/jet/dependencies/seo.ts
new file mode 100644
index 0000000..0938afa
--- /dev/null
+++ b/src/jet/dependencies/seo.ts
@@ -0,0 +1,254 @@
+import type { Opt } from '@jet/environment/types/optional';
+import type {
+ ArcadeSeeAllGamesPage,
+ ArticlePage,
+ ChartsHubPage,
+ GenericPage,
+ ReviewsPage,
+ SearchLandingPage,
+ SearchResultsPage,
+ SeeAllPage,
+ ShelfBasedProductPage,
+ TodayPage,
+ TopChartsPage,
+} from '@jet-app/app-store/api/models';
+import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+import type { SEO as SEODependency } from '@jet-app/app-store/foundation/dependencies/seo';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import type { DataContainer } from '@jet-app/app-store/foundation/media/data-structure';
+
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+import type { Locale } from './locale';
+
+import { seoDataForAnyPage, updateCanonicalURL } from '~/utils/seo/common';
+import { seoDataForArticlePage } from '~/utils/seo/article-page';
+import { seoDataForChartsPage } from '~/utils/seo/charts-page';
+import { seoDataForChartsHubPage } from '~/utils/seo/charts-hub-page';
+import { seoDataForDeveloperPage } from '~/utils/seo/developer-page';
+import { seoDataForProductPage } from '~/utils/seo/product-page';
+import { seoDataForAppEventDetailPage } from '~/utils/seo/app-event-detail-page';
+import { seoDataForReviewsPage } from '~/utils/seo/reviews-page';
+import { seoDataForSearchLandingPage } from '~/utils/seo/search-landing-page';
+import { seoDataForSearchResultsPage } from '~/utils/seo/search-results-page';
+import { seoDataForEditorialShelfCollectionPage } from '~/utils/seo/editorial-shelf-collection-page';
+import { seoDataForArcadeSeeAllPage } from '~/utils/seo/arcade-see-all-page';
+import { seoDataForSeeAllPage } from '~/utils/seo/see-all-page';
+
+export class SEO implements SEODependency {
+ private locale: Locale;
+
+ constructor(locale: Locale) {
+ this.locale = locale;
+ }
+
+ private get i18n() {
+ if (this.locale.i18n) {
+ return this.locale.i18n;
+ }
+
+ throw new Error('`i18n` not yet configured ');
+ }
+
+ private getSEODataForGenericPage(page: GenericPage): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ };
+ }
+
+ updateCanonicalURL(page: WebRenderablePage, canonicalURL: string): void {
+ updateCanonicalURL(page, canonicalURL);
+ }
+
+ /// MARK: Page SEO Data Hooks
+
+ getSEODataForAppEventPage(
+ objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForAppEventDetailPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForArcadeSeeAllPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: ArcadeSeeAllGamesPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForArcadeSeeAllPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ page: ArticlePage,
+ response: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForArticlePage(
+ objectGraph,
+ this.i18n,
+ page,
+ response,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForBundlePage(
+ objectGraph: AppStoreObjectGraph,
+ page: ShelfBasedProductPage,
+ data: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return this.getSEODataForProductPage(objectGraph, page, data);
+ }
+
+ getSEODataForChartsPage(
+ objectGraph: AppStoreObjectGraph,
+ page: TopChartsPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForChartsPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForChartsHubPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ChartsHubPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForChartsHubPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForDeveloperPage(
+ objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ response: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForDeveloperPage(objectGraph, response, this.i18n),
+ };
+ }
+
+ getSEODataForEditorialPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return this.getSEODataForGenericPage(page);
+ }
+
+ getSEODataForEditorialShelfCollectionPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForEditorialShelfCollectionPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForGroupingPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return this.getSEODataForGenericPage(page);
+ }
+
+ getSEODataForProductPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ShelfBasedProductPage,
+ data: Opt<DataContainer>,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForProductPage(
+ objectGraph,
+ page,
+ data,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForReviewsPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ReviewsPage,
+ productPage: ShelfBasedProductPage,
+ ): Opt<SeoData> {
+ return {
+ ...this.getSEODataForGenericPage(page),
+ ...seoDataForReviewsPage(this.i18n, page, productPage, objectGraph),
+ };
+ }
+
+ getSEODataForRoomPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: GenericPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForSearchLandingPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: SearchLandingPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForSearchLandingPage(page, this.i18n),
+ };
+ }
+
+ getSEODataForSearchResultsPage(
+ objectGraph: AppStoreObjectGraph,
+ page: SearchResultsPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForSearchResultsPage(
+ page,
+ this.i18n,
+ objectGraph.locale.activeLanguage,
+ ),
+ };
+ }
+
+ getSEODataForTodayPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: TodayPage,
+ ): Opt<SeoData> {
+ return seoDataForAnyPage(page, this.i18n);
+ }
+
+ getSEODataForSeeAllPage(
+ _objectGraph: AppStoreObjectGraph,
+ page: SeeAllPage,
+ ): Opt<SeoData> {
+ return {
+ ...seoDataForAnyPage(page, this.i18n),
+ ...seoDataForSeeAllPage(page, this.i18n),
+ };
+ }
+}
diff --git a/src/jet/dependencies/storage.ts b/src/jet/dependencies/storage.ts
new file mode 100644
index 0000000..fe1da2c
--- /dev/null
+++ b/src/jet/dependencies/storage.ts
@@ -0,0 +1,44 @@
+/**
+ * `AppStoreKit` `Storage` implementation for the "web" client
+ *
+ * Note: The `AppStoreKit` `Storage` interface is declared as a global, which has the (presumably
+ * accidental) side-effect of implicitly being merged with the DOM library's own `Storage` interface
+ * (like `localStorage`), since interfaces declared in the same scope are merged together by TypeScript.
+ * There's no way to tell TypeScript that we only care about the `AppStoreKit` part of it, so
+ * satifying TypeScript here means that we need to implement both interfaces.
+ */
+export class WebStorage extends Map<string, string> implements Storage {
+ /* == "DOM" `Storage` Interface == */
+
+ get length() {
+ return this.size;
+ }
+
+ getItem(key: string): string | null {
+ return this.get(key) ?? null;
+ }
+
+ key(_index: number): string | null {
+ throw new Error('Method not implemented.');
+ }
+
+ removeItem(key: string): void {
+ this.delete(key);
+ }
+
+ setItem(key: string, value: string): void {
+ this.set(key, value);
+ }
+
+ /* == AppStoreKit `Storage` Interface == */
+
+ storeString(aString: string, key: string): void {
+ this.set(key, aString);
+ }
+
+ retrieveString(key: string): string {
+ // Fallback value designed based on how the ObjectGraph `StorageWrapper` handles that specific value
+ // https://github.pie.apple.com/app-store/ios-appstore-app/blob/1761d575b8dc3d7a63e7e36f3320cf9245be9f37/src/foundation/wrappers/storage.ts#L13
+ return this.get(key) ?? '<null>';
+ }
+}
diff --git a/src/jet/dependencies/user.ts b/src/jet/dependencies/user.ts
new file mode 100644
index 0000000..2dad212
--- /dev/null
+++ b/src/jet/dependencies/user.ts
@@ -0,0 +1,30 @@
+/**
+ * Create an "unauthenticated" {@linkcode User} representation
+ *
+ * The property values below match the way that `AppStoreKit` will define the `user`
+ * when the session is not authenticated.
+ */
+export function makeUnauthenticatedUser(): User {
+ return {
+ accountIdentifier: undefined,
+ dsid: undefined,
+ firstName: undefined,
+ // Note: this property is `true` for the native apps but `false` makes
+ // more sense in the context of the "web" client
+ isFitnessAppInstallationAllowed: false,
+ isManagedAppleID: false,
+ isOnDevicePersonalizationEnabled: false,
+ isUnderThirteen: false,
+ katanaId: undefined,
+ lastName: undefined,
+ treatmentGroupIdOverride: undefined,
+ userAgeIfAvailable: undefined,
+
+ onDevicePersonalizationDataContainerForAppIds(appIds) {
+ return {
+ personalizationData: {},
+ metricsData: {},
+ };
+ },
+ };
+}
diff --git a/src/jet/intents/charts-page-redirect-intent-controller.ts b/src/jet/intents/charts-page-redirect-intent-controller.ts
new file mode 100644
index 0000000..06d41ce
--- /dev/null
+++ b/src/jet/intents/charts-page-redirect-intent-controller.ts
@@ -0,0 +1,68 @@
+import type { IntentController } from '@jet/environment/dispatching';
+import type { RouteProvider } from '@jet/environment/routing';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
+import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
+import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
+import { makeChartsPageURL } from '@jet-app/app-store/common/charts/charts-page-url';
+import { makeChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent';
+import { GenericPage } from '@jet-app/app-store/api/models';
+import { isPreviewPlatform } from '@jet-app/app-store/api/models/preview-platform';
+import { notFoundError } from '@jet-app/app-store/foundation/media/network';
+
+const makeIntent = (opts) => ({
+ ...opts,
+ $kind: 'ChartsPageRedirect',
+});
+
+// This will catch URLs like `/charts/iphone`
+const { routes: routesWithoutGenreId } = generateRoutes(
+ makeIntent,
+ '/charts/{platform}',
+);
+
+// This will catch URLs like `/charts/iphone/utilities-apps/6002`
+const { routes: routesWithGenreId } = generateRoutes(
+ makeIntent,
+ '/charts/{platform}/{slug}/{genreId}',
+);
+
+function chartsPageRedirectRoutes(objectGraph: AppStoreObjectGraph) {
+ return [
+ ...routesWithoutGenreId(objectGraph),
+ ...routesWithGenreId(objectGraph),
+ ];
+}
+
+export const ChartsPageRedirectIntentController: IntentController<any> &
+ RouteProvider = {
+ $intentKind: 'ChartsPageRedirect',
+
+ routes: chartsPageRedirectRoutes,
+
+ async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
+ return await withActiveIntent(
+ objectGraphWithoutActiveIntent,
+ intent,
+ async (objectGraph) => {
+ const page = new GenericPage([]);
+ const chartPageIntent = makeChartsPageIntent(intent);
+
+ if (!isPreviewPlatform(intent.platform)) {
+ throw notFoundError();
+ }
+
+ // Setting the `canonicalUrl` on the page to normal Charts Page URL (e.g. /{platform}/charts)
+ // will trigger a 301 redirect to the that page.
+ page.canonicalURL = makeChartsPageURL(
+ objectGraph,
+ chartPageIntent,
+ );
+
+ injectWebNavigation(objectGraph, page, intent.platform);
+
+ return page;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/error-page-intent-controller.ts b/src/jet/intents/error-page-intent-controller.ts
new file mode 100644
index 0000000..59ac1fd
--- /dev/null
+++ b/src/jet/intents/error-page-intent-controller.ts
@@ -0,0 +1,52 @@
+import type { Intent, IntentController } from '@jet/environment/dispatching';
+import type { Opt } from '@jet/environment/types/optional';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
+import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
+
+import { ErrorPage } from '~/jet/models/error-page';
+import type { Page } from '~/jet/models/page';
+import { getRejectedIntent } from '~/jet/utils/error-metadata';
+import { isWithPlatform } from '~/jet/utils/with-platform';
+
+interface ErrorPageIntent extends Intent<Page> {
+ $kind: 'ErrorPageIntent';
+ error: Opt<Error>;
+}
+
+export function makeErrorPageIntent(
+ options: Omit<ErrorPageIntent, '$kind'>,
+): ErrorPageIntent {
+ return {
+ ...options,
+ $kind: 'ErrorPageIntent',
+ };
+}
+
+export const ErrorPageIntentController: IntentController<ErrorPageIntent> = {
+ $intentKind: 'ErrorPageIntent',
+
+ async perform(
+ intent,
+ objectGraphWithoutActiveIntent: AppStoreObjectGraph,
+ ): Promise<Page> {
+ const { error } = intent;
+ const rejectedIntent = error ? getRejectedIntent(error) : null;
+ const platform =
+ (rejectedIntent && isWithPlatform(rejectedIntent)
+ ? rejectedIntent.platform
+ : null) ?? 'iphone';
+
+ return await withActiveIntent(
+ objectGraphWithoutActiveIntent,
+ { ...intent, platform },
+ async (objectGraph) => {
+ const page = new ErrorPage({ error: intent.error });
+
+ injectWebNavigation(objectGraph, page, platform);
+
+ return page;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts
new file mode 100644
index 0000000..046914b
--- /dev/null
+++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts
@@ -0,0 +1,18 @@
+import type { IntentController } from '@jet/environment/dispatching/base/intent-controller';
+import type { LintedMetricsEvent } from '@jet/environment/types/metrics';
+
+import {
+ type LintMetricsEventIntent,
+ LintMetricsEventIntentKind,
+} from './lint-metrics-event-intent';
+
+export const LintMetricsEventIntentController: IntentController<LintMetricsEventIntent> =
+ {
+ $intentKind: LintMetricsEventIntentKind.Name,
+
+ async perform(
+ intent: LintMetricsEventIntent,
+ ): Promise<LintedMetricsEvent> {
+ return { fields: intent.fields };
+ },
+ };
diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts
new file mode 100644
index 0000000..a2a085e
--- /dev/null
+++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts
@@ -0,0 +1,23 @@
+import type { Intent } from '@jet/environment/dispatching';
+import type {
+ LintedMetricsEvent,
+ MetricsFields,
+} from '@jet/environment/types/metrics';
+
+export const enum LintMetricsEventIntentKind {
+ Name = 'LintMetricsEventIntent',
+}
+
+export interface LintMetricsEventIntent extends Intent<LintedMetricsEvent> {
+ $kind: LintMetricsEventIntentKind.Name;
+ fields: MetricsFields;
+}
+
+export function makeLintMetricsEventIntent(
+ options: Omit<LintMetricsEventIntent, '$kind'>,
+): LintMetricsEventIntent {
+ return {
+ ...options,
+ $kind: LintMetricsEventIntentKind.Name,
+ };
+}
diff --git a/src/jet/intents/route-url/route-url-controller.ts b/src/jet/intents/route-url/route-url-controller.ts
new file mode 100644
index 0000000..8c8fdb6
--- /dev/null
+++ b/src/jet/intents/route-url/route-url-controller.ts
@@ -0,0 +1,28 @@
+import { isSome } from '@jet/environment/types/optional';
+import type { IntentController } from '@jet/environment/dispatching';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { isRoutableIntent } from '@jet-app/app-store/api/intents/routable-intent';
+
+import type { RouteUrlIntent } from '~/jet/intents';
+import { makeFlowAction } from '~/jet/models';
+
+export const RouteUrlIntentController: IntentController<RouteUrlIntent> = {
+ $intentKind: 'RouteUrlIntent',
+
+ async perform(intent: RouteUrlIntent, objectGraph: AppStoreObjectGraph) {
+ const targetIntent = objectGraph.router.intentFor(intent.url);
+
+ if (isSome(targetIntent) && isRoutableIntent(targetIntent)) {
+ return {
+ // intent needed for SSR
+ intent: targetIntent,
+ // only ever used by client; only clients have actions
+ action: makeFlowAction(targetIntent),
+ storefront: targetIntent.storefront,
+ language: targetIntent.language,
+ };
+ }
+
+ return null;
+ },
+};
diff --git a/src/jet/intents/route-url/route-url-intent.ts b/src/jet/intents/route-url/route-url-intent.ts
new file mode 100644
index 0000000..841bd25
--- /dev/null
+++ b/src/jet/intents/route-url/route-url-intent.ts
@@ -0,0 +1,48 @@
+import type { Optional } from '@jet/environment/types/optional';
+import type { Intent } from '@jet/environment/dispatching';
+import type { FlowAction } from '@jet-app/app-store/api/models';
+
+import type {
+ NormalizedStorefront,
+ NormalizedLanguage,
+} from '@jet-app/app-store/api/locale';
+
+/**
+ * A response from the router given an incoming (deeplink) URL.
+ */
+export interface RouterResponse {
+ /**
+ * The intent to dispatch to get the view model for this URL.
+ */
+ intent: Intent<unknown>;
+
+ /**
+ * action to navigate to a new page of the app.
+ */
+ action: FlowAction;
+
+ storefront: NormalizedStorefront;
+
+ language: NormalizedLanguage;
+}
+
+export interface RouteUrlIntent extends Intent<Optional<RouterResponse>> {
+ $kind: 'RouteUrlIntent';
+
+ /**
+ * The URL to route (ex. "https://podcasts.apple.com/us/show/serial/id123").
+ */
+ url: string;
+}
+
+export function isRouteUrlIntent(
+ intent: Intent<unknown>,
+): intent is RouteUrlIntent {
+ return intent.$kind === 'RouteUrlIntent';
+}
+
+export function makeRouteUrlIntent(
+ options: Omit<RouteUrlIntent, '$kind'>,
+): RouteUrlIntent {
+ return { $kind: 'RouteUrlIntent', ...options };
+}
diff --git a/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts
new file mode 100644
index 0000000..a1b049c
--- /dev/null
+++ b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts
@@ -0,0 +1,41 @@
+import type { IntentController } from '@jet/environment/dispatching';
+import type { RouteProvider } from '@jet/environment/routing';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
+import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
+import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
+
+import { StaticMessagePage } from '~/jet/models/static-message-page';
+
+const { routes, makeCanonicalUrl } = generateRoutes(
+ (opts) => ({
+ ...opts,
+ $kind: 'CarrierPageIntent',
+ }),
+ '/carrier',
+);
+
+export const CarrierPageIntentController: IntentController<any> &
+ RouteProvider = {
+ $intentKind: 'CarrierPageIntent',
+
+ routes,
+
+ async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
+ return await withActiveIntent(
+ objectGraphWithoutActiveIntent,
+ intent,
+ async (objectGraph) => {
+ const page = new StaticMessagePage({
+ titleLocKey: 'ASE.Web.AppStore.Carrier.Title',
+ contentType: 'carrier',
+ });
+
+ page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
+
+ injectWebNavigation(objectGraph, page, intent.platform);
+ return page;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts
new file mode 100644
index 0000000..ba2babd
--- /dev/null
+++ b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts
@@ -0,0 +1,49 @@
+import type { IntentController } from '@jet/environment/dispatching';
+import type { RouteProvider } from '@jet/environment/routing';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
+import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
+import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
+
+import { StaticMessagePage } from '~/jet/models/static-message-page';
+
+const { routes, makeCanonicalUrl } = generateRoutes(
+ (opts) => ({
+ ...opts,
+ $kind: 'ContingentPriceIntent',
+ }),
+ '/contingent-price/{offerId}',
+ [],
+ {
+ extraRules: [
+ {
+ regex: [/(?:\/[a-z]{2})?\/contingent-price/],
+ },
+ ],
+ },
+);
+
+export const ContingentPricingIntentController: IntentController<any> &
+ RouteProvider = {
+ $intentKind: 'ContingentPriceIntent',
+
+ routes,
+
+ async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
+ return await withActiveIntent(
+ objectGraphWithoutActiveIntent,
+ intent,
+ async (objectGraph) => {
+ const page = new StaticMessagePage({
+ titleLocKey: 'ASE.Web.AppStore.WinBack.Title',
+ contentType: 'contingent-price',
+ });
+
+ page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
+
+ injectWebNavigation(objectGraph, page, intent.platform);
+ return page;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts
new file mode 100644
index 0000000..caa02f4
--- /dev/null
+++ b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts
@@ -0,0 +1,41 @@
+import type { IntentController } from '@jet/environment/dispatching';
+import type { RouteProvider } from '@jet/environment/routing';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
+import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
+import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
+
+import { StaticMessagePage } from '~/jet/models/static-message-page';
+
+const { routes, makeCanonicalUrl } = generateRoutes(
+ (opts) => ({
+ ...opts,
+ $kind: 'InvoicePageIntent',
+ }),
+ '/invoice',
+);
+
+export const InvoicePageIntentController: IntentController<any> &
+ RouteProvider = {
+ $intentKind: 'InvoicePageIntent',
+
+ routes,
+
+ async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
+ return await withActiveIntent(
+ objectGraphWithoutActiveIntent,
+ intent,
+ async (objectGraph) => {
+ const page = new StaticMessagePage({
+ titleLocKey: 'ASE.Web.AppStore.Invoice.Title',
+ contentType: 'invoice',
+ });
+
+ page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
+
+ injectWebNavigation(objectGraph, page, intent.platform);
+ return page;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts
new file mode 100644
index 0000000..2b78ba0
--- /dev/null
+++ b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts
@@ -0,0 +1,49 @@
+import type { IntentController } from '@jet/environment/dispatching';
+import type { RouteProvider } from '@jet/environment/routing';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
+import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
+import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
+
+import { StaticMessagePage } from '~/jet/models/static-message-page';
+
+const { routes, makeCanonicalUrl } = generateRoutes(
+ (opts) => ({
+ ...opts,
+ $kind: 'WinBackPageIntent',
+ }),
+ '/win-back/{offerId}',
+ [],
+ {
+ extraRules: [
+ {
+ regex: [/(?:\/[a-z]{2})?\/win-back/],
+ },
+ ],
+ },
+);
+
+export const WinBackPageIntentController: IntentController<any> &
+ RouteProvider = {
+ $intentKind: 'WinBackPageIntent',
+
+ routes,
+
+ async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
+ return await withActiveIntent(
+ objectGraphWithoutActiveIntent,
+ intent,
+ async (objectGraph) => {
+ const page = new StaticMessagePage({
+ titleLocKey: 'ASE.Web.AppStore.WinBack.Title',
+ contentType: 'win-back',
+ });
+
+ page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
+
+ injectWebNavigation(objectGraph, page, intent.platform);
+ return page;
+ },
+ );
+ },
+};
diff --git a/src/jet/jet.ts b/src/jet/jet.ts
new file mode 100644
index 0000000..75b0afc
--- /dev/null
+++ b/src/jet/jet.ts
@@ -0,0 +1,320 @@
+import type I18N from '@amp/web-apps-localization';
+import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
+
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import type { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime';
+import type {
+ NormalizedStorefront,
+ NormalizedLanguage,
+} from '@jet-app/app-store/api/locale';
+
+import type {
+ LintedMetricsEvent,
+ MetricsFields,
+ PageMetrics,
+} from '@jet/environment/types/metrics';
+import { type Opt } from '@jet/environment/types/optional';
+import type { Intent, IntentReturnType } from '@jet/environment/dispatching';
+import {
+ type ActionImplementation,
+ ActionDispatcher,
+ type ActionOutcome,
+ type MetricsBehavior,
+} from '@jet/engine';
+
+import { Metrics } from '@amp/web-apps-metrics-8';
+import { makeMetricsSettings } from '~/jet/metrics/settings';
+import { makeMetricsProviders } from '~/jet/metrics/providers';
+import { config as metricsConfig } from '~/config/metrics';
+
+import { bootstrap } from '~/jet/bootstrap';
+import { makeDependencies } from '~/jet/dependencies';
+import type { Locale } from '~/jet/dependencies/locale';
+import type { WebLocalization } from '~/jet/dependencies/localization';
+import {
+ type RouterResponse,
+ type RouteUrlIntent,
+ makeRouteUrlIntent,
+ makeLintMetricsEventIntent,
+} from '~/jet/intents';
+import type { Page, ActionModel } from '~/jet/models';
+import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
+import { CONTEXT_NAME } from '~/jet/svelte';
+import type { FeaturesCallbacks } from './dependencies/net';
+
+/**
+ * The entry point for interacting with the Jet shared business logic.
+ */
+export class Jet {
+ private readonly log: Logger;
+ private readonly runtime: AppStoreRuntime;
+ private readonly actionDispatcher: ActionDispatcher;
+ private readonly metrics: Metrics;
+ private readonly locale: Locale;
+
+ /**
+ * Intents (and their resolved data) that have yet to be dispatched that
+ * were recently dispatched. These are consulted before dispatching
+ * intents. If a prefetched intent exists for an ongoing dispatch, it will
+ * be used as the return value instead of actually dispatching.
+ *
+ * This can be used, for example, for intents that are dispatched during
+ * SSR. The server can serialize the intents it dispatches and then the
+ * client can populate this, to avoid re-dispatching the intents.
+ */
+ private readonly prefetchedIntents: PrefetchedIntents;
+
+ /**
+ * A set of the action types that already have registered implementations to catch
+ * double registers.
+ */
+ private readonly wiredActions: Set<string>;
+
+ readonly objectGraph: AppStoreObjectGraph;
+ readonly localization: WebLocalization;
+
+ static load({
+ loggerFactory,
+ context,
+ fetch,
+ prefetchedIntents = PrefetchedIntents.empty(),
+ featuresCallbacks,
+ }: {
+ loggerFactory: LoggerFactory;
+ context: Map<string, unknown>;
+ fetch: typeof window.fetch;
+ prefetchedIntents?: PrefetchedIntents;
+ featuresCallbacks?: FeaturesCallbacks;
+ }): Jet {
+ const dependencies = makeDependencies(
+ loggerFactory,
+ fetch,
+ featuresCallbacks,
+ );
+ const { runtime, objectGraph } = bootstrap(dependencies);
+ let jet: Jet;
+
+ const processEvent = async (
+ fields: MetricsFields,
+ ): Promise<LintedMetricsEvent> => {
+ const intent = makeLintMetricsEventIntent({ fields });
+ return jet.dispatch(intent);
+ };
+ const metrics = Metrics.load(
+ loggerFactory,
+ context,
+ processEvent,
+ metricsConfig,
+ makeMetricsProviders(objectGraph),
+ makeMetricsSettings(context),
+ );
+ const actionDispatcher = new ActionDispatcher(
+ // `@amp/web-apps-metrics` depends on a different version of `@jet/engine` with a different
+ // type definition for `MetricsPipeline`
+ // @ts-expect-error
+ metrics.metricsPipeline,
+ );
+
+ jet = new Jet(
+ loggerFactory.loggerFor('Jet'),
+ runtime,
+ objectGraph,
+ actionDispatcher,
+ metrics,
+ dependencies.locale,
+ prefetchedIntents,
+ dependencies.localization,
+ );
+
+ context.set(CONTEXT_NAME, jet);
+
+ return jet;
+ }
+
+ private constructor(
+ log: Logger,
+ runtime: AppStoreRuntime,
+ objectGraph: AppStoreObjectGraph,
+ actionDispatcher: ActionDispatcher,
+ metrics: Metrics,
+ locale: Locale,
+ prefetchedIntents: PrefetchedIntents,
+ localization: WebLocalization,
+ ) {
+ this.log = log;
+ this.runtime = runtime;
+ this.objectGraph = objectGraph;
+ this.actionDispatcher = actionDispatcher;
+
+ this.metrics = metrics;
+ this.locale = locale;
+ this.localization = localization;
+
+ this.prefetchedIntents = prefetchedIntents;
+
+ this.wiredActions = new Set();
+ }
+
+ async didEnterPage(page: Page | null): Promise<void> {
+ // This is a very temporary hacky fix to move the `platformContext` value from
+ // `pageRenderFields` to `pageFields`, which will eventually happen in the Jet
+ // business logic.
+ const pageWithMetrics = { ...page };
+ if (pageWithMetrics.pageMetrics?.pageFields) {
+ pageWithMetrics.pageMetrics.pageFields.platformContext =
+ pageWithMetrics.pageMetrics.pageRenderFields?.platformContext;
+ }
+
+ // @ts-expect-error - pageMetrics property not required at runtime
+ await this.metrics.didEnterPage(page);
+ }
+
+ get pageMetrics(): Opt<PageMetrics> {
+ return this.metrics.currentPageMetrics?.pageMetrics;
+ }
+
+ /**
+ * Dispatch a Jet intent, returning its output.
+ *
+ * @param intent The intent to dispatch
+ * @return output The value returned by the intent's controller
+ */
+ async dispatch<I extends Intent<unknown>>(
+ intent: I,
+ ): Promise<IntentReturnType<I>> {
+ const data = this.prefetchedIntents.get(intent);
+ if (data) {
+ this.log.info(
+ 're-using prefetched intent response for:',
+ intent,
+ 'data:',
+ data,
+ );
+ return data;
+ }
+
+ // TODO: rdar://73165545 (Error Handling Across App)
+ return this.runtime.dispatch(intent);
+ }
+
+ /**
+ * Perform a Jet action, returning the outcome.
+ *
+ * @param action The action to perform
+ * @param metricsBehavior Indicates how to handle metrics for this action
+ * @return outcome Either 'performed' or 'unsupported'
+ */
+ async perform(
+ action: ActionModel,
+ metricsBehavior?: MetricsBehavior,
+ ): Promise<ActionOutcome> {
+ if (!metricsBehavior) {
+ if (this.pageMetrics) {
+ metricsBehavior = {
+ behavior: 'fromAction',
+ context: this.pageMetrics || {},
+ };
+ } else {
+ this.log.warn(
+ 'No pageMetrics found for jet.perform action:',
+ action,
+ );
+ metricsBehavior = { behavior: 'notProcessed' };
+ }
+ }
+ // TODO: rdar://73165545 (Error Handling Across App): handle throw
+ const outcome = await this.actionDispatcher.perform(
+ action,
+ metricsBehavior,
+ );
+
+ if (outcome === 'unsupported') {
+ this.log.error(
+ 'unable to perform action:',
+ action,
+ metricsBehavior,
+ );
+ }
+
+ return outcome;
+ }
+
+ /**
+ * Register an implementation to handle a Jet action.
+ *
+ * @param kind The type of the action
+ * @param implementation The code to run when that action is performed
+ */
+ onAction<A extends ActionModel>(
+ kind: string,
+ implementation: ActionImplementation<A>,
+ ): void {
+ if (this.wiredActions.has(kind)) {
+ throw new Error(
+ `onAction called twice with the same action type: ${kind}`,
+ );
+ }
+
+ this.actionDispatcher.register(kind, implementation);
+ this.wiredActions.add(kind);
+ }
+
+ /**
+ * Route a URL using Jet, returning the routing if the URL could be routed.
+ *
+ * @param url The URL to route
+ * @return routing The routing of the URL or null if unrouteable
+ */
+ async routeUrl(url: string): Promise<RouterResponse | null> {
+ // TODO: rdar://73165545 (Error Handling Across App): what about 404s?
+ const routerResponse = await this.dispatch<RouteUrlIntent>(
+ makeRouteUrlIntent({ url }),
+ );
+
+ if (routerResponse && routerResponse.action) {
+ return routerResponse;
+ }
+
+ this.log.warn(
+ 'url did not resolve to a flow action with a discernable intent:',
+ url,
+ routerResponse,
+ );
+
+ return null;
+ }
+
+ /**
+ * Propagates the routing-derrived localization information through the Jet app
+ *
+ * The {@link Locale} instance that is configured here is referenced by
+ * the rest of our Jet dependencies in order to lazily retreive the locale
+ * information.
+ *
+ * @param localizer
+ * @param storefront
+ * @param language
+ */
+ setLocale(
+ localizer: I18N,
+ storefront: NormalizedStorefront,
+ language: NormalizedLanguage,
+ ): void {
+ this.locale.i18n = localizer;
+ this.locale.setActiveLocale({ storefront, language });
+ }
+
+ recordCustomMetricsEvent(fields?: Opt<MetricsFields>) {
+ this.metrics.recordCustomEvent(fields);
+ }
+
+ enableFunnelKit(): void {
+ this.metrics.enableFunnelKit();
+ }
+
+ disableFunnelKit(): void {
+ this.metrics.disableFunnelKit();
+ }
+
+ // TODO: rdar://75011660 (Bridge Jet to MetricsKit and PerfKit for reporting)
+}
diff --git a/src/jet/metrics/providers/StorefrontFieldsProvider.ts b/src/jet/metrics/providers/StorefrontFieldsProvider.ts
new file mode 100644
index 0000000..f4c5448
--- /dev/null
+++ b/src/jet/metrics/providers/StorefrontFieldsProvider.ts
@@ -0,0 +1,19 @@
+import type {
+ MetricsFieldsBuilder,
+ MetricsFieldsContext,
+ MetricsFieldsProvider,
+} from '@jet/engine';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { getLocale } from '@jet-app/app-store/common/locale';
+
+export class StorefrontFieldsProvider implements MetricsFieldsProvider {
+ constructor(private readonly objectGraph: AppStoreObjectGraph) {}
+
+ addMetricsFields(
+ builder: MetricsFieldsBuilder,
+ _context: MetricsFieldsContext,
+ ) {
+ const { storefront } = getLocale(this.objectGraph);
+ builder.addValue(storefront, 'storeFrontCountryCode');
+ }
+}
diff --git a/src/jet/metrics/providers/index.ts b/src/jet/metrics/providers/index.ts
new file mode 100644
index 0000000..98f3780
--- /dev/null
+++ b/src/jet/metrics/providers/index.ts
@@ -0,0 +1,15 @@
+import type { MetricsProvider } from '@amp/web-apps-metrics-8';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+
+import { StorefrontFieldsProvider } from './StorefrontFieldsProvider';
+
+export function makeMetricsProviders(
+ objectGraph: AppStoreObjectGraph,
+): MetricsProvider[] {
+ return [
+ {
+ provider: new StorefrontFieldsProvider(objectGraph),
+ request: 'storeFrontCountryCode',
+ },
+ ];
+}
diff --git a/src/jet/metrics/settings.ts b/src/jet/metrics/settings.ts
new file mode 100644
index 0000000..c0c5075
--- /dev/null
+++ b/src/jet/metrics/settings.ts
@@ -0,0 +1,20 @@
+import type { MetricSettings } from '@amp/web-apps-metrics-8';
+
+/**
+ * Generates a metric settings for Metrics class.
+ *
+ * @param context - app context map
+ * @returns MetricSettings
+ */
+export function makeMetricsSettings(
+ context: Map<string, unknown>,
+): MetricSettings {
+ return {
+ shouldEnableFunnelKit: function (): boolean {
+ return false;
+ },
+ getConsumerId: async function (): Promise<string> {
+ return null;
+ },
+ };
+}
diff --git a/src/jet/models/error-page.ts b/src/jet/models/error-page.ts
new file mode 100644
index 0000000..80bcdf5
--- /dev/null
+++ b/src/jet/models/error-page.ts
@@ -0,0 +1,15 @@
+import { GenericPage } from '@jet-app/app-store/api/models';
+import type { Opt } from '@jet/environment';
+
+export class ErrorPage extends GenericPage {
+ constructor({ error }: { error: Opt<Error> }) {
+ super([]);
+ this.error = error;
+ }
+
+ // Used in our type guards to narrow a `Page` down to a `ErrorPage`
+ pageType: string = 'errorPage';
+
+ // The browser `Error`, used to determine which message to display to the user
+ error: Opt<Error>;
+}
diff --git a/src/jet/models/external-action.ts b/src/jet/models/external-action.ts
new file mode 100644
index 0000000..25dbd14
--- /dev/null
+++ b/src/jet/models/external-action.ts
@@ -0,0 +1,7 @@
+import type { Action, ExternalUrlAction } from '@jet-app/app-store/api/models';
+
+export function isExternalUrlAction(
+ action: Action,
+): action is ExternalUrlAction {
+ return action.$kind === 'ExternalUrlAction';
+}
diff --git a/src/jet/models/flow-action.ts b/src/jet/models/flow-action.ts
new file mode 100644
index 0000000..d5edb40
--- /dev/null
+++ b/src/jet/models/flow-action.ts
@@ -0,0 +1,28 @@
+import type { Intent } from '@jet/environment/dispatching';
+import { FlowAction } from '@jet-app/app-store/api/models';
+
+export const FLOW_ACTION_KIND: FlowAction['$kind'] = 'flowAction';
+
+/**
+ * Creates a FlowAction For a given destination.
+ *
+ * Note: this is only here temporarily as a convenience for the "web" client, to be used
+ * while the upstream `FlowAction` is represented as a class that needs to be constructed,
+ * so those details are abstracted away from our codebase. Once `FlowAction` has been
+ * migrated to a POJO, there should be a factory-function provided that we should leverage
+ * instead
+ *
+ * @param destination Destination of the `FlowAction`
+ */
+export function makeFlowAction(destination: Intent<unknown>): FlowAction {
+ const action = new FlowAction(
+ // This data is only used by the Jet app's `PageRouter` architecture, which is not
+ // relevant for us. We should safely be able to pass an arbitrary value here.
+ 'page',
+ );
+
+ // The important part for the "web" client router: setting the `destination`
+ action.destination = destination;
+
+ return action;
+}
diff --git a/src/jet/models/page.ts b/src/jet/models/page.ts
new file mode 100644
index 0000000..a05e59f
--- /dev/null
+++ b/src/jet/models/page.ts
@@ -0,0 +1,177 @@
+import type {
+ ArticlePage,
+ ChartsHubPage,
+ GenericPage,
+ SearchLandingPage,
+ SearchResultsPage,
+ ShelfBasedProductPage,
+ TopChartsPage,
+ TodayPage,
+ SeeAllPage,
+} from '@jet-app/app-store/api/models';
+import { StaticMessagePage } from '~/jet/models/static-message-page';
+import { isObject } from '~/utils/types';
+import { ErrorPage } from './error-page';
+import type { WebRenderablePage } from 'node_modules/@jet-app/app-store/src/api/models/web-renderable-page';
+
+/**
+ * The union of every type of page that the App Store Onyx app can render
+ */
+export type Page = (
+ | ArticlePage
+ | ChartsHubPage
+ | GenericPage
+ | SearchLandingPage
+ | SearchResultsPage
+ | ShelfBasedProductPage
+ | StaticMessagePage
+ | TopChartsPage
+ | TodayPage
+ | ErrorPage
+) &
+ // TS needs to be told this explicitly, even though all the above implement this
+ WebRenderablePage;
+
+/**
+ * Detects if {@linkcode page} is actually an {@linkcode AppEventDetailPage}
+ */
+export function isAppEventDetailPage(page: Page): page is GenericPage {
+ return (
+ 'shelves' in page &&
+ page.shelves.some(({ contentType }) => contentType === 'appEventDetail')
+ );
+}
+
+/**
+ * Detects if {@linkcode page} is actually an {@linkcode ArticlePage}
+ */
+export function isArticlePage(page: Page): page is ArticlePage {
+ return 'card' in page && 'shelves' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode ChartsHubPage}
+ */
+export function isChartsHubPage(page: Page): page is ChartsHubPage {
+ return 'charts' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode GenericPage}
+ */
+export function isGenericPage(page: Page): page is GenericPage {
+ return 'shelves' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode ShelfBasedProductPage}
+ */
+export function isShelfBasedProductPage(
+ page: Page,
+): page is ShelfBasedProductPage {
+ return 'shelfMapping' in page && !('seeAllType' in page);
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode SeeAllPage}
+ */
+export function isSeeAllPage(page: Page): page is SeeAllPage {
+ return 'seeAllType' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode SearchLandingPage}
+ */
+export function isSearchLandingPage(page: Page): page is SearchLandingPage {
+ return 'adIncidents' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode SearchResultsPage}
+ */
+export function isSearchResultsPage(page: Page): page is SearchResultsPage {
+ return 'searchClearAction' in page || 'searchCancelAction' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode TopChartsPage}
+ */
+export function isTopChartsPage(page: Page): page is TopChartsPage {
+ return 'segments' in page && 'categories' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode TodayPage}
+ */
+export function isTodayPage(page: Page): page is TodayPage {
+ return 'titleDetail' in page;
+}
+
+/**
+ * Detects if {@linkcode page} is actually a {@linkcode StaticMessagePage}
+ */
+export function isStaticMessagePage(
+ page: GenericPage,
+): page is StaticMessagePage {
+ return 'pageType' in page && page.pageType === 'staticMessagePage';
+}
+
+export function isErrorPage(page: GenericPage) {
+ return 'pageType' in page && page.pageType === 'errorPage';
+}
+
+/**
+ * Type-guard that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition
+ */
+export function isPage(page: unknown): page is Page {
+ if (!isObject(page)) {
+ return false;
+ }
+
+ return [
+ isAppEventDetailPage,
+ isArticlePage,
+ isChartsHubPage,
+ isGenericPage,
+ isShelfBasedProductPage,
+ isSearchLandingPage,
+ isSearchResultsPage,
+ isTopChartsPage,
+ isTodayPage,
+ isErrorPage,
+ isSeeAllPage,
+ ].some((specificPageTypePredicate) =>
+ specificPageTypePredicate(
+ // This type-cast reflects the fact that we don't really know if `page` is really a `Page`,
+ // but that we're going to use the type-guards of our `Page` members to see if `page` looks
+ // like one of them
+ page as Page,
+ ),
+ );
+}
+
+/**
+ * Type-assertion that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition
+ */
+export function assertIsPage(page: unknown): asserts page is Page {
+ if (!isPage(page)) {
+ throw new Error(
+ 'The view-model for the dispatched `Intent` does not match a known renderable shape',
+ );
+ }
+}
+
+/**
+ * Detects if {@linkcode page} has the Vision Pro pathname in it's URL.
+ */
+export function hasVisionProUrl(page: GenericPage) {
+ if (!page.canonicalURL) {
+ return false;
+ }
+
+ const url = new URL(page.canonicalURL);
+ return (
+ url.pathname.includes('/vision/apps-and-games') ||
+ url.pathname.includes('/vision/arcade')
+ );
+}
diff --git a/src/jet/models/static-message-page.ts b/src/jet/models/static-message-page.ts
new file mode 100644
index 0000000..91dafb0
--- /dev/null
+++ b/src/jet/models/static-message-page.ts
@@ -0,0 +1,33 @@
+import { GenericPage } from '@jet-app/app-store/api/models';
+
+const contentTypes = [
+ 'win-back',
+ 'carrier',
+ 'invoice',
+ 'contingent-price',
+] as const;
+
+export type ContentType = (typeof contentTypes)[number];
+
+export class StaticMessagePage extends GenericPage {
+ constructor({
+ titleLocKey,
+ contentType,
+ }: {
+ titleLocKey: string;
+ contentType: ContentType;
+ }) {
+ super([]);
+ this.titleLocKey = titleLocKey;
+ this.contentType = contentType;
+ }
+
+ titleLocKey?: string;
+
+ // Used to indicate which type of content the page needs to show, used to pull in the proper
+ // LOC keys when rendering
+ contentType: ContentType;
+
+ // Used in our type guards to narrow a `Page` down to a `StaticMessagePage`
+ pageType: string = 'staticMessagePage';
+}
diff --git a/src/jet/svelte.ts b/src/jet/svelte.ts
new file mode 100644
index 0000000..f1870ca
--- /dev/null
+++ b/src/jet/svelte.ts
@@ -0,0 +1,45 @@
+import { getContext } from 'svelte';
+import type { Opt } from '@jet/environment';
+import type { ActionOutcome } from '@jet/engine';
+
+import type { ActionModel } from '~/jet/models';
+import type { Jet } from '~/jet/jet';
+
+export const CONTEXT_NAME = 'jet';
+
+/**
+ * Gets the current Jet instance from the Svelte context.
+ *
+ * @return jet The current instance of Jet
+ */
+export function getJet(): Jet {
+ const jet = getContext<Opt<Jet>>(CONTEXT_NAME);
+
+ if (!jet) {
+ throw new Error('getJet called before Jet.load');
+ }
+
+ return jet;
+}
+
+/**
+ * Jet helper to expose jet.perform in single location
+ *
+ * @return Promise<ActionOutcome>
+ */
+type ActionUndefined = 'noActionProvided';
+
+export function getJetPerform(): (
+ action: ActionModel,
+) => Promise<ActionOutcome | ActionUndefined> {
+ const jet = getJet();
+
+ return (action: ActionModel) => {
+ if (!action) {
+ //TODO: rdar://73165545 (Error Handling Across App)
+ return Promise.resolve('noActionProvided');
+ }
+
+ return jet.perform(action);
+ };
+}
diff --git a/src/jet/utils/app-event-formatted-date.ts b/src/jet/utils/app-event-formatted-date.ts
new file mode 100644
index 0000000..c885687
--- /dev/null
+++ b/src/jet/utils/app-event-formatted-date.ts
@@ -0,0 +1,194 @@
+import {
+ type Optional,
+ isSome,
+ isNothing,
+} from '@jet/environment/types/optional';
+import type { LocalizationWrapper } from '@jet-app/app-store/foundation/wrappers/localization';
+import type {
+ AppEventFormattedDate,
+ AppEventBadgeKind,
+} from '@jet-app/app-store/api/models';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import { formattedDatesWithKind } from '@jet-app/app-store/common/app-promotions/app-event';
+
+/**
+ * Partial type of {@linkcode AppEventFormattedDate} with just the properties
+ * that are actually used
+ */
+export type RequiredAppEventFormattedDate = Pick<
+ AppEventFormattedDate,
+ 'displayText' | 'displayFromDate' | 'countdownToDate' | 'countdownStringKey'
+>;
+
+/**
+ * Represents a client-side serialization of an {@linkcode RequiredAppEventFormattedDate}
+ *
+ * This is needed because our client-side code will receive the event object with `Date` properties
+ * serialized as ISO 8601-formatted strings, while the server-side code will receive the original
+ * `Date` values. We need to normalize this to make sure we have consistent logic in both environments
+ */
+type SerializedAppEventFormattedDate = Pick<
+ RequiredAppEventFormattedDate,
+ 'displayText' | 'countdownStringKey'
+> & {
+ readonly displayFromDate?: string;
+ readonly countdownToDate?: string;
+};
+
+function deserializeDate(value: Optional<Date | string>): Date | undefined {
+ if (isNothing(value)) {
+ return undefined;
+ }
+
+ return typeof value === 'string' ? new Date(value) : value;
+}
+
+/**
+ * Turn {@linkcode date} in either the client- or server-side format into the
+ * server-side format by parsing the ISO 8601 string values into `Date` instances
+ */
+function deserializeDateProperties(
+ date: SerializedAppEventFormattedDate | RequiredAppEventFormattedDate,
+): RequiredAppEventFormattedDate {
+ const { countdownToDate, displayFromDate, ...rest } = date;
+
+ return {
+ // Normalize properties that might have been serialized as `string` to `Date`
+ countdownToDate: deserializeDate(countdownToDate),
+ displayFromDate: deserializeDate(displayFromDate),
+
+ // Use all of the other properties with their existing values
+ ...rest,
+ };
+}
+
+/**
+ * A {@linkcode RequiredAppEventFormattedDate} with a definitely-defined `.displayFromDate` property
+ */
+type AppEventFormattedDateWithDisplayFromDate =
+ RequiredAppEventFormattedDate & {
+ readonly displayFromDate: Date;
+ };
+
+function hasDisplayRequirement(
+ date: RequiredAppEventFormattedDate,
+): date is AppEventFormattedDateWithDisplayFromDate {
+ return isSome(date.displayFromDate);
+}
+
+export function chooseAppEventDate(
+ dates: (SerializedAppEventFormattedDate | RequiredAppEventFormattedDate)[],
+): Optional<RequiredAppEventFormattedDate> {
+ const nowTime = Date.now();
+
+ // We might be passed `dates` in the expected format (server-side) or with their `Date`
+ // properties serialized as strings (client-side); we need to normalize them all to the
+ // same format
+ const normalizedDates = dates.map((date) =>
+ deserializeDateProperties(date),
+ );
+
+ // A `dates` member might not have a `.displayFromDate`; if that's the case, we will
+ // use that as a fallback if all other options are in the future
+ const fallback = normalizedDates.find(
+ (date) => !hasDisplayRequirement(date),
+ );
+
+ // Find all of the `dates` members with a `.displayFromDate` in the past
+ const optionsWithPastDisplayFromDates = normalizedDates
+ // Ensure all `date` objects have a display requirement
+ .filter((date) => hasDisplayRequirement(date))
+ // Filter out any `date` objects with a display requirement in the future
+ .filter((date) => {
+ const dateTime = date.displayFromDate.getTime();
+ const timeDifference = nowTime - dateTime;
+
+ return timeDifference > 0;
+ });
+
+ // If there are none, use the fallback
+ if (optionsWithPastDisplayFromDates.length === 0) {
+ return fallback;
+ }
+
+ // Otherwise, find the `date` object with the most recent `.displayFromDate`
+ return optionsWithPastDisplayFromDates.reduce((acc, next) => {
+ const accTime = acc.displayFromDate.getTime();
+ const nextTime = next.displayFromDate.getTime();
+
+ // Which time is closer to "now"?
+ const accTimeDiff = nowTime - accTime;
+ const nextTimeDiff = nowTime - nextTime;
+
+ return accTimeDiff > nextTimeDiff ? next : acc;
+ });
+}
+
+/**
+ * Partial type of {@linkcode LocalizationWrapper} with just the methods that
+ * are actually called
+ *
+ * This partial type simplifies testing by reducing the surface area of the function's
+ * dependencies
+ */
+type RequiredLocalization = Pick<LocalizationWrapper, 'string'>;
+
+function msToMinutes(ms: number): number {
+ return ms / (1_000 * 60);
+}
+
+export function renderDate(
+ localization: RequiredLocalization,
+ date: RequiredAppEventFormattedDate,
+): Optional<string> {
+ if (typeof date.countdownStringKey === 'string' && date.countdownToDate) {
+ const nowTime = Date.now();
+ const translationString = localization.string(date.countdownStringKey);
+
+ const countdownToDateTime = date.countdownToDate.getTime();
+ const diffTime = countdownToDateTime - nowTime;
+
+ const count = Math.floor(msToMinutes(diffTime));
+
+ return translationString.replace('@@count@@', count.toString());
+ }
+
+ if (typeof date.displayText === 'string') {
+ return date.displayText;
+ }
+
+ return undefined;
+}
+
+/**
+ * Helper function to compute formatted dates for app events.
+ * Handles date conversion and error handling.
+ *
+ * @param objectGraph - objectGraph from Jet
+ * @param badgeKind - The badge kind from the app event
+ * @param startDate - The start date (string or Date)
+ * @param endDate - The optional end date (string or Date)
+ * @returns Array of formatted dates or undefined if an error occurs
+ */
+export function computeAppEventFormattedDates(
+ objectGraph: AppStoreObjectGraph,
+ badgeKind: AppEventBadgeKind,
+ startDate: string | Date,
+ endDate?: string | Date | null,
+): RequiredAppEventFormattedDate[] | undefined {
+ // Use deserializeDate function to convert dates
+ const startDateObj = deserializeDate(startDate);
+ const endDateObj = deserializeDate(endDate);
+
+ // Validate that we have a valid start date
+ if (!startDateObj || isNaN(startDateObj.getTime())) {
+ return undefined;
+ }
+
+ return formattedDatesWithKind(
+ objectGraph,
+ badgeKind,
+ startDateObj,
+ endDateObj,
+ );
+}
diff --git a/src/jet/utils/error-metadata.ts b/src/jet/utils/error-metadata.ts
new file mode 100644
index 0000000..1322dfd
--- /dev/null
+++ b/src/jet/utils/error-metadata.ts
@@ -0,0 +1,16 @@
+import type { Opt } from '@jet/environment';
+import type { Intent } from '@jet/environment/dispatching';
+
+export function addRejectedIntent(error: Error, intent: Intent<unknown>) {
+ (error as any).rejectedIntent = intent;
+}
+
+export function getRejectedIntent(error: Error): Opt<Intent<unknown>> {
+ return hasRejectedIntent(error) ? error.rejectedIntent : null;
+}
+
+function hasRejectedIntent(
+ error: Error,
+): error is Error & { rejectedIntent: Intent<unknown> } {
+ return 'rejectedIntent' in error;
+}
diff --git a/src/jet/utils/handle-modal-presentation.ts b/src/jet/utils/handle-modal-presentation.ts
new file mode 100644
index 0000000..9040d4f
--- /dev/null
+++ b/src/jet/utils/handle-modal-presentation.ts
@@ -0,0 +1,29 @@
+import { getModalPageStore } from '~/stores/modalPage';
+import { isGenericPage, type Page } from '../models';
+import type { Logger } from '@amp/web-apps-logger/src';
+
+/**
+ * This function handles rendering flow action pages into a modal container.
+ * NOTE: Rendering a page in a modal will not update URL or history
+ *
+ * @param page page promise
+ * @param log app logger
+ */
+export const handleModalPresentation = (
+ page: { promise: Promise<Page> },
+ log: Logger<unknown[]>,
+ pageDetail?: string,
+) => {
+ page.promise
+ .then((page) => {
+ if (isGenericPage(page)) {
+ const modalStore = getModalPageStore();
+ modalStore.setPage({ page, pageDetail });
+ } else {
+ throw new Error('only generic page is rendered in modal');
+ }
+ })
+ .catch((e) => {
+ log.error('modal presentation failed', e);
+ });
+};
diff --git a/src/jet/utils/with-platform.ts b/src/jet/utils/with-platform.ts
new file mode 100644
index 0000000..6e11ab8
--- /dev/null
+++ b/src/jet/utils/with-platform.ts
@@ -0,0 +1,5 @@
+import type { WithPlatform } from 'node_modules/@jet-app/app-store/src/api/models/preview-platform';
+
+export function isWithPlatform(x: unknown): x is WithPlatform {
+ return typeof x === 'object' && x !== null && 'platform' in x;
+}
diff --git a/src/sf-symbols/AgeRating-AU-15.svg b/src/sf-symbols/AgeRating-AU-15.svg
new file mode 100644
index 0000000..6ff30c5
--- /dev/null
+++ b/src/sf-symbols/AgeRating-AU-15.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='28px'%20height='24px'%20viewBox='0%200%2028%2024'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eAge_Rating_Gen_15+%20Outline%3c/title%3e%3cg%20id='Age_Rating_Gen_15+-Outline'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='AUS-15+'%3e%3crect%20id='Mask'%20fill='%23DDDDDE'%20x='0'%20y='0'%20width='28'%20height='24'%20rx='6'%3e%3c/rect%3e%3cg%20id='label'%20transform='translate(5.2969,%206.4312)'%20fill='%2374747B'%20fill-rule='nonzero'%3e%3cpolygon%20id='Path'%20points='2.0268631%2010.5688477%203.60271454%2010.5688477%203.60271454%200%202.03029633%200%200%201.6462326%200%203.24531555%201.98337555%201.65550232%202.0268631%201.65550232'%3e%3c/polygon%3e%3cpath%20d='M8.1775086,10.7278061%20C8.77542425,10.7278061%209.29273535,10.5772972%209.72944189,10.2762794%20C10.1661484,9.97526169%2010.5028718,9.5514679%2010.7396119,9.00489807%20C10.976352,8.45832825%2011.094722,7.81330109%2011.094722,7.06981659%20L11.094722,7.05516815%20C11.094722,6.36295319%2010.9934037,5.75984955%2010.790767,5.24585724%20C10.5881302,4.73186493%2010.3027718,4.33383942%209.93469168,4.0517807%20C9.56661154,3.76972198%209.13598944,3.62869263%208.64282537,3.62869263%20C8.38579107,3.62869263%208.14973761,3.66607666%207.93466497,3.74084473%20C7.71959234,3.81561279%207.52782751,3.92505646%207.35937048,4.06917572%20C7.19091345,4.21329498%207.051677,4.3875885%206.94166113,4.59205627%20L6.89256598,4.59205627%20L7.08837439,1.33403778%20L10.6257432,1.33403778%20L10.6257432,0%20L5.82220007,0%20L5.50359655,6.21116638%20L6.87665869,6.21116638%20C6.90252234,6.08360291%206.93666388,5.96191406%206.97908331,5.84609985%20C7.02150274,5.73028564%207.06540991,5.62900543%207.11080481,5.54225922%20C7.23417212,5.3194046%207.38708426,5.1524353%207.56954123,5.04135132%20C7.7519982,4.93026733%207.962703,4.87472534%208.20165564,4.87472534%20C8.48371435,4.87472534%208.72545172,4.96448517%208.92686773,5.14400482%20C9.12828375,5.32352448%209.28289343,5.57857513%209.39069677,5.9091568%20C9.49850012,6.23973846%209.55240179,6.63223267%209.55240179,7.0866394%20L9.55240179,7.09568024%20C9.55240179,7.57419586%209.49878622,7.98578262%209.39155508,8.33044052%20C9.28432394,8.67509842%209.12906576,8.9402771%208.92578054,9.12597656%20C8.72249533,9.31167603%208.47738195,9.40452576%208.19044043,9.40452576%20C7.91929174,9.40452576%207.6870339,9.33286667%207.4936669,9.18954849%20C7.30029989,9.04623032%207.14790274,8.84937286%207.03647543,8.59897614%20C6.92504812,8.34857941%206.85583044,8.05850983%206.82882238,7.7287674%20L6.81589056,7.54657745%20L5.36100317,7.54657745%20L5.370044,7.78049469%20C5.40346075,8.38161469%205.53844381,8.90184402%205.77499319,9.34118271%20C6.01154257,9.78052139%206.33342672,10.12146%206.74064566,10.3639984%20C7.14786459,10.6065369%207.6268189,10.7278061%208.1775086,10.7278061%20Z'%20id='Path'%3e%3c/path%3e%3cpath%20d='M12.0326825,6.98318481%20L17.9403516,6.98318481%20L17.9403516,5.58963776%20L12.0326825,5.58963776%20L12.0326825,6.98318481%20Z%20M14.2917462,9.71820831%20L15.6796857,9.71820831%20L15.6796857,2.85621643%20L14.2917462,2.85621643%20L14.2917462,9.71820831%20Z'%20id='Shape'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/AgeRating-AU-18.svg b/src/sf-symbols/AgeRating-AU-18.svg
new file mode 100644
index 0000000..d316f67
--- /dev/null
+++ b/src/sf-symbols/AgeRating-AU-18.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__Dxnbu0ML__" \ No newline at end of file
diff --git a/src/sf-symbols/accessibility.svg b/src/sf-symbols/accessibility.svg
new file mode 100644
index 0000000..47f958a
--- /dev/null
+++ b/src/sf-symbols/accessibility.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.2832%2019.9316'%3e%3cg%3e%3crect%20height='19.9316'%20opacity='0'%20width='20.2832'%20x='0'%20y='0'/%3e%3cpath%20d='M9.96094%2019.9219C15.459%2019.9219%2019.9219%2015.459%2019.9219%209.96094C19.9219%204.46289%2015.459%200%209.96094%200C4.46289%200%200%204.46289%200%209.96094C0%2015.459%204.46289%2019.9219%209.96094%2019.9219ZM9.96094%2018.2617C5.37109%2018.2617%201.66016%2014.5508%201.66016%209.96094C1.66016%205.37109%205.37109%201.66016%209.96094%201.66016C14.5508%201.66016%2018.2617%205.37109%2018.2617%209.96094C18.2617%2014.5508%2014.5508%2018.2617%209.96094%2018.2617Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M9.95117%206.66016C8.56445%206.66016%206.5918%206.42578%205.64453%206.2793C5.55664%206.26953%205.47852%206.24023%205.38086%206.24023C5.13672%206.24023%204.88281%206.44531%204.88281%206.79688C4.88281%207.06055%205.04883%207.28516%205.29297%207.35352C5.61523%207.45117%207.83203%207.70508%208.11523%207.75391C8.38867%207.8125%208.55469%208.04688%208.56445%208.37891C8.57422%208.88672%208.56445%2010.5078%208.4375%2011.25C8.32031%2011.9824%207.4707%2015.5078%207.43164%2015.6641C7.32422%2016.0449%207.55859%2016.377%207.93945%2016.377C8.21289%2016.377%208.39844%2016.2305%208.50586%2015.8594C8.67188%2015.1465%209.28711%2012.7051%209.48242%2012.1191C9.59961%2011.6895%209.70703%2011.543%209.95117%2011.543C10.1855%2011.543%2010.293%2011.6895%2010.4297%2012.1191C10.5957%2012.7051%2011.2305%2015.1465%2011.3965%2015.8594C11.4941%2016.2305%2011.6895%2016.377%2011.9629%2016.377C12.3438%2016.377%2012.5684%2016.0449%2012.4609%2015.6641C12.4316%2015.5078%2011.5723%2011.9824%2011.4551%2011.25C11.3477%2010.5078%2011.3477%208.88672%2011.3477%208.37891C11.3477%208.04688%2011.5039%207.8125%2011.7871%207.75391C12.0605%207.70508%2014.2871%207.45117%2014.5996%207.35352C14.8535%207.28516%2015.0195%207.06055%2015.0195%206.79688C15.0195%206.44531%2014.7559%206.24023%2014.5117%206.24023C14.4238%206.24023%2014.3359%206.26953%2014.248%206.2793C13.3105%206.42578%2011.3477%206.66016%209.95117%206.66016ZM9.95117%206.01562C10.6543%206.01562%2011.2207%205.43945%2011.2207%204.74609C11.2207%204.04297%2010.6543%203.47656%209.95117%203.47656C9.24805%203.47656%208.68164%204.04297%208.68164%204.74609C8.68164%205.43945%209.24805%206.01562%209.95117%206.01562Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/app.3.stack.3d.fill.svg b/src/sf-symbols/app.3.stack.3d.fill.svg
new file mode 100644
index 0000000..5ff740e
--- /dev/null
+++ b/src/sf-symbols/app.3.stack.3d.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2024.8047%2023.4863'%3e%3cg%3e%3crect%20height='23.4863'%20opacity='0'%20width='24.8047'%20x='0'%20y='0'/%3e%3cpath%20d='M2.48047%2015.1367L8.51562%2017.8906C9.96094%2018.5547%2011.0938%2018.8672%2012.2168%2018.8672C13.3496%2018.8672%2014.4824%2018.5547%2015.9277%2017.8906L21.9629%2015.1367C22.0067%2015.1165%2022.0499%2015.0961%2022.0896%2015.0742C22.5477%2015.4686%2022.7148%2015.9089%2022.7148%2016.3672C22.7148%2017.041%2022.3633%2017.666%2021.2402%2018.1738L15.2148%2020.9277C13.9551%2021.5039%2013.0566%2021.748%2012.2168%2021.748C11.3867%2021.748%2010.4883%2021.5039%209.22852%2020.9277L3.20312%2018.1738C2.08008%2017.666%201.72852%2017.041%201.72852%2016.3672C1.72852%2015.9083%201.89155%2015.4676%202.35092%2015.0728Z'%20fill='currentColor'%20/%3e%3cpath%20d='M2.48047%2010.4785L8.51562%2013.2422C9.96094%2013.8965%2011.0938%2014.2188%2012.2168%2014.2188C13.3496%2014.2188%2014.4824%2013.8965%2015.9277%2013.2422L21.9629%2010.4785C21.9986%2010.4623%2022.0338%2010.4459%2022.0661%2010.4282C22.5419%2010.8246%2022.7148%2011.2721%2022.7148%2011.7383C22.7148%2012.4121%2022.3633%2013.0371%2021.2402%2013.5547L15.2148%2016.2988C13.9551%2016.875%2013.0566%2017.1191%2012.2168%2017.1191C11.3867%2017.1191%2010.4883%2016.875%209.22852%2016.2988L3.20312%2013.5547C2.08008%2013.0371%201.72852%2012.4121%201.72852%2011.7383C1.72852%2011.2716%201.89718%2010.8236%202.37464%2010.4269Z'%20fill='currentColor'%20/%3e%3cpath%20d='M12.2168%2012.4902C13.0566%2012.4902%2013.9551%2012.2461%2015.2148%2011.6699L21.2402%208.91602C22.3633%208.4082%2022.7148%207.7832%2022.7148%207.10938C22.7148%206.42578%2022.3535%205.80078%2021.2402%205.29297L15.1953%202.54883C13.9648%201.99219%2013.0664%201.72852%2012.2168%201.72852C11.377%201.72852%2010.4785%201.99219%209.23828%202.54883L3.20312%205.29297C2.08008%205.80078%201.72852%206.42578%201.72852%207.10938C1.72852%207.7832%202.08008%208.4082%203.20312%208.91602L9.22852%2011.6699C10.4883%2012.2461%2011.3867%2012.4902%2012.2168%2012.4902Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/app.3.stack.3d.svg b/src/sf-symbols/app.3.stack.3d.svg
new file mode 100644
index 0000000..7b18932
--- /dev/null
+++ b/src/sf-symbols/app.3.stack.3d.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20129.864%20120'%3e%3cpath%20d='M51.446,106.57l-31.584,-14.452c-5.479,-2.491%20-7.401,-5.573%20-7.401,-9.031c0,-3.509%201.922,-6.591%207.401,-9.092l5.46098,-2.48692l9.09647,4.14444l-11.44944,5.22248c-1.315,0.533%20-1.942,1.305%20-1.942,2.264c0,0.794%200.627,1.628%201.942,2.202l31.584,14.401c4.555,2.024%207.63,2.823%2010.403,2.823c2.773,0%205.797,-0.799%2010.351,-2.823l31.637,-14.401c1.263,-0.574%201.879,-1.408%201.879,-2.202c0,-0.959%20-0.616,-1.731%20-1.879,-2.264l-11.45166,-5.22168l9.09403,-4.1432l5.46563,2.48488c5.427,2.501%207.349,5.583%207.349,9.092c0,3.458%20-1.87,6.54%20-7.349,9.031l-31.585,14.452c-5.319,2.406%20-9.358,3.486%20-13.511,3.486c-4.204,0%20-8.243,-1.08%20-13.511,-3.486z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M51.446,83.41l-31.584,-14.39c-5.479,-2.553%20-7.401,-5.584%20-7.401,-9.093c0,-3.509%201.922,-6.592%207.401,-9.082l5.43712,-2.46884l9.08875,4.15589l-11.41786,5.19295c-1.315,0.574%20-1.942,1.346%20-1.942,2.254c0,0.856%200.627,1.68%201.942,2.254l31.584,14.349c4.555,2.075%207.63,2.875%2010.403,2.875c2.773,0%205.797,-0.8%2010.351,-2.875l31.637,-14.349c1.263,-0.574%201.879,-1.398%201.879,-2.254c0,-0.908%20-0.616,-1.68%20-1.879,-2.254l-11.42007,-5.19215l9.08633,-4.15466l5.44174,2.46681c5.427,2.49%207.349,5.573%207.349,9.082c0,3.509%20-1.87,6.54%20-7.349,9.093l-31.585,14.39c-5.319,2.406%20-9.358,3.486%20-13.511,3.486c-4.204,0%20-8.243,-1.08%20-13.511,-3.486z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M64.957,63.818c4.153,0%208.192,-1.132%2013.511,-3.486l31.585,-14.442c5.479,-2.501%207.349,-5.583%207.349,-9.092c0,-3.458%20-1.922,-6.592%20-7.349,-9.031l-31.7,-14.422c-5.194,-2.322%20-9.233,-3.454%20-13.396,-3.454c-4.214,0%20-8.254,1.132%20-13.448,3.454l-31.647,14.422c-5.479,2.439%20-7.401,5.573%20-7.401,9.031c0,3.509%201.922,6.591%207.401,9.092l31.584,14.442c5.268,2.354%209.307,3.486%2013.511,3.486zM64.957,56.327c-2.773,0%20-5.848,-0.799%20-10.403,-2.875l-31.584,-14.338c-1.315,-0.585%20-1.942,-1.409%20-1.942,-2.265c0,-0.897%200.627,-1.628%201.942,-2.202l31.71,-14.474c4.429,-1.981%207.515,-2.739%2010.277,-2.739c2.763,0%205.797,0.758%2010.267,2.739l31.721,14.474c1.263,0.574%201.879,1.305%201.879,2.202c0,0.856%20-0.616,1.68%20-1.879,2.265l-31.637,14.338c-4.554,2.076%20-7.578,2.875%20-10.351,2.875z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/appearance.darkmode.svg b/src/sf-symbols/appearance.darkmode.svg
new file mode 100644
index 0000000..738defc
--- /dev/null
+++ b/src/sf-symbols/appearance.darkmode.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.2832%2019.9316'%3e%3cg%3e%3crect%20height='19.9316'%20opacity='0'%20width='20.2832'%20x='0'%20y='0'/%3e%3cpath%20d='M19.9219%209.96094C19.9219%2015.459%2015.459%2019.9219%209.96094%2019.9219C4.46289%2019.9219%200%2015.459%200%209.96094C0%204.46289%204.46289%200%209.96094%200C15.459%200%2019.9219%204.46289%2019.9219%209.96094ZM0.546875%209.96094C0.546875%2015.166%204.75586%2019.375%209.96094%2019.375L9.96094%2018.2617C14.5508%2018.2617%2018.2617%2014.5508%2018.2617%209.96094C18.2617%205.37109%2014.5508%201.66016%209.96094%201.66016L9.96094%200.537109C4.75586%200.537109%200.546875%204.75586%200.546875%209.96094Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M9.96094%206.07422C7.82227%206.07422%206.07422%207.8125%206.07422%209.96094C6.07422%2012.0996%207.82227%2013.8477%209.96094%2013.8477L9.96094%2019.375C4.75586%2019.375%200.546875%2015.166%200.546875%209.96094C0.546875%204.75586%204.75586%200.537109%209.96094%200.537109Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M9.96094%2013.8477C12.0996%2013.8477%2013.8477%2012.0996%2013.8477%209.96094C13.8477%207.8125%2012.0996%206.07422%209.96094%206.07422Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/applewatch.svg b/src/sf-symbols/applewatch.svg
new file mode 100644
index 0000000..1f3b670
--- /dev/null
+++ b/src/sf-symbols/applewatch.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2070.762%20104.469'%3e%3cpath%20d='M0%2071.965C0%2079.535%202.937%2085.087%208.492%2088.13%2011.19%2089.572%2012.724%2091.386%2013.752%2094.613L15.27%2099.873C16.204%20102.996%2018.372%20104.47%2021.66%20104.47H44.214C47.617%20104.469%2049.63%20103.047%2050.605%2099.874L52.185%2094.613C53.15%2091.386%2054.737%2089.573%2057.383%2088.13%2062.938%2085.087%2065.875%2079.535%2065.875%2071.965V32.503C65.875%2024.934%2062.938%2019.381%2057.383%2016.34%2054.737%2014.896%2053.15%2013.083%2052.185%209.856L50.605%204.595C49.733%201.525%2047.565%200%2044.215%200H21.66C18.372%200%2016.204%201.473%2015.27%204.595L13.752%209.855C12.776%2013.032%2011.242%2014.949%208.492%2016.34%202.989%2019.226%200%2024.83%200%2032.503ZM64.824%2048.795H66.591C69.09%2048.794%2070.762%2047.05%2070.762%2044.321V37.695C70.762%2034.915%2069.092%2033.171%2066.591%2033.171H64.824ZM7.129%2070.92V33.56C7.129%2026.263%2011.39%2021.887%2018.48%2021.887H47.405C54.546%2021.887%2058.745%2026.263%2058.745%2033.56V70.92C58.746%2078.205%2054.547%2082.58%2047.406%2082.58H18.48C11.39%2082.581%207.13%2078.206%207.13%2070.92Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/appstore-ribbon-bar-fallback-icon.svg b/src/sf-symbols/appstore-ribbon-bar-fallback-icon.svg
new file mode 100644
index 0000000..12cde33
--- /dev/null
+++ b/src/sf-symbols/appstore-ribbon-bar-fallback-icon.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20123.272%20120'%3e%3cpath%20d='M63.709,71.877h-15.616l27.337,-47.427c1.786,-3.083%201.163,-7.278%20-2.159,-9.126c-3.395,-1.983%20-7.257,-0.426%20-8.95,2.647l-2.71,4.444l-2.658,-4.444c-1.744,-3.073%20-5.554,-4.682%20-8.949,-2.647c-3.323,1.91%20-3.883,6.043%20-2.098,9.126l6.417,10.86l-21.149,36.567h-16.415c-3.52,0%20-6.79,2.596%20-6.79,6.479c0,3.82%203.27,6.406%206.79,6.406h56.387c1.412,-5.731%20-1.609,-12.885%20-9.437,-12.885zM106.515,71.877h-16.352l-17.878,-30.898c-5.368,3.883%20-6.105,15.055%20-2.16,21.897l22.571,39.1c1.786,3.083%205.493,4.63%208.95,2.71c3.333,-1.911%204.008,-6.043%202.16,-9.189l-6.24,-10.735h8.949c3.52,0%206.791,-2.586%206.791,-6.406c0,-3.883%20-3.271,-6.479%20-6.791,-6.479zM23.061,89.33l-3.53,6.167c-1.786,3.146%20-1.236,7.216%202.046,9.189c3.457,1.972%207.215,0.311%209.001,-2.71l5.233,-8.877c-1.236,-2.284%20-6.219,-5.503%20-12.75,-3.769z'%20fill='%238e8e93'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/appstore.svg b/src/sf-symbols/appstore.svg
new file mode 100644
index 0000000..50366f4
--- /dev/null
+++ b/src/sf-symbols/appstore.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20123.272%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='123.272'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M63.709%2057.458L48.093%2057.458L75.43%2010.083C77.216%206.999%2076.593%202.805%2073.271%200.946C69.876-1.026%2065.993%200.521%2064.321%203.604L61.611%208.048L58.953%203.604C57.282%200.521%2053.399-1.089%2050.004%200.946C46.681%202.867%2046.121%206.999%2047.906%2010.083L54.323%2020.943L33.174%2057.458L16.759%2057.458C13.239%2057.458%209.969%2060.054%209.969%2063.937C9.969%2067.757%2013.239%2070.343%2016.759%2070.343L73.146%2070.343C74.558%2064.612%2071.537%2057.458%2063.709%2057.458ZM106.515%2057.458L90.163%2057.458L72.285%2026.601C66.917%2030.495%2066.169%2041.677%2070.125%2048.508L92.696%2087.557C94.471%2090.64%2098.189%2092.187%20101.646%2090.267C104.979%2088.356%20105.643%2084.224%20103.806%2081.078L97.566%2070.343L106.515%2070.343C110.035%2070.343%20113.306%2067.757%20113.306%2063.937C113.306%2060.054%20110.035%2057.458%20106.515%2057.458ZM23.061%2074.911L19.531%2081.078C17.745%2084.224%2018.295%2088.294%2021.577%2090.267C25.034%2092.239%2028.803%2090.589%2030.578%2087.557L35.811%2078.68C34.575%2076.396%2029.592%2073.177%2023.061%2074.911Z'%20fill='currentColor'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/arkit.svg b/src/sf-symbols/arkit.svg
new file mode 100644
index 0000000..b87210b
--- /dev/null
+++ b/src/sf-symbols/arkit.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20123.97%20120'%3e%3cpath%20d='M57.796,112.739c2.894,1.65%205.499,1.65%208.393,0l13.512,-7.7c2.404,-1.357%202.953,-3.719%201.855,-5.833c-1.098,-2.02%20-3.74,-2.559%20-6.03,-1.253l-9.446,5.423v-9.939c0,-2.622%20-1.741,-4.591%20-4.093,-4.591c-2.341,0%20-4.092,1.969%20-4.092,4.591v9.939l-9.447,-5.423c-2.29,-1.306%20-4.931,-0.767%20-6.03,1.253c-1.087,2.114%20-0.497,4.476%201.866,5.833zM25.785,94.571c2.341,1.305%204.962,0.819%206.05,-1.316c1.099,-2.061%200.259,-4.558%20-1.875,-5.771l-5.264,-3l7.53,-4.274c2.342,-1.346%203.233,-3.968%201.928,-5.926c-1.295,-1.979%20-3.71,-2.61%20-5.896,-1.367l-7.901,4.469v-6.382c0,-2.632%20-1.659,-4.59%20-3.948,-4.59c-2.3,0%20-3.948,1.958%20-3.948,4.59v7.64c0,7.258%202.393,9.736%206.99,12.329zM16.409,55.89c2.352,0%203.948,-1.958%203.948,-4.59v-9.26l9.04,5.184c2.134,1.202%204.59,0.612%205.947,-1.357c1.295,-1.969%200.362,-4.579%20-2.093,-5.937l-8.463,-4.77l8.252,-4.682c2.487,-1.43%203.264,-4.093%202.031,-6.133c-1.295,-1.948%20-3.781,-2.363%20-5.792,-1.192l-10.702,6.057c-3.949,2.332%20-6.116,5.4%20-6.116,10.789v11.301c0,2.632%201.71,4.59%203.948,4.59zM61.987,29.893c2.352,0%204.093,-1.958%204.093,-4.58v-9.027l6.868,3.902c2.208,1.264%204.911,0.756%205.989,-1.243c1.139,-2.041%200.508,-4.496%20-1.814,-5.823l-10.618,-6.04c-3.253,-1.819%20-5.783,-1.819%20-9.035,0l-10.608,6.04c-2.332,1.327%20-2.953,3.782%20-1.824,5.823c1.088,1.999%203.833,2.507%205.988,1.243l6.869,-3.964v9.089c0,2.622%201.751,4.58%204.092,4.58zM107.576,55.89c2.238,0%203.937,-1.958%203.937,-4.59v-11.301c0,-5.854%20-1.535,-8.146%20-6.105,-10.789l-10.713,-6.057c-2.011,-1.171%20-4.445,-0.756%20-5.729,1.192c-1.295,2.04%20-0.508,4.703%201.968,6.133l8.252,4.682l-8.462,4.77c-2.445,1.358%20-3.388,3.968%20-2.083,5.937c1.347,1.969%203.802,2.559%205.947,1.357l9.04,-5.184v9.26c0,2.632%201.637,4.59%203.948,4.59zM98.19,94.571l6.344,-3.598c4.586,-2.593%206.979,-5.071%206.979,-12.329v-7.64c0,-2.632%20-1.637,-4.59%20-3.937,-4.59c-2.248,0%20-3.948,1.958%20-3.948,4.59v6.393l-7.912,-4.48c-2.186,-1.243%20-4.59,-0.612%20-5.895,1.367c-1.295,1.958%20-0.414,4.58%201.938,5.926l7.498,4.274l-5.232,3c-2.093,1.213%20-2.984,3.71%20-1.886,5.771c1.098,2.135%203.761,2.621%206.051,1.316zM61.987,78.789c2.352,0%204.093,-1.906%204.093,-4.58v-10.77l10.009,-5.747c2.352,-1.346%203.264,-3.968%201.969,-5.936c-1.306,-1.958%20-3.688,-2.601%20-5.937,-1.347l-10.134,5.747l-10.134,-5.747c-2.186,-1.254%20-4.631,-0.611%20-5.926,1.347c-1.305,1.968%20-0.383,4.59%201.958,5.936l10.01,5.747v10.77c0,2.674%201.751,4.58%204.092,4.58z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/bag.fill.svg b/src/sf-symbols/bag.fill.svg
new file mode 100644
index 0000000..2d95528
--- /dev/null
+++ b/src/sf-symbols/bag.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20111.559%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='111.559'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M27.779%2093.877L85.221%2093.877C94.014%2093.877%2099.1%2088.77%2099.1%2078.715L99.1%2028.2C99.1%2018.145%2093.963%2013.038%2083.782%2013.038L27.779%2013.038C17.599%2013.038%2012.461%2018.124%2012.461%2028.2L12.461%2078.715C12.461%2088.791%2017.599%2093.877%2027.779%2093.877ZM36.512%2014.948L44.417%2015C44.417%208.019%2048.981%203.082%2055.755%203.082C62.57%203.082%2067.144%208.019%2067.144%2015L74.998%2014.948C74.998%204.174%2066.725-4.357%2055.755-4.357C44.836-4.357%2036.512%204.174%2036.512%2014.948Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.10.official.svg b/src/sf-symbols/br.10.official.svg
new file mode 100644
index 0000000..54d4703
--- /dev/null
+++ b/src/sf-symbols/br.10.official.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e10%3c/title%3e%3cdesc%3e10%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/10'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%230095D9'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M4.09273752,5.20290217%20C4.27137064,5.11579879%204.49873561,4.94710839%204.77483242,4.69683099%20C4.99292401,4.471661%205.15432914,4.22841554%205.2590478,3.96709461%20L6.62259677,3.96709461%20L6.62259677,12.125072%20L5.0648255,12.125072%20L5.0648255,6.03001358%20C4.89350954,6.18226182%204.73764603,6.30913186%204.59723499,6.41062371%20C4.45682394,6.51211556%204.28865812,6.59320487%204.09273752,6.65389166%20L4.09273752,5.20290217%20Z%20M11.9072625,6.71055955%20L11.9072625,9.35519001%20C11.9072625,9.95594557%2011.8790107,10.3934523%2011.8225071,10.6677103%20C11.7660035,10.9419683%2011.6628615,11.2587291%2011.4848753,11.4970723%20C11.306889,11.7354155%2011.1073279,11.8814592%2010.855887,11.9859384%20C10.6044461,12.0904176%2010.1987357,12.1075647%209.88796601,12.1075647%20C9.4783151,12.1075647%209.20835844,12.0953151%208.93714128,11.9859384%20C8.66592413,11.8765617%208.43834952,11.7288856%208.27731433,11.4970723%20C8.11627915,11.265259%208.03818025,10.9558444%207.97037597,10.6995438%20C7.90257168,10.4432432%207.86866953,10.0359375%207.86866953,9.4776266%20L7.86866953,6.71055955%20C7.86866953,5.98246992%207.92305422,5.43721895%208.0318236,5.07480663%20C8.14059298,4.71239431%208.25994623,4.46344396%208.58038754,4.21526928%20C8.90082885,3.96709461%209.39567862,3.874928%209.85618275,3.874928%20C10.4565529,3.874928%2010.8897883,4.00502057%2011.2698551,4.2724843%20C11.6499218,4.53994803%2011.776598,5.12378127%2011.8288638,5.39803924%20C11.8811296,5.67229722%2011.9072625,6.10980398%2011.9072625,6.71055955%20Z%20M10.2474521,5.90136398%20C10.2474521,5.45009119%2010.226792,5.16760842%2010.1854717,5.05391566%20C10.1441515,4.9402229%2010.0474621,4.88337652%209.89540365,4.88337652%20C9.74665079,4.88337652%209.64830861,4.94372114%209.60037714,5.06441037%20C9.55244566,5.18509961%209.52847992,5.46408415%209.52847992,5.90136398%20L9.52847992,10.0467768%20C9.52847992,10.5400285%209.55079285,10.8373788%209.59541871,10.9388277%20C9.64004457,11.0402766%209.73673393,11.0910011%209.88548679,11.0910011%20C10.0342397,11.0910011%2010.1317554,11.031531%2010.1780341,10.9125909%20C10.2243128,10.7936508%2010.2474521,10.5260355%2010.2474521,10.1097451%20L10.2474521,5.90136398%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.10.svg b/src/sf-symbols/br.10.svg
new file mode 100644
index 0000000..9f5a416
--- /dev/null
+++ b/src/sf-symbols/br.10.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA10%3c/title%3e%3cdesc%3eA10%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A10'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%230283CA'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M6.77976115,5.19366546%20C6.95715179,5.10716793%207.18293532,4.93965086%207.45711175,4.69111425%20C7.67368641,4.46751043%207.83396889,4.22595686%207.93795917,3.96645354%20L9.292024,3.96645354%20L9.292024,12.0676882%20L7.74508779,12.0676882%20L7.74508779,6.01502391%20C7.57496341,6.16621319%207.42018401,6.29220079%207.28074959,6.39298671%20C7.14131518,6.49377263%206.97431903,6.57429793%206.77976115,6.63456261%20L6.77976115,5.19366546%20Z%20M14.5399323,6.69083634%20L14.5399323,9.31707211%20C14.5399323,9.91364912%2014.511877,10.3481128%2014.4557664,10.6204632%20C14.3996559,10.8928136%2014.2972312,11.2073711%2014.120483,11.4440566%20C13.9437347,11.680742%2013.7455616,11.8257699%2013.4958696,11.9295224%20C13.2461776,12.0332749%2012.8432891,12.0503027%2012.534681,12.0503027%20C12.1278794,12.0503027%2011.8598004,12.0381383%2011.5904697,11.9295224%20C11.321139,11.8209065%2011.0951473,11.6742575%2010.9352322,11.4440566%20C10.7753171,11.2138557%2010.6977614,10.9065932%2010.6304287,10.6520753%20C10.5630961,10.3975574%2010.5294297,9.99308465%2010.5294297,9.4386571%20L10.5294297,6.69083634%20C10.5294297,5.96781094%2010.5834361,5.42635246%2010.691449,5.06646089%20C10.7994618,4.70656932%2010.9179849,4.45935054%2011.2361974,4.21290204%20C11.5544099,3.96645354%2012.0458177,3.874928%2012.5031188,3.874928%20C13.0993131,3.874928%2013.5295352,4.00411571%2013.9069583,4.2697191%20C14.2843815,4.53532249%2014.4101766,5.11509488%2014.4620789,5.38744526%20C14.5139811,5.65979564%2014.5399323,6.09425933%2014.5399323,6.69083634%20Z%20M12.8916667,5.88726913%20C12.8916667,5.43913516%2012.8711503,5.15861719%2012.8301174,5.04571522%20C12.7890846,4.93281325%2012.6930678,4.87636227%2012.5420669,4.87636227%20C12.3943487,4.87636227%2012.2966905,4.93628716%2012.2490924,5.05613694%20C12.2014944,5.17598673%2012.1776953,5.45303079%2012.1776953,5.88726913%20L12.1776953,10.0038486%20C12.1776953,10.4936695%2012.199853,10.7889516%2012.2441685,10.8896948%20C12.288484,10.9904381%2012.3845008,11.0408098%2012.532219,11.0408098%20C12.6799372,11.0408098%2012.7767747,10.9817534%2012.8227315,10.8636405%20C12.8686883,10.7455277%2012.8916667,10.4797738%2012.8916667,10.0663789%20L12.8916667,5.88726913%20Z%20M5.50180611,4.03412044%20L6.75931556,12.0676882%20L4.57337821,12.0676882%20L4.46041707,10.6171204%20L3.70141707,10.6171204%20L3.584216,12.0676882%20L1.37941707,12.0676882%20L2.46560111,4.03412044%20L5.50180611,4.03412044%20Z%20M4.09305594,5.84858991%20L3.68673127,9.1892807%20L3.68941707,9.19012044%20L4.35041707,9.19012044%20L4.09305594,5.84858991%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.12.official.svg b/src/sf-symbols/br.12.official.svg
new file mode 100644
index 0000000..e8e0b54
--- /dev/null
+++ b/src/sf-symbols/br.12.official.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e12%3c/title%3e%3cdesc%3e12%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/12'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23FFCC03'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M4.07768806,5.24555806%20C4.25359702,5.15978302%204.47749466,4.99366516%204.74938098,4.7472045%20C4.96414667,4.52546837%205.12309036,4.28593241%205.22621205,4.02859664%20L6.56896682,4.02859664%20L6.56896682,12.0621644%20L5.03495165,12.0621644%20L5.03495165,6.06005599%20C4.86624826,6.20998243%204.71276168,6.3349177%204.57449192,6.43486179%20C4.43622215,6.53480588%204.27062086,6.61465858%204.07768806,6.67441988%20L4.07768806,5.24555806%20Z%20M11.9223119,10.8174254%20L11.9223119,11.9944762%20L7.649851,11.9944762%20L7.649851,10.6468199%20C8.93565829,8.54395537%209.56601712,7.87585348%209.80853014,7.376179%20C10.0510432,6.87650453%2010.186746,6.35354574%2010.186746,6.07359783%20C10.186746,5.85875408%2010.1622996,5.34143621%2010.1183271,5.194608%20C10.0743545,5.04777979%209.91577513,4.92448122%209.76603555,4.92448122%20C9.61629596,4.92448122%209.52321367,5.03068994%209.45573454,5.194608%20C9.41074844,5.30388671%209.38730837,5.64585019%209.3854143,6.22049844%20L7.78185994,6.22049844%20C7.766021,5.63561132%207.82149491,5.18824269%207.94828164,4.87839256%20C8.13846175,4.41361735%208.39854891,4.25521839%208.68500725,4.12826526%20C8.97146558,4.00131214%209.31489006,3.93783557%209.71528069,3.93783557%20C10.4997859,3.93783557%2010.9614643,4.07010925%2011.3634826,4.45910665%20C11.7655008,4.84810404%2011.7587377,5.40267932%2011.7587377,5.99838245%20C11.7587377,6.45085641%2011.7711856,7.0028398%2011.5449486,7.50739709%20C11.3187116,8.01195438%2010.4163731,9.18005565%209.30960228,10.8174254%20L11.9223119,10.8174254%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.12.svg b/src/sf-symbols/br.12.svg
new file mode 100644
index 0000000..51a2a6a
--- /dev/null
+++ b/src/sf-symbols/br.12.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA12%3c/title%3e%3cdesc%3eA12%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A12'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23FECB17'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M5.52652131,4.03412044%20L6.78403075,12.0676882%20L4.5980934,12.0676882%20L4.48513226,10.6171204%20L3.72613226,10.6171204%20L3.60893119,12.0676882%20L1.40413226,12.0676882%20L2.4903163,4.03412044%20L5.52652131,4.03412044%20Z%20M9.27530951,4.03412044%20L9.27530951,12.0676882%20L7.74129434,12.0676882%20L7.74129434,6.06557979%20C7.57259095,6.21550623%207.41910437,6.3404415%207.2808346,6.44038559%20C7.14256483,6.54032968%206.97696355,6.62018238%206.78403075,6.67994368%20L6.78403075,5.25108186%20C6.95993971,5.16530682%207.18383734,4.99918896%207.45572366,4.7527283%20C7.67048935,4.53099217%207.82943304,4.29145622%207.93255473,4.03412044%20L9.27530951,4.03412044%20Z%20M4.11777113,5.84858991%20L3.71144646,9.1892807%20L3.71413226,9.19012044%20L4.37513226,9.19012044%20L4.11777113,5.84858991%20Z%20M14.6286546,10.8229492%20L14.6286546,12%20L10.3561937,12%20L10.3561937,10.6523438%20C11.642001,8.54947917%2012.2723598,7.88137728%2012.5148728,7.38170281%20C12.7573858,6.88202833%2012.8930887,6.35906955%2012.8930887,6.07912163%20C12.8930887,5.86427788%2012.8686423,5.34696002%2012.8246698,5.2001318%20C12.7806972,5.05330359%2012.6221178,4.93000502%2012.4723782,4.93000502%20C12.3226386,4.93000502%2012.2295564,5.03621375%2012.1620772,5.2001318%20C12.1170911,5.30941051%2012.0936511,5.65137399%2012.091757,6.22602224%20L10.4882026,6.22602224%20C10.4723637,5.64113512%2010.5278376,5.19376649%2010.6546243,4.88391636%20C10.8448044,4.41914116%2011.1048916,4.26074219%2011.3913499,4.13378906%20C11.6778083,4.00683594%2012.0212327,3.94335938%2012.4216234,3.94335938%20C13.2061286,3.94335938%2013.667807,4.07563305%2014.0698253,4.46463045%20C14.4718435,4.85362785%2014.4650804,5.40820313%2014.4650804,6.00390625%20C14.4650804,6.45638021%2014.4775283,7.0083636%2014.2512913,7.51292089%20C14.0250543,8.01747818%2013.1227158,9.18557945%2012.015945,10.8229492%20L14.6286546,10.8229492%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.14.official.svg b/src/sf-symbols/br.14.official.svg
new file mode 100644
index 0000000..e2fdad7
--- /dev/null
+++ b/src/sf-symbols/br.14.official.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e14%3c/title%3e%3cdesc%3e14%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/14'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23F5821F'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M3.90988528,5.20017753%20C4.08579424,5.11440248%204.30969187,4.94828463%204.58157819,4.70182396%20C4.79634388,4.48008783%204.95528757,4.24055188%205.05840926,3.98321611%20L6.40116403,3.98321611%20L6.40116403,12.0167839%20L4.86714887,12.0167839%20L4.86714887,6.01467545%20C4.69844548,6.16460189%204.5449589,6.28953716%204.40668913,6.38948125%20C4.26841936,6.48942535%204.10281808,6.56927804%203.90988528,6.62903935%20L3.90988528,5.20017753%20Z%20M11.1129635,3.98321611%20L11.1129635,9.00245547%20L12.0901147,9.00245547%20L12.0901147,10.2018294%20L11.1129635,10.2018294%20L11.1129635,11.9547345%20L9.59811451,11.9547345%20L9.59811451,10.2018294%20L7.41855117,10.2018294%20L7.41855117,8.91521973%20L8.8770918,3.98321611%20L11.1129635,3.98321611%20Z%20M9.64833266,8.97521103%20L9.69733561,5.12030612%20L8.62646361,8.97521103%20L9.64833266,8.97521103%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.14.svg b/src/sf-symbols/br.14.svg
new file mode 100644
index 0000000..2d5749c
--- /dev/null
+++ b/src/sf-symbols/br.14.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA14%3c/title%3e%3cdesc%3eA14%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A14'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23ED6A13'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M5.36080741,4.03412044%20L6.61831686,12.0676882%20L4.43237951,12.0676882%20L4.31941837,10.6171204%20L3.56041837,10.6171204%20L3.4432173,12.0676882%20L1.23841837,12.0676882%20L2.32460241,4.03412044%20L5.36080741,4.03412044%20Z%20M9.10959561,4.03412044%20L9.10959561,12.0676882%20L7.57558045,12.0676882%20L7.57558045,6.06557979%20C7.40687706,6.21550623%207.25339048,6.3404415%207.11512071,6.44038559%20C6.97685094,6.54032968%206.81124966,6.62018238%206.61831686,6.67994368%20L6.61831686,5.25108186%20C6.79422582,5.16530682%207.01812345,4.99918896%207.29000977,4.7527283%20C7.50477546,4.53099217%207.66371915,4.29145622%207.76684084,4.03412044%20L9.10959561,4.03412044%20Z%20M3.95205724,5.84858991%20L3.54573257,9.1892807%20L3.54841837,9.19012044%20L4.20941837,9.19012044%20L3.95205724,5.84858991%20Z%20M13.8213951,4.03412044%20L13.8213951,9.05335981%20L14.7985463,9.05335981%20L14.7985463,10.2527337%20L13.8213951,10.2527337%20L13.8213951,12.0056388%20L12.3065461,12.0056388%20L12.3065461,10.2527337%20L10.1269828,10.2527337%20L10.1269828,8.96612407%20L11.5855234,4.03412044%20L13.8213951,4.03412044%20Z%20M12.3567642,9.02611537%20L12.4057672,5.17121046%20L11.3348952,9.02611537%20L12.3567642,9.02611537%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.16.official.svg b/src/sf-symbols/br.16.official.svg
new file mode 100644
index 0000000..0dbb2f1
--- /dev/null
+++ b/src/sf-symbols/br.16.official.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e16%3c/title%3e%3cdesc%3e16%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/16'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23EB1A25'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M11.8519926,5.86097341%20L10.1976942,5.86097341%20C10.1976942,5.49636184%2010.1942819,5.34649709%2010.1874572,5.25131088%20C10.1806325,5.15612467%2010.1478742,4.97949788%2010.0891821,4.91496486%20C10.03049,4.85043183%209.9506414,4.81816532%209.84963638,4.81816532%20C9.76501056,4.81816532%209.695399,4.84881851%209.64080169,4.91012488%20C9.58620439,4.97143125%209.55481093,5.14725138%209.54662134,5.24405092%20C9.53843174,5.34085045%209.53433695,5.70878031%209.53433695,6.00240557%20L9.53433695,7.33405522%20C9.59337476,7.22917217%209.73493322,7.10372254%209.89872513,6.99885637%20C10.062517,6.89399021%2010.2750591,6.83785659%2010.5152872,6.83785659%20C10.8183023,6.83785659%2011.1147325,6.86171678%2011.3467711,7.05531585%20C11.5788096,7.24891492%2011.677464,7.42821654%2011.7402509,7.69925523%20C11.8030378,7.97029392%2011.8519926,8.24654141%2011.8519926,8.70795252%20L11.8519926,9.33230951%20C11.8519926,9.8808402%2011.8342484,10.2890116%2011.7987602,10.5568236%20C11.7632719,10.8246356%2011.6684091,11.0722811%2011.5141717,11.29976%20C11.3599343,11.5272389%2011.1483698,11.7038981%2010.8794781,11.8297375%20C10.6105863,11.9555769%2010.2973343,12.0184966%209.93972194,12.0184966%20C9.4947539,12.0184966%208.99713197,11.8967332%208.71322598,11.7515339%20C8.42932,11.6063346%208.34286742,11.487862%208.18863004,11.21521%20C8.03439265,10.942558%207.94487289,10.6149033%207.91893917,10.3148248%20C7.89300545,10.0147462%207.88003859,9.43233569%207.88003859,8.5675932%20L7.88003859,7.48343842%20C7.88003859,6.55093625%207.89164052,5.92738593%207.91484437,5.61278744%20C7.93804823,5.29818896%208.08417058,4.81307074%208.25069236,4.5323521%20C8.41721414,4.25163345%208.59321589,4.15297913%208.88121668,4.01261981%20C9.16921747,3.87226048%209.48929417,3.85220028%209.86601558,3.85220028%20C10.3300927,3.85220028%2010.7907565,3.87444143%2011.0883118,4.05190724%20C11.3858671,4.22937306%2011.5566647,4.39714664%2011.6849684,4.71981175%20C11.813272,5.04247686%2011.8519926,5.37052244%2011.8519926,5.86097341%20Z%20M10.2551658,8.71865495%20C10.2551658,8.4343801%2010.2247384,8.23935434%2010.1638837,8.13357765%20C10.1030289,8.02780096%2010.0037395,7.97491262%209.86601558,7.97491262%20C9.73149451,7.97491262%209.63300587,8.02532182%209.57054965,8.12614023%20C9.50809344,8.22695863%209.47686534,8.42446354%209.47686534,8.71865495%20L9.47686534,10.1714316%20C9.47686534,10.5251224%209.506492,10.7565089%209.56574533,10.8655912%20C9.62499866,10.9746734%209.7234873,11.0292145%209.86121125,11.0292145%20C9.9444862,11.0292145%2010.030964,10.9870691%2010.1206447,10.9027783%20C10.2103255,10.8184875%2010.2551658,10.5912329%2010.2551658,10.2210145%20L10.2551658,8.71865495%20Z%20M4.10542164,5.20542637%20C4.27850296,5.12098829%204.49880156,4.95745971%204.76631745,4.71484062%20C4.9776309,4.49656068%205.13401965,4.26075836%205.23548372,4.00743367%20L6.55665444,4.00743367%20L6.55665444,11.9157826%20L5.04729773,11.9157826%20L5.04729773,6.00722874%20C4.88130616,6.15481829%204.7302868,6.27780619%204.59423964,6.37619246%20C4.45819248,6.47457873%204.29525315,6.55318677%204.10542164,6.61201658%20L4.10542164,5.20542637%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%20fill-rule='nonzero'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.16.svg b/src/sf-symbols/br.16.svg
new file mode 100644
index 0000000..4b74086
--- /dev/null
+++ b/src/sf-symbols/br.16.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA16%3c/title%3e%3cdesc%3eA16%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A16'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23DC061D'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M14.6274878,5.89277984%20L12.9461631,5.89277984%20C12.9461631,5.52239509%2012.942695,5.37015742%2012.9357588,5.27346406%20C12.9288227,5.1767707%2012.8955291,4.99734724%2012.8358782,4.93179241%20C12.7762272,4.86623759%2012.6950741,4.83346018%2012.592419,4.83346018%20C12.5064106,4.83346018%2012.4356618,4.86459872%2012.3801726,4.9268758%20C12.3246833,4.98915288%2012.292777,5.16775691%2012.2844536,5.26608914%20C12.2761302,5.36442138%2012.2719685,5.73817695%2012.2719685,6.0364514%20L12.2719685,7.38918607%20C12.3319708,7.28264233%2012.4758419,7.15520635%2012.6423097,7.04867977%20C12.8087775,6.94215318%2013.0247919,6.88513075%2013.2689446,6.88513075%20C13.57691,6.88513075%2013.8781831,6.90936874%2014.1140125,7.10603321%20C14.3498419,7.30269768%2014.4501079,7.48483831%2014.5139206,7.76016857%20C14.5777332,8.03549882%2014.6274878,8.31612035%2014.6274878,8.78483733%20L14.6274878,9.41908023%20C14.6274878,9.97629623%2014.6094538,10.3909305%2014.5733858,10.662983%20C14.5373178,10.9350355%2014.4409052,11.1866021%2014.284148,11.4176829%20C14.1273908,11.6487636%2013.9123699,11.82822%2013.6390853,11.9560519%20C13.3658006,12.0838838%2013.047431,12.1477997%2012.6839763,12.1477997%20C12.2317388,12.1477997%2011.7259872,12.0241084%2011.437443,11.8766101%20C11.1488988,11.7291117%2011.0610339,11.6087633%2010.9042767,11.3317941%20C10.7475195,11.054825%2010.6565373,10.7219823%2010.6301799,10.4171524%20C10.6038225,10.1123225%2010.5906438,9.52069021%2010.5906438,8.64225559%20L10.5906438,7.54093457%20C10.5906438,6.59366739%2010.6024352,5.96024392%2010.6260182,5.64066416%20C10.6496011,5.3210844%2010.7981107,4.82828493%2010.9673529,4.54312146%20C11.1365952,4.25795798%2011.3154723,4.15774159%2011.6081782,4.01515985%20C11.900884,3.87257811%2012.2261899,3.85220028%2012.6090658,3.85220028%20C13.0807245,3.85220028%2013.5489143,3.87479359%2013.8513308,4.05506936%20C14.1537473,4.23534512%2014.3273352,4.40577519%2014.4577349,4.7335493%20C14.5881347,5.06132341%2014.6274878,5.39456319%2014.6274878,5.89277984%20Z%20M13.0045736,8.79570922%20C13.0045736,8.50693323%2012.9736491,8.30881947%2012.9118002,8.20136794%20C12.8499512,8.09391641%2012.7490397,8.04019065%2012.6090658,8.04019065%20C12.472347,8.04019065%2012.3722494,8.09139802%2012.3087728,8.19381276%20C12.2452963,8.2962275%2012.213558,8.49685965%2012.213558,8.79570922%20L12.213558,10.2714888%20C12.213558,10.6307799%2012.2436686,10.8658301%2012.30389,10.9766395%20C12.3641114,11.0874489%2012.464209,11.1428536%2012.604183,11.1428536%20C12.6888184,11.1428536%2012.776709,11.1000409%2012.8678548,11.0144154%20C12.9590007,10.92879%2013.0045736,10.6979371%2013.0045736,10.3218567%20L13.0045736,8.79570922%20Z%20M5.36518653,4.0098916%20L6.62269598,12.0434594%20L4.43675863,12.0434594%20L4.32379749,10.5928916%20L3.56479749,10.5928916%20L3.44759642,12.0434594%20L1.24279749,12.0434594%20L2.32898153,4.0098916%20L5.36518653,4.0098916%20Z%20M3.95643636,5.82436107%20L3.55011169,9.16505185%20L3.55279749,9.1658916%20L4.21379749,9.1658916%20L3.95643636,5.82436107%20Z%20M6.75436062,5.22685302%20C6.93026958,5.14107797%207.15416722,4.97496012%207.42605353,4.72849946%20C7.64081922,4.50676332%207.79976291,4.26722737%207.9028846,4.0098916%20L9.24563938,4.0098916%20L9.24563938,12.0434594%20L7.71162421,12.0434594%20L7.71162421,6.04135094%20C7.54292082,6.19127739%207.38943424,6.31621265%207.25116447,6.41615674%20C7.11289471,6.51610084%206.94729342,6.59595354%206.75436062,6.65571484%20L6.75436062,5.22685302%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%20fill-rule='nonzero'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.18.official.svg b/src/sf-symbols/br.18.official.svg
new file mode 100644
index 0000000..8c71b5f
--- /dev/null
+++ b/src/sf-symbols/br.18.official.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e18%3c/title%3e%3cdesc%3e18%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/18'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23000000'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M9.86034765,3.95527548%20L9.876648,3.95527548%20L9.876648,4.92127548%20L9.80903388,4.92714217%20C9.70875828,4.94537019%209.6384501,5.00005426%209.59810934,5.09119436%20C9.5476834,5.2051195%209.52247042,5.41797961%209.52247042,5.72977472%20L9.52247042,6.46729006%20C9.52247042,6.75909829%209.55128525,6.96096563%209.6089149,7.07289208%20C9.66299812,7.17793075%209.75197541,7.23368204%209.87584677,7.24014596%20L9.877573,8.40279572%20C9.87409042,8.40273335%209.87058445,8.40270217%209.86705511,8.40270217%20C9.7276029,8.40270217%209.62737163,8.45217369%209.5663613,8.55111674%20C9.50535096,8.65005978%209.47484579,8.85884637%209.47484579,9.17747652%20L9.47484579,10.233987%20C9.47484579,10.5861571%209.50883726,10.8125522%209.57682021,10.9131723%20C9.64136306,11.0087007%209.74125792,11.0588819%209.8765048,11.0637159%20L9.87707939,12.0447245%20L9.87707939,12.0447245%20C9.375127,12.0447245%208.94201772,11.9412604%208.64921216,11.7583936%20C8.3564066,11.5755269%208.22903181,11.4566445%208.1021494,11.1550747%20C7.97526699,10.8535049%207.923648,10.3363638%207.923648,9.63377035%20C7.923648,9.16216653%207.92852809,8.74535394%208.2374232,8.32126591%20C8.54631831,7.89717787%208.66225725,7.98475532%208.99410356,7.77622303%20C8.46326742,7.62051075%208.15543999,7.25533326%208.0554105,6.92445457%20C7.95538102,6.59357589%207.923648,6.35500099%207.923648,5.9668101%20C7.923648,5.29629856%208.09116301,4.76393329%208.41464344,4.42867752%20C8.73812387,4.09342175%209.21617541,3.95527548%209.86034765,3.95527548%20Z%20M9.89479836,3.95527548%20L9.87849801,3.95527548%20L9.87849801,4.92127548%20L9.94611213,4.92714217%20C10.0463877,4.94537019%2010.1166959,5.00005426%2010.1570367,5.09119436%20C10.2074626,5.2051195%2010.2326756,5.41797961%2010.2326756,5.72977472%20L10.2326756,6.46729006%20C10.2326756,6.75909829%2010.2038608,6.96096563%2010.1462311,7.07289208%20C10.0921479,7.17793075%2010.0031706,7.23368204%209.87929924,7.24014596%20L9.877573,8.40279572%20C9.88105559,8.40273335%209.88456156,8.40270217%209.8880909,8.40270217%20C10.0275431,8.40270217%2010.1277744,8.45217369%2010.1887847,8.55111674%20C10.2497951,8.65005978%2010.2803002,8.85884637%2010.2803002,9.17747652%20L10.2803002,10.233987%20C10.2803002,10.5861571%2010.2463087,10.8125522%2010.1783258,10.9131723%20C10.113783,11.0087007%2010.0138881,11.0588819%209.87864121,11.0637159%20L9.87806662,12.0447245%20L9.87806662,12.0447245%20C10.380019,12.0447245%2010.8131283,11.9412604%2011.1059338,11.7583936%20C11.3987394,11.5755269%2011.5261142,11.4566445%2011.6529966,11.1550747%20C11.779879,10.8535049%2011.831498,10.3363638%2011.831498,9.63377035%20C11.831498,9.16216653%2011.8266179,8.74535394%2011.5177228,8.32126591%20C11.2088277,7.89717787%2011.0928888,7.98475532%2010.7610425,7.77622303%20C11.2918786,7.62051075%2011.599706,7.25533326%2011.6997355,6.92445457%20C11.799765,6.59357589%2011.831498,6.35500099%2011.831498,5.9668101%20C11.831498,5.29629856%2011.663983,4.76393329%2011.3405026,4.42867752%20C11.0170221,4.09342175%2010.5389706,3.95527548%209.89479836,3.95527548%20Z%20M4.16850199,5.31425825%20C4.34218167,5.22957023%204.56324187,5.06555757%204.8316826,4.82222028%20C5.04372659,4.60329419%205.200656,4.36679385%205.30247084,4.11271927%20L6.628209,4.11271927%20L6.628209,12.0444784%20L5.11363427,12.0444784%20L5.11363427,6.11843412%20C4.94706884,6.26646056%204.79552738,6.38981253%204.6590099,6.48849004%20C4.52249241,6.58716755%204.35898977,6.66600828%204.16850199,6.72501224%20L4.16850199,5.31425825%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%20fill-rule='nonzero'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.18.svg b/src/sf-symbols/br.18.svg
new file mode 100644
index 0000000..faa47f6
--- /dev/null
+++ b/src/sf-symbols/br.18.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__DRMmrmK5__" \ No newline at end of file
diff --git a/src/sf-symbols/br.l.official.svg b/src/sf-symbols/br.l.official.svg
new file mode 100644
index 0000000..fc8d788
--- /dev/null
+++ b/src/sf-symbols/br.l.official.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eL%3c/title%3e%3cdesc%3eL%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/L'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%2300A54F'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M5.8268717,11.5906405%20L5.8268717,4.48614501%20L7.75215401,4.48614501%20L7.7518717,10.205145%20L10.9518537,10.2056233%20L10.9518537,11.5906405%20L5.8268717,11.5906405%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/br.l.svg b/src/sf-symbols/br.l.svg
new file mode 100644
index 0000000..0f22cae
--- /dev/null
+++ b/src/sf-symbols/br.l.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eAL%3c/title%3e%3cdesc%3eA%20L%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/AL'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%232D973D'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M6.40808555,4.60711452%20L7.47319531,11.4115417%20L5.62170765,11.4115417%20L5.52642444,10.1831145%20L4.88342444,10.1831145%20L4.78388784,11.4115417%20L2.91642444,11.4115417%20L3.83642168,4.60711452%20L6.40808555,4.60711452%20Z%20M5.21487497,6.1439691%20L4.87071821,8.97353224%20L4.87342444,8.97411452%20L5.43342444,8.97411452%20L5.21487497,6.1439691%20Z%20M8.28190748,11.4115417%20L8.28190748,4.60711452%20L9.865707,4.60711452%20L9.86490748,9.80711452%20L13.0998906,9.80754771%20L13.0998906,11.4115417%20L8.28190748,11.4115417%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/captions.bubble.fill.svg b/src/sf-symbols/captions.bubble.fill.svg
new file mode 100644
index 0000000..7e7a9da
--- /dev/null
+++ b/src/sf-symbols/captions.bubble.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2021.8848%2021.5723'%3e%3cg%3e%3crect%20height='21.5723'%20opacity='0'%20width='21.8848'%20x='0'%20y='0'/%3e%3cpath%20d='M21.5234%205.78125L21.5234%2013.2617C21.5234%2016.123%2019.9609%2017.7246%2017.0508%2017.7246L10.4492%2017.7246L6.92383%2020.9473C6.46484%2021.377%206.18164%2021.5723%205.80078%2021.5723C5.24414%2021.5723%204.93164%2021.1719%204.93164%2020.5664L4.93164%2017.7246L4.47266%2017.7246C1.5625%2017.7246%200%2016.1328%200%2013.2617L0%205.78125C0%202.91016%201.5625%201.30859%204.47266%201.30859L17.0508%201.30859C19.9609%201.30859%2021.5234%202.91992%2021.5234%205.78125ZM4.4043%2012.0801C4.08203%2012.0801%203.81836%2012.3438%203.81836%2012.6758C3.81836%2012.998%204.08203%2013.252%204.4043%2013.252L6.11328%2013.252C6.43555%2013.252%206.68945%2012.998%206.68945%2012.6758C6.68945%2012.3438%206.43555%2012.0801%206.11328%2012.0801ZM8.18359%2012.0801C7.86133%2012.0801%207.60742%2012.3438%207.60742%2012.6758C7.60742%2012.998%207.86133%2013.252%208.18359%2013.252L13.2324%2013.252C13.5547%2013.252%2013.8184%2012.998%2013.8184%2012.6758C13.8184%2012.3438%2013.5547%2012.0801%2013.2324%2012.0801ZM15.3125%2012.0801C14.9902%2012.0801%2014.7266%2012.3438%2014.7266%2012.6758C14.7266%2012.998%2014.9902%2013.252%2015.3125%2013.252L17.1387%2013.252C17.4609%2013.252%2017.7148%2012.998%2017.7148%2012.6758C17.7148%2012.3438%2017.4609%2012.0801%2017.1387%2012.0801ZM4.4043%209.45312C4.08203%209.45312%203.81836%209.7168%203.81836%2010.0293C3.81836%2010.3613%204.08203%2010.625%204.4043%2010.625L8.54492%2010.625C8.86719%2010.625%209.13086%2010.3613%209.13086%2010.0293C9.13086%209.7168%208.86719%209.45312%208.54492%209.45312ZM10.625%209.45312C10.3027%209.45312%2010.0391%209.7168%2010.0391%2010.0293C10.0391%2010.3613%2010.3027%2010.625%2010.625%2010.625L17.1387%2010.625C17.4609%2010.625%2017.7148%2010.3613%2017.7148%2010.0293C17.7148%209.7168%2017.4609%209.45312%2017.1387%209.45312Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/chart.bar.fill.svg b/src/sf-symbols/chart.bar.fill.svg
new file mode 100644
index 0000000..0f081f8
--- /dev/null
+++ b/src/sf-symbols/chart.bar.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20147.559%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='147.559'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M110.387%2090.086L124.947%2090.086C131.66%2090.086%20135.105%2086.796%20135.105%2080.403L135.105%208.423C135.105%202.019%20131.66-1.218%20124.947-1.218L110.387-1.218C103.715-1.218%20100.28%202.019%20100.28%208.423L100.28%2080.403C100.28%2086.796%20103.715%2090.086%20110.387%2090.086Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3cpath%20d='M66.498%2090.086L81.068%2090.086C87.782%2090.086%2091.227%2086.796%2091.227%2080.403L91.227%2022.701C91.227%2016.297%2087.782%2013.059%2081.068%2013.059L66.498%2013.059C59.836%2013.059%2056.34%2016.297%2056.34%2022.701L56.34%2080.403C56.34%2086.796%2059.836%2090.086%2066.498%2090.086Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3cpath%20d='M22.567%2090.086L37.128%2090.086C43.851%2090.086%2047.286%2086.796%2047.286%2080.403L47.286%2036.863C47.286%2030.47%2043.851%2027.232%2037.128%2027.232L22.567%2027.232C15.906%2027.232%2012.461%2030.47%2012.461%2036.863L12.461%2080.403C12.461%2086.796%2015.906%2090.086%2022.567%2090.086Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/checkmark.circle.svg b/src/sf-symbols/checkmark.circle.svg
new file mode 100644
index 0000000..f92c0a9
--- /dev/null
+++ b/src/sf-symbols/checkmark.circle.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2099.598%2099.547'%3e%3cpath%20d='M49.773%2099.547C76.997%2099.547%2099.598%2076.997%2099.598%2049.773%2099.598%2022.55%2076.945%200%2049.722%200%2022.54%200%200%2022.55%200%2049.773%200%2076.997%2022.591%2099.547%2049.773%2099.547ZM49.773%2091.279C26.788%2091.28%208.36%2072.811%208.36%2049.773%208.36%2026.736%2026.736%208.267%2049.722%208.267%2072.759%208.267%2091.279%2026.736%2091.279%2049.773A41.36%2041.36%200%200%201%2049.773%2091.28Z'%20fill='%23000000'%3e%3c/path%3e%3cpath%20d='M44.447%2072.95C46.032%2072.95%2047.379%2072.185%2048.374%2070.683L70.672%2035.575C71.252%2034.612%2071.842%2033.513%2071.842%2032.436%2071.842%2030.271%2069.936%2028.872%2067.863%2028.872%2066.61%2028.872%2065.397%2029.597%2064.465%2031.058L44.229%2063.563%2034.62%2051.098C33.426%2049.534%2032.338%2049.14%2031.012%2049.14%2028.899%2049.14%2027.24%2050.83%2027.24%2053.015%2027.241%2054.062%2027.687%2055.11%2028.351%2056.041L40.26%2070.682C41.515%2072.298%2042.8%2072.951%2044.447%2072.951Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/checkmark.svg b/src/sf-symbols/checkmark.svg
new file mode 100644
index 0000000..11f6a18
--- /dev/null
+++ b/src/sf-symbols/checkmark.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2017.1875%2017.2363'%3e%3cg%3e%3crect%20height='17.2363'%20opacity='0'%20width='17.1875'%20x='0'%20y='0'/%3e%3cpath%20d='M6.36719%2017.2363C6.78711%2017.2363%207.11914%2017.0508%207.35352%2016.6895L16.582%202.1582C16.7578%201.875%2016.8262%201.66016%2016.8262%201.43555C16.8262%200.898438%2016.4746%200.546875%2015.9375%200.546875C15.5469%200.546875%2015.332%200.673828%2015.0977%201.04492L6.32812%2015.0195L1.77734%209.0625C1.5332%208.7207%201.28906%208.58398%200.9375%208.58398C0.380859%208.58398%200%208.96484%200%209.50195C0%209.72656%200.0976562%209.98047%200.283203%2010.2148L5.35156%2016.6699C5.64453%2017.0508%205.94727%2017.2363%206.36719%2017.2363Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/chevron.down.svg b/src/sf-symbols/chevron.down.svg
new file mode 100644
index 0000000..d31473d
--- /dev/null
+++ b/src/sf-symbols/chevron.down.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20109.73%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='109.73'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M54.884%2070.758C56.118%2070.758%2057.319%2070.281%2058.17%2069.327L95.95%2030.654C96.768%2029.825%2097.266%2028.769%2097.266%2027.515C97.266%2024.977%2095.359%2023.019%2092.822%2023.019C91.63%2023.019%2090.439%2023.547%2089.62%2024.314L52.251%2062.483L57.476%2062.483L20.096%2024.314C19.288%2023.547%2018.189%2023.019%2016.957%2023.019C14.408%2023.019%2012.461%2024.977%2012.461%2027.515C12.461%2028.769%2012.969%2029.835%2013.787%2030.665L51.609%2069.338C52.5%2070.291%2053.599%2070.758%2054.884%2070.758Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/chevron.forward.svg b/src/sf-symbols/chevron.forward.svg
new file mode 100644
index 0000000..9fda468
--- /dev/null
+++ b/src/sf-symbols/chevron.forward.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2051.108%2087.687'%3e%3cpath%20d='M51.108%2043.834C51.09%2041.837%2050.39%2040.196%2048.774%2038.604L11.214%201.877C9.974%200.6%208.453%200%206.64%200%202.96%200%200.001%202.897%200.001%206.55%200%208.326%200.765%2010.004%202.068%2011.344L35.466%2043.816%202.07%2076.325C0.784%2077.665%200%2079.305%200%2081.137%200%2084.79%202.96%2087.687%206.641%2087.687%208.437%2087.687%209.975%2087.086%2011.213%2085.81L48.774%2049.064C50.41%2047.473%2051.108%2045.814%2051.108%2043.834Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/chevron.right.svg b/src/sf-symbols/chevron.right.svg
new file mode 100644
index 0000000..6b40bb0
--- /dev/null
+++ b/src/sf-symbols/chevron.right.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2072.648%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='72.648'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M66.42%2044.739C66.42%2043.516%2065.974%2042.439%2065.031%2041.506L26.337%203.684C25.518%202.814%2024.43%202.379%2023.187%202.379C20.649%202.379%2018.691%204.274%2018.691%206.823C18.691%208.014%2019.23%209.143%2019.986%2010.003L55.521%2044.739L19.986%2079.476C19.23%2080.335%2018.691%2081.413%2018.691%2082.656C18.691%2085.204%2020.649%2087.1%2023.187%2087.1C24.43%2087.1%2025.518%2086.665%2026.337%2085.795L65.031%2047.972C65.974%2047.04%2066.42%2045.962%2066.42%2044.739Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/circle.dotted.and.circle.svg b/src/sf-symbols/circle.dotted.and.circle.svg
new file mode 100644
index 0000000..81e81b1
--- /dev/null
+++ b/src/sf-symbols/circle.dotted.and.circle.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__BFr3gotS__" \ No newline at end of file
diff --git a/src/sf-symbols/circle.lefthalf.filled.inverse.svg b/src/sf-symbols/circle.lefthalf.filled.inverse.svg
new file mode 100644
index 0000000..b10207d
--- /dev/null
+++ b/src/sf-symbols/circle.lefthalf.filled.inverse.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.2832%2019.9316'%3e%3cg%3e%3crect%20height='19.9316'%20opacity='0'%20width='20.2832'%20x='0'%20y='0'/%3e%3cpath%20d='M9.96094%2018.8867C15.0391%2018.8867%2018.8965%2015.0391%2018.8965%209.96094C18.8965%204.88281%2015.0391%201.02539%209.96094%201.02539ZM9.96094%2019.9219C15.459%2019.9219%2019.9219%2015.459%2019.9219%209.96094C19.9219%204.46289%2015.459%200%209.96094%200C4.46289%200%200%204.46289%200%209.96094C0%2015.459%204.46289%2019.9219%209.96094%2019.9219ZM9.96094%2018.2617C5.37109%2018.2617%201.66016%2014.5508%201.66016%209.96094C1.66016%205.37109%205.37109%201.66016%209.96094%201.66016C14.5508%201.66016%2018.2617%205.37109%2018.2617%209.96094C18.2617%2014.5508%2014.5508%2018.2617%209.96094%2018.2617Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/clock.fill.svg b/src/sf-symbols/clock.fill.svg
new file mode 100644
index 0000000..be42d66
--- /dev/null
+++ b/src/sf-symbols/clock.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20120'%3e%3cpath%20d='M32.962,65.218c-1.938,0%20-3.357,-1.482%20-3.357,-3.368c0,-1.906%201.419,-3.399%203.357,-3.399h22.135v-29.49c0,-1.927%201.493,-3.398%203.347,-3.398c1.928,0%203.42,1.471%203.42,3.398v32.889c0,1.886%20-1.492,3.368%20-3.42,3.368zM58.496,109.742c27.482,0%2049.825,-22.344%2049.825,-49.774c0,-27.43%20-22.343,-49.773%20-49.825,-49.773c-27.43,0%20-49.773,22.343%20-49.773,49.773c0,27.43%2022.343,49.774%2049.773,49.774z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/creditcard.fill.svg b/src/sf-symbols/creditcard.fill.svg
new file mode 100644
index 0000000..25a044d
--- /dev/null
+++ b/src/sf-symbols/creditcard.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20139.624%20120'%3e%3cpath%20d='M32.404,86.568c-2.866,0%20-4.797,-1.931%20-4.797,-4.672v-9.045c0,-2.742%201.931,-4.673%204.797,-4.673h11.963c2.866,0%204.797,1.931%204.797,4.673v9.045c0,2.741%20-1.931,4.672%20-4.797,4.672zM12.461,49.113v-11.099h114.711v11.099zM27.779,101.725h84.086c10.232,0%2015.307,-5.056%2015.307,-15.111v-53.145c0,-10.056%20-5.075,-15.111%20-15.307,-15.111h-84.086c-10.18,0%20-15.318,5.034%20-15.318,15.111v53.145c0,10.076%205.138,15.111%2015.318,15.111z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/ellipsis.circle.fill.svg b/src/sf-symbols/ellipsis.circle.fill.svg
new file mode 100644
index 0000000..57de095
--- /dev/null
+++ b/src/sf-symbols/ellipsis.circle.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M32.09%2061.568c16.185%200%2029.586-13.43%2029.586-29.587%200-16.186-13.43-29.587-29.616-29.587-16.157%200-29.558%2013.4-29.558%2029.587%200%2016.156%2013.43%2029.587%2029.587%2029.587zM18.078%2036.332c-2.379%200-4.351-1.944-4.351-4.38%200-2.408%201.972-4.351%204.35-4.351%202.408%200%204.381%201.943%204.381%204.35a4.358%204.358%200%2001-4.38%204.38zm13.981%200a4.376%204.376%200%2001-4.38-4.38c0-2.408%201.973-4.351%204.38-4.351a4.345%204.345%200%20014.351%204.35%204.352%204.352%200%2001-4.35%204.38zm13.981%200a4.358%204.358%200%2001-4.38-4.38%204.352%204.352%200%20014.38-4.351c2.38%200%204.352%201.943%204.352%204.35%200%202.437-1.973%204.38-4.352%204.38z'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/eye.fill.svg b/src/sf-symbols/eye.fill.svg
new file mode 100644
index 0000000..fbedd51
--- /dev/null
+++ b/src/sf-symbols/eye.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20152.271%20120'%3e%3cpath%20d='M76.158,101.46c39.098,0%2066.137,-31.607%2066.137,-41.492c0,-9.936%20-27.092,-41.492%20-66.137,-41.492c-38.601,0%20-66.189,31.556%20-66.189,41.492c0,9.885%2027.557,41.492%2066.189,41.492zM76.158,87.225c-15.077,0%20-27.309,-12.18%20-27.309,-27.257c0,-15.076%2012.232,-27.257%2027.309,-27.257c15.077,0%2027.257,12.181%2027.257,27.257c0,15.077%20-12.18,27.257%20-27.257,27.257zM76.158,69.921c5.51,0%209.952,-4.432%209.952,-9.953c0,-5.521%20-4.442,-9.952%20-9.952,-9.952c-5.521,0%20-9.952,4.431%20-9.952,9.952c0,5.521%204.431,9.953%209.952,9.953z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/figure.svg b/src/sf-symbols/figure.svg
new file mode 100644
index 0000000..470fb29
--- /dev/null
+++ b/src/sf-symbols/figure.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20109.728%20120'%3e%3cpath%20d='M54.817,27.328c5.917,0%2010.63,-4.713%2010.63,-10.641c0,-5.865%20-4.713,-10.63%20-10.63,-10.63c-5.876,0%20-10.64,4.765%20-10.64,10.63c0,5.928%204.764,10.641%2010.64,10.641zM54.817,73.163c2.046,0%202.939,1.278%204.008,4.87c1.556,4.817%206.778,25.362%208.233,31.373c0.685,3.062%202.304,4.308%204.619,4.308c3.145,0%204.971,-2.844%204.131,-5.886c-0.364,-1.287%20-7.465,-30.916%20-8.409,-37.051c-0.944,-6.209%20-0.956,-19.84%20-0.946,-24.054c0.011,-2.919%201.35,-4.787%203.749,-5.275c2.326,-0.54%2020.95,-2.576%2023.493,-3.364c2.118,-0.684%203.571,-2.366%203.571,-4.619c0,-2.823%20-2.189,-4.535%20-4.183,-4.535c-0.841,0%20-1.569,0.186%20-2.515,0.384c-7.586,1.391%20-24.062,3.155%20-35.751,3.155c-11.596,0%20-28.124,-1.661%20-35.761,-3.155c-0.843,-0.198%20-1.622,-0.384%20-2.464,-0.384c-2.045,0%20-4.131,1.712%20-4.131,4.535c0,2.253%201.349,4.142%203.52,4.619c2.646,0.581%2021.167,2.928%2023.492,3.364c2.399,0.436%203.79,2.356%203.852,5.275c0.063,4.214%20-0.104,17.845%20-1.048,24.054c-0.996,6.135%20-8.098,35.764%20-8.461,37.051c-0.841,3.042%201.038,5.886%204.183,5.886c2.263,0%203.83,-1.246%204.671,-4.308c1.661,-5.959%206.624,-26.452%208.232,-31.373c1.121,-3.488%201.921,-4.87%203.915,-4.87z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/gamecontroller.fill.svg b/src/sf-symbols/gamecontroller.fill.svg
new file mode 100644
index 0000000..28ebb03
--- /dev/null
+++ b/src/sf-symbols/gamecontroller.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20165.869%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='165.869'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M42.211%2034.999C42.211%2032.461%2043.755%2030.865%2046.407%2030.865L55.392%2030.865L55.392%2022.126C55.392%2019.526%2056.884%2017.93%2059.422%2017.93C61.908%2017.93%2063.401%2019.526%2063.401%2022.126L63.401%2030.865L71.879%2030.865C74.737%2030.865%2076.385%2032.461%2076.385%2034.999C76.385%2037.64%2074.737%2039.236%2071.879%2039.236L63.401%2039.236L63.401%2048.026C63.401%2050.627%2061.908%2052.212%2059.422%2052.212C56.884%2052.212%2055.392%2050.627%2055.392%2048.026L55.392%2039.236L46.407%2039.236C43.755%2039.236%2042.211%2037.64%2042.211%2034.999ZM114.254%2034.475C110.248%2034.475%20106.866%2031.217%20106.866%2027.149C106.866%2023.071%20110.248%2019.813%20114.254%2019.813C118.322%2019.813%20121.601%2023.071%20121.601%2027.149C121.601%2031.217%20118.322%2034.475%20114.254%2034.475ZM99.463%2049.428C95.447%2049.428%2092.075%2046.119%2092.075%2042.051C92.075%2038.024%2095.447%2034.714%2099.463%2034.714C103.531%2034.714%20106.8%2038.024%20106.8%2042.051C106.8%2046.119%20103.531%2049.428%2099.463%2049.428ZM31.05%2089.019C37.752%2089.019%2042.301%2086.58%2046.454%2081.514L55.303%2070.775C56.54%2069.278%2058.014%2068.541%2059.469%2068.541L106.349%2068.541C107.845%2068.541%20109.32%2069.278%20110.567%2070.775L119.416%2081.514C123.517%2086.58%20128.065%2089.019%20134.768%2089.019C145.956%2089.019%20153.409%2081.598%20153.409%2070.16C153.409%2065.332%20152.277%2059.696%20150.397%2053.371C147.406%2043.392%20142.184%2029.779%20137.157%2019.216C132.994%2010.424%20130.824%206.409%20120.518%204.094C111.295%202.006%2098.553%200.564%2082.909%200.564C67.317%200.564%2054.564%202.006%2045.352%204.094C35.045%206.409%2032.876%2010.424%2028.661%2019.216C23.686%2029.779%2018.463%2043.392%2015.473%2053.371C13.593%2059.696%2012.461%2065.332%2012.461%2070.16C12.461%2081.598%2019.913%2089.019%2031.05%2089.019Z'%20fill='currentColor'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/gearshape.fill.svg b/src/sf-symbols/gearshape.fill.svg
new file mode 100644
index 0000000..9d41961
--- /dev/null
+++ b/src/sf-symbols/gearshape.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20119.426%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='119.426'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M55.228%2095.763L64.203%2095.763C66.747%2095.763%2068.606%2094.268%2069.188%2091.743L71.7%2081.019C73.609%2080.365%2075.434%2079.67%2077.095%2078.861L86.479%2084.655C88.558%2085.964%2090.977%2085.766%2092.712%2084.032L99.007%2077.737C100.752%2075.991%20101.001%2073.479%2099.589%2071.328L93.847%2062.016C94.656%2060.345%2095.403%2058.53%2095.953%2056.807L106.791%2054.243C109.264%2053.713%20110.707%2051.855%20110.707%2049.258L110.707%2040.449C110.707%2037.956%20109.264%2036.097%20106.791%2035.567L96.056%2032.951C95.403%2030.97%2094.604%2029.206%2093.951%2027.691L99.692%2018.224C101.053%2016.083%20100.907%2013.716%2099.11%2011.918L92.712%205.624C90.925%203.992%2088.765%203.691%2086.635%204.897L77.095%2010.794C75.486%209.985%2073.66%209.29%2071.7%208.636L69.188-2.243C68.606-4.768%2066.747-6.263%2064.203-6.263L55.228-6.263C52.683-6.263%2050.824-4.768%2050.294-2.243L47.73%208.533C45.852%209.187%2043.944%209.881%2042.325%2010.742L32.837%204.897C30.655%203.691%2028.494%203.94%2026.708%205.624L20.309%2011.918C18.512%2013.716%2018.366%2016.083%2019.728%2018.224L25.469%2027.691C24.815%2029.206%2024.017%2030.97%2023.415%2032.951L12.691%2035.567C10.166%2036.097%208.723%2037.956%208.723%2040.449L8.723%2049.258C8.723%2051.855%2010.166%2053.713%2012.691%2054.243L23.519%2056.807C24.017%2058.53%2024.764%2060.345%2025.573%2062.016L19.831%2071.328C18.418%2073.479%2018.667%2075.991%2020.413%2077.737L26.708%2084.032C28.443%2085.766%2030.862%2085.964%2032.992%2084.655L42.377%2078.861C43.996%2079.67%2045.852%2080.365%2047.73%2081.019L50.294%2091.743C50.824%2094.268%2052.683%2095.763%2055.228%2095.763ZM59.746%2062C50.223%2062%2042.486%2054.159%2042.486%2044.688C42.486%2035.289%2050.234%2027.5%2059.746%2027.5C69.217%2027.5%2076.955%2035.289%2076.955%2044.688C76.955%2054.159%2069.217%2062%2059.746%2062Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/hammer.fill.svg b/src/sf-symbols/hammer.fill.svg
new file mode 100644
index 0000000..9950551
--- /dev/null
+++ b/src/sf-symbols/hammer.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2024.132%2022.5342'%3e%3cg%3e%3crect%20height='22.5342'%20opacity='0'%20width='24.132'%20x='0'%20y='0'/%3e%3cpath%20d='M20.108%2011.2329C20.44%2011.565%2020.8306%2011.5943%2021.1627%2011.2622L23.5552%208.87942C23.858%208.56692%2023.8384%208.15676%2023.5064%207.8345L22.9205%207.25833C22.6275%206.94583%2022.4224%206.90676%2022.1002%206.93606L21.1627%207.03372L20.5572%206.44778L20.8306%205.32473C20.9771%204.76809%2020.7623%204.19192%2020.1763%203.60598L18.233%201.68215C16.2603-0.25144%2011.8072-0.134253%2010.0201%201.85793C9.65875%202.25833%209.71734%202.66848%209.95172%202.93215C10.1275%203.13723%2010.4498%203.25442%2010.7134%203.12747C12.1295%202.4634%2013.6627%202.23879%2014.9517%202.81497L14.1509%204.86575C13.8873%205.54934%2014.024%206.02786%2014.4634%206.46731L16.2408%208.24465C16.563%208.55715%2016.9048%208.65481%2017.4224%208.54739L18.7017%208.29348L19.2974%208.89895L19.2095%209.81692C19.1705%2010.1782%2019.2095%2010.3736%2019.522%2010.6763ZM0.566952%2020.94L1.58258%2021.9654C2.40289%2022.7857%203.41851%2022.7173%204.24859%2021.77L15.4107%209.0259C15.3521%208.97708%2015.3033%208.92825%2015.2545%208.87942L13.692%207.31692C13.6431%207.25833%2013.5845%207.19973%2013.5259%207.1509L0.781796%2018.2935C-0.185001%2019.1236-0.25336%2020.1294%200.566952%2020.94Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/hammer.svg b/src/sf-symbols/hammer.svg
new file mode 100644
index 0000000..33ee2bd
--- /dev/null
+++ b/src/sf-symbols/hammer.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20142.092%20120'%3e%3cpath%20d='M14.204,104.517l8.523,8.574c4.266,4.37%209.215,4.058%2013.844,-1.079l53.435,-58.905l-4.829,-4.891l-53.061,58.377c-1.749,2.008%20-3.434,2.464%20-5.792,0.158l-5.831,-5.831c-2.358,-2.306%20-1.799,-3.991%200.147,-5.792l57.404,-53.993l-4.891,-4.828l-57.973,54.417c-4.889,4.578%20-5.304,9.465%20-0.976,13.793zM46.222,13.609c-2.045,2.055%20-2.2,4.918%20-1.007,6.899c1.11,1.774%203.423,2.904%206.658,2.137c7.296,-1.713%2014.909,-2.002%2022.085,2.715l-2.942,7.232c-1.681,4.17%20-0.8,7.077%201.847,9.827l11.464,11.578c2.427,2.437%204.511,2.531%207.325,2.033l5.289,-0.947l3.349,3.298l-0.217,2.793c-0.166,2.484%200.507,4.403%202.937,6.77l3.78,3.77c2.426,2.374%205.514,2.53%207.795,0.186l14.582,-14.571c2.333,-2.395%202.229,-5.328%20-0.187,-7.743l-3.831,-3.822c-2.379,-2.367%20-4.225,-3.165%20-6.656,-2.999l-2.897,0.269l-3.194,-3.194l1.214,-5.586c0.623,-2.815%20-0.155,-5.048%20-3.051,-7.933l-10.974,-10.963c-16.678,-16.627%20-38.891,-16.227%20-53.369,-1.749zM53.711,15.457c12.159,-8.868%2028.665,-7.388%2039.734,3.733l12.136,12.085c1.227,1.175%201.415,2.09%201.072,3.796l-1.619,7.391l7.557,7.443l4.868,-0.25c1.258,-0.062%201.696,0.043%202.642,0.989l2.913,2.912l-12.264,12.191l-2.84,-2.86c-0.956,-0.998%20-1.164,-1.436%20-1.102,-2.735l0.353,-4.827l-7.433,-7.454l-7.65,1.309c-1.602,0.291%20-2.321,0.155%20-3.548,-1.02l-9.998,-9.999c-1.269,-1.217%20-1.384,-2.041%20-0.614,-3.902l4.392,-10.413c-7.849,-7.275%20-17.989,-10.366%20-28.141,-7.409c-0.801,0.197%20-1.062,-0.479%20-0.458,-0.98z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/heart.circle.fill.svg b/src/sf-symbols/heart.circle.fill.svg
new file mode 100644
index 0000000..9f58fb3
--- /dev/null
+++ b/src/sf-symbols/heart.circle.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20120'%3e%3cpath%20d='M8.723,59.968c0,-27.43%2022.343,-49.773%2049.773,-49.773c27.482,0%2049.825,22.343%2049.825,49.773c0,27.43%20-22.343,49.774%20-49.825,49.774c-27.43,0%20-49.773,-22.344%20-49.773,-49.774zM31.74,53.112c0,13.14%2013.952,25.362%2024.226,31.92c0.912,0.56%202.053,1.182%202.634,1.182c0.684,0%201.669,-0.612%202.427,-1.182c10.24,-6.674%2024.267,-18.78%2024.267,-31.92c0,-8.928%20-6.168,-15.396%20-14.671,-15.396c-5.367,0%20-9.584,3.106%20-12.127,7.693c-2.491,-4.587%20-6.645,-7.693%20-12.075,-7.693c-8.555,0%20-14.681,6.468%20-14.681,15.396z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/house.svg b/src/sf-symbols/house.svg
new file mode 100644
index 0000000..14d0829
--- /dev/null
+++ b/src/sf-symbols/house.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20116.513%20102.535'%3e%3cpath%20d='M25.102%20102.535H91.142C98.085%20102.535%20102.13%2098.572%20102.13%2091.755V37.778L94.288%2032.473V89.755C94.288%2092.957%2092.563%2094.682%2089.464%2094.682H26.73C23.64%2094.682%2021.968%2092.957%2021.968%2089.755V32.495L14.062%2037.778V91.754C14.062%2098.583%2018.108%20102.535%2025.102%20102.535ZM0%2048.207C0%2050.217%201.585%2052.112%204.237%2052.112%205.594%2052.112%206.713%2051.367%207.697%2050.548L56.562%209.502C57.685%208.566%2058.962%208.576%2060.045%209.502L108.867%2050.548C109.903%2051.367%20111.022%2052.112%20112.38%2052.112%20114.68%2052.112%20116.513%2050.682%20116.513%2048.362%20116.513%2046.88%20116.067%2045.896%20115%2045.004L64.211%202.304C60.57-0.769%2056.11-0.769%2052.448%202.303L1.565%2045.003C0.497%2045.896%200%2047.087%200%2048.207ZM44.189%2097.522H72.324V61.982C72.324%2059.75%2070.851%2058.276%2068.621%2058.276H47.892C45.662%2058.277%2044.19%2059.751%2044.19%2061.981ZM89.874%2026.197%20102.13%2036.577V13.981C102.13%2011.846%20100.721%2010.488%2098.637%2010.488H93.42C91.284%2010.488%2089.874%2011.846%2089.874%2013.982Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/info.circle.fill.svg b/src/sf-symbols/info.circle.fill.svg
new file mode 100644
index 0000000..26aa918
--- /dev/null
+++ b/src/sf-symbols/info.circle.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='117.045'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M58.496%2094.513C85.719%2094.513%20108.321%2071.963%20108.321%2044.739C108.321%2017.516%2085.667-5.034%2058.444-5.034C31.262-5.034%208.723%2017.516%208.723%2044.739C8.723%2071.963%2031.314%2094.513%2058.496%2094.513Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M49.71%2073.019C47.606%2073.019%2046.032%2071.516%2046.032%2069.392C46.032%2067.475%2047.606%2065.869%2049.71%2065.869L55.929%2065.869L55.929%2042.893L50.529%2042.893C48.528%2042.893%2046.902%2041.391%2046.902%2039.267C46.902%2037.339%2048.528%2035.743%2050.529%2035.743L60.052%2035.743C62.611%2035.743%2063.989%2037.598%2063.989%2040.291L63.989%2065.869L70.207%2065.869C72.249%2065.869%2073.886%2067.475%2073.886%2069.392C73.886%2071.516%2072.249%2073.019%2070.207%2073.019ZM58.082%2027.439C54.412%2027.439%2051.468%2024.454%2051.468%2020.784C51.468%2017.062%2054.412%2014.159%2058.082%2014.159C61.752%2014.159%2064.603%2017.062%2064.603%2020.784C64.603%2024.454%2061.752%2027.439%2058.082%2027.439Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/ipad.gen2.landscape.svg b/src/sf-symbols/ipad.gen2.landscape.svg
new file mode 100644
index 0000000..01f1456
--- /dev/null
+++ b/src/sf-symbols/ipad.gen2.landscape.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20115.09%2089.912'%3e%3cpath%20d='M15.318%2089.912H99.772C110.004%2089.912%20115.09%2084.805%20115.09%2074.749V15.163C115.09%205.107%20110.004%200%2099.772%200H15.318C5.138%200%200%205.086%200%2015.163V74.749C0%2084.826%205.138%2089.912%2015.318%2089.912ZM15.443%2082.007C10.566%2082.007%207.853%2079.449%207.853%2074.366V15.546C7.853%2010.514%2010.566%207.906%2015.443%207.906H99.647C104.481%207.905%20107.237%2010.513%20107.237%2015.545V74.366C107.236%2079.449%20104.48%2082.006%2099.646%2082.006ZM38.696%2078.268H76.446C77.751%2078.268%2078.674%2077.408%2078.674%2076.05%2078.675%2074.64%2077.752%2073.77%2076.445%2073.77H38.696C37.39%2073.768%2036.416%2074.638%2036.416%2076.048%2036.415%2077.408%2037.39%2078.27%2038.695%2078.27Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/ipad.gen2.svg b/src/sf-symbols/ipad.gen2.svg
new file mode 100644
index 0000000..dcde7ea
--- /dev/null
+++ b/src/sf-symbols/ipad.gen2.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2016.6797%2022.0215'%3e%3cg%3e%3crect%20height='22.0215'%20opacity='0'%20width='16.6797'%20x='0'%20y='0'%20/%3e%3cpath%20d='M5.39062%2019.6875L10.9277%2019.6875C11.2012%2019.6875%2011.3867%2019.502%2011.3867%2019.2285C11.3867%2018.9551%2011.2012%2018.7793%2010.9277%2018.7793L5.39062%2018.7793C5.12695%2018.7793%204.94141%2018.9551%204.94141%2019.2285C4.94141%2019.502%205.12695%2019.6875%205.39062%2019.6875ZM0%2019.4238C0%2020.9668%201.08398%2022.002%202.70508%2022.002L13.6133%2022.002C15.2344%2022.002%2016.3184%2020.9668%2016.3184%2019.4238L16.3184%202.58789C16.3184%201.04492%2015.2344%200%2013.6133%200L2.70508%200C1.08398%200%200%201.04492%200%202.58789ZM1.57227%2019.1602L1.57227%202.85156C1.57227%202.05078%202.06055%201.57227%202.90039%201.57227L13.418%201.57227C14.248%201.57227%2014.7461%202.05078%2014.7461%202.85156L14.7461%2019.1602C14.7461%2019.9609%2014.248%2020.4297%2013.418%2020.4297L2.90039%2020.4297C2.06055%2020.4297%201.57227%2019.9609%201.57227%2019.1602Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/iphone.gen2.svg b/src/sf-symbols/iphone.gen2.svg
new file mode 100644
index 0000000..9d98651
--- /dev/null
+++ b/src/sf-symbols/iphone.gen2.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2062.771%20103.335'%3e%3cpath%20d='M13.275%20103.335H49.444C57.407%20103.335%2062.77%2098.282%2062.77%2090.67V12.665C62.771%205.053%2057.407%200%2049.444%200H13.275C5.301%200%200%205.053%200%2012.665V90.67C0%2098.282%205.301%20103.335%2013.275%20103.335ZM14.28%2095.493C10.19%2095.493%207.853%2093.31%207.853%2089.377V13.959C7.853%2010.026%2010.191%207.853%2014.28%207.853H20.55C21.316%207.853%2021.731%208.258%2021.731%209.026V10.135C21.732%2012.137%2023.081%2013.548%2025.083%2013.548H37.688C39.742%2013.548%2041.028%2012.137%2041.028%2010.135V9.025C41.028%208.259%2041.443%207.854%2042.211%207.854H48.439C52.58%207.853%2054.866%2010.026%2054.866%2013.96V89.377C54.866%2093.31%2052.58%2095.493%2048.439%2095.493ZM21.055%2091.912H41.767C43.063%2091.912%2044.037%2090.988%2044.037%2089.63S43.064%2087.36%2041.768%2087.36H21.055C19.697%2087.36%2018.785%2088.272%2018.785%2089.63S19.697%2091.912%2021.055%2091.912Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/joystickcontroller.fill.svg b/src/sf-symbols/joystickcontroller.fill.svg
new file mode 100644
index 0000000..97cc598
--- /dev/null
+++ b/src/sf-symbols/joystickcontroller.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2023.623%2021.3965'%3e%3cg%3e%3crect%20height='21.3965'%20opacity='0'%20width='23.623'%20x='0'%20y='0'/%3e%3cpath%20d='M11.6309%2021.3965C12.9883%2021.3965%2014.0039%2021.0938%2014.9121%2020.6836L21.6895%2017.6367C22.7441%2017.1582%2023.2617%2016.7676%2023.2617%2015.9375L23.2617%2015.1953C23.2617%2014.873%2022.9199%2014.8047%2022.7344%2014.8926L15.459%2018.1934C14.2383%2018.75%2012.9102%2019.0137%2011.6406%2019.0137C10.332%2019.0137%209.26758%2018.8379%207.82227%2018.1738L0.537109%2014.873C0.351562%2014.7852%200%2014.873%200%2015.1953L0%2015.9375C0%2016.7676%200.517578%2017.1582%201.58203%2017.6367L8.34961%2020.6836C9.26758%2021.0938%2010.2734%2021.3965%2011.6309%2021.3965ZM11.6406%2017.6758C12.7441%2017.6758%2013.8574%2017.4609%2014.9414%2016.9727L21.8359%2013.8379C22.4316%2013.5645%2023.2617%2013.1055%2023.2617%2012.4219C23.2617%2011.7383%2022.4219%2011.2793%2021.8164%2011.0059L14.9414%207.87109C14.1211%207.50977%2013.3496%207.30469%2012.4902%207.2168L12.4902%2012.6465C12.4902%2012.9297%2012.1582%2013.1934%2011.6406%2013.1934C11.1328%2013.1934%2010.8008%2012.9297%2010.8008%2012.6465L10.8008%207.2168C9.90234%207.31445%209.10156%207.53906%208.34961%207.87109L1.04492%2011.2109C0.341797%2011.5234%200.0292969%2011.9629%200.0292969%2012.4219C0.0292969%2012.8809%200.341797%2013.3203%201.02539%2013.6328L8.34961%2016.9727C9.41406%2017.4609%2010.5273%2017.6758%2011.6406%2017.6758ZM4.55078%2013.3887C3.69141%2013.3887%202.99805%2012.9688%202.99805%2012.4316C2.99805%2011.9043%203.69141%2011.4844%204.55078%2011.4844C5.41016%2011.4844%206.09375%2011.9043%206.09375%2012.4316C6.09375%2012.9688%205.41016%2013.3887%204.55078%2013.3887ZM11.6406%206.41602C9.88281%206.41602%208.44727%204.99023%208.44727%203.23242C8.44727%201.47461%209.88281%200.0585938%2011.6406%200.0585938C13.3984%200.0585938%2014.8145%201.47461%2014.8145%203.23242C14.8145%204.99023%2013.3984%206.41602%2011.6406%206.41602Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/joystickcontroller.svg b/src/sf-symbols/joystickcontroller.svg
new file mode 100644
index 0000000..de2c57a
--- /dev/null
+++ b/src/sf-symbols/joystickcontroller.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20140.886%20120'%3e%3cpath%20d='M70.446,97.424c6.801,0%2011.919,-1.476%2016.488,-3.583l35.696,-16.313c3.591,-1.67%205.727,-4.763%205.727,-8.206c0,-3.569%20-2.106,-6.827%20-5.654,-8.425l-35.376,-16.115c-2.247,-1.062%20-4.681,-1.852%20-7.012,-2.402v7.522c1.191,0.343%202.589,0.802%203.862,1.376l34.849,15.919c2.072,0.927%201.854,3.229%20-0.136,4.145l-34.713,15.795c-4.228,1.974%20-8.684,2.929%20-13.731,2.929c-5.047,0%20-9.565,-0.955%20-13.742,-2.929l-34.713,-15.795c-2.02,-0.905%20-2.229,-3.207%20-0.135,-4.145l34.848,-15.919c1.274,-0.574%202.671,-1.033%203.873,-1.376v-7.522c-2.341,0.55%20-4.816,1.34%20-7.074,2.402l-35.325,16.115c-3.548,1.598%20-5.654,4.856%20-5.654,8.425c0,3.443%202.147,6.536%205.727,8.206l35.645,16.313c4.568,2.107%209.698,3.583%2016.55,3.583zM70.446,114.321c5.535,0.052%2011.108,-1.288%2016.246,-3.634l33.869,-15.221c5.264,-2.409%207.859,-4.371%207.859,-8.514v-1.845c0,-1.764%20-1.835,-2.118%20-2.842,-1.681l-36.272,16.51c-6.073,2.762%20-12.593,4.153%20-18.757,4.153c-6.121,0%20-12.59,-1.391%20-18.859,-4.256l-36.427,-16.614c-1.442,-0.633%20-2.802,0.093%20-2.802,1.836v1.846c0,4.142%202.647,6.156%207.963,8.565l33.817,15.221c5.086,2.294%2010.619,3.582%2016.205,3.634zM41.043,74.012c4.482,0%208.134,-2.19%208.134,-5.053c0,-2.77%20-3.704,-4.959%20-8.134,-4.959c-4.523,0%20-8.185,2.189%20-8.185,4.959c0,2.863%203.61,5.053%208.185,5.053zM70.446,37.68c8.864,0%2015.985,-7.183%2015.985,-16.069c0,-8.823%20-7.172,-15.996%20-15.985,-15.996c-8.885,0%20-16.069,7.225%20-16.069,15.996c0,8.937%207.184,16.069%2016.069,16.069zM70.446,71.69c2.3,0%204.237,-1.139%204.237,-2.673v-40.787h-8.433v40.787c0,1.534%201.833,2.673%204.196,2.673z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/kr.12.svg b/src/sf-symbols/kr.12.svg
new file mode 100644
index 0000000..8b1261f
--- /dev/null
+++ b/src/sf-symbols/kr.12.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__BpUQnQPF__" \ No newline at end of file
diff --git a/src/sf-symbols/kr.15.svg b/src/sf-symbols/kr.15.svg
new file mode 100644
index 0000000..a0399e7
--- /dev/null
+++ b/src/sf-symbols/kr.15.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__Ck4hDTBn__" \ No newline at end of file
diff --git a/src/sf-symbols/kr.all.svg b/src/sf-symbols/kr.all.svg
new file mode 100644
index 0000000..f6cf67e
--- /dev/null
+++ b/src/sf-symbols/kr.all.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__CrpLBrZe__" \ No newline at end of file
diff --git a/src/sf-symbols/laurel.leading.svg b/src/sf-symbols/laurel.leading.svg
new file mode 100644
index 0000000..1cb1163
--- /dev/null
+++ b/src/sf-symbols/laurel.leading.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2067.9%20120'%3e%3cpath%20d='M43.092,104.881c-2.835,-0.238%20-6.354,0.561%20-8.753,2.035c-1.006,0.54%20-1.131,1.474%20-0.301,2.293c2.222,1.994%205.607,3.479%208.452,3.717c2.98,0.374%206.551,-0.55%208.898,-2.346c0.809,-0.581%200.871,-1.401%200.114,-2.106c-2.097,-1.973%20-5.493,-3.395%20-8.41,-3.593zM53.321,88.412c-1.536,2.399%20-2.46,5.919%20-2.325,8.722c0.176,2.948%201.536,6.395%203.447,8.513c0.653,0.727%201.462,0.727%202.054,-0.052c1.9,-2.284%202.938,-5.855%202.626,-8.887c-0.187,-2.834%20-1.599,-6.178%20-3.561,-8.421c-0.716,-0.892%20-1.598,-0.83%20-2.241,0.125zM29.322,91.673c-2.689,-0.986%20-6.344,-1.059%20-9.095,-0.249c-1.048,0.29%20-1.338,1.161%20-0.809,2.157c1.609,2.492%204.506,4.777%207.216,5.752c2.741,1.111%206.448,1.122%209.261,-0.041c0.882,-0.353%201.121,-1.12%200.591,-1.992c-1.557,-2.471%20-4.402,-4.703%20-7.164,-5.627zM43.352,78.381c-2.097,1.869%20-3.883,5.025%20-4.433,7.797c-0.623,2.929%20-0.197,6.573%201.101,9.085c0.415,0.934%201.234,1.059%202.043,0.519c2.336,-1.723%204.246,-4.88%204.745,-7.912c0.561,-2.834%200.062,-6.468%20-1.236,-9.126c-0.415,-1.017%20-1.338,-1.193%20-2.22,-0.363zM8.774,72.751c0.395,2.856%202.119,6.189%204.164,8.234c2.046,2.149%205.42,3.623%208.482,3.696c0.996,0.062%201.473,-0.581%201.349,-1.577c-0.488,-2.969%20-2.222,-6.177%20-4.268,-8.088c-2.086,-1.92%20-5.346,-3.519%20-8.191,-3.852c-1.162,-0.125%20-1.712,0.467%20-1.536,1.587zM36.768,68.599c-2.73,0.862%20-5.626,3.021%20-7.278,5.337c-1.734,2.284%20-2.782,5.752%20-2.658,8.774c0.063,0.965%200.747,1.473%201.702,1.286c2.959,-0.685%206.032,-2.783%207.527,-5.306c1.661,-2.481%202.71,-6.001%202.533,-8.846c0,-1.183%20-0.705,-1.66%20-1.826,-1.245zM28.597,57.124c-2.326,1.848%20-4.184,4.994%20-4.807,7.85c-0.125,0.933%200.353,1.628%201.286,1.628c3.021,0.125%206.531,-1.173%208.68,-3.156c2.211,-1.9%204.132,-5.067%204.765,-7.922c0.177,-1.1%20-0.415,-1.753%20-1.473,-1.753c-2.948,0.249%20-6.333,1.557%20-8.451,3.353zM8.847,50.758c-0.322,2.865%200.488,6.447%201.983,8.971c1.485,2.658%204.371,4.942%207.195,5.689c0.934,0.291%201.639,-0.124%201.753,-1.12c0.322,-2.824%20-0.477,-6.406%20-2.035,-8.95c-1.557,-2.43%20-4.34,-4.766%20-6.966,-5.752c-1.058,-0.467%20-1.816,-0.062%20-1.93,1.162zM31.255,40.738c-2.783,0.935%20-5.679,3.178%20-7.226,5.69c-0.478,0.82%20-0.239,1.639%200.643,2.044c2.772,1.111%206.478,1.111%209.25,-0.063c2.856,-1.069%205.753,-3.364%207.164,-5.825c0.581,-0.933%200.28,-1.753%20-0.767,-2.105c-2.772,-0.8%20-6.406,-0.727%20-9.064,0.259zM14.87,27.924c-1.298,2.679%20-1.734,6.334%20-1.174,9.157c0.488,3.001%202.399,6.095%204.808,7.829c0.757,0.591%201.576,0.363%202.043,-0.467c1.308,-2.555%201.682,-6.188%201.049,-9.064c-0.613,-2.845%20-2.399,-6.001%20-4.444,-7.922c-0.83,-0.654%20-1.764,-0.477%20-2.282,0.467zM40.133,26.647c-2.918,0.582%20-6.064,2.389%20-7.891,4.641c-0.581,0.747%20-0.415,1.566%200.353,2.044c2.564,1.547%206.249,2.034%209.167,1.235c2.866,-0.623%206.023,-2.481%207.871,-4.652c0.746,-0.871%200.529,-1.763%20-0.467,-2.23c-2.596,-1.163%20-6.188,-1.588%20-9.033,-1.038zM25.69,11.662c-1.661,2.471%20-2.648,6.053%20-2.461,8.888c0.062,2.959%201.537,6.302%203.686,8.472c0.757,0.705%201.587,0.602%202.127,-0.176c1.598,-2.503%202.574,-6.023%202.325,-8.888c-0.187,-2.793%20-1.547,-6.136%20-3.343,-8.41c-0.768,-0.83%20-1.691,-0.768%20-2.334,0.114zM50.89,6.709c-2.845,0.447%20-6.063,2.119%20-8.088,4.143c-2.086,2.035%20-3.696,5.368%20-4.018,8.223c-0.114,0.986%200.415,1.525%201.411,1.474c2.98,-0.146%206.313,-1.704%208.327,-3.977c1.972,-1.994%203.571,-5.316%204.007,-8.224c0.125,-1.161%20-0.456,-1.815%20-1.639,-1.639z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/laurel.left.svg b/src/sf-symbols/laurel.left.svg
new file mode 100644
index 0000000..20d253e
--- /dev/null
+++ b/src/sf-symbols/laurel.left.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2021%2044'%3e%3cpath%20d='M14.71%2044a6.24%206.24%200%200%201-4.77-2.22%206.2%206.2%200%200%201%208.55.94A6.21%206.21%200%200%201%2014.7%2044m2.75-7.25a6.2%206.2%200%200%201%201.28-3.78%206.23%206.23%200%200%201%20.95%208.55%206.24%206.24%200%200%201-2.23-4.77m-9.28%201.74a6.23%206.23%200%200%201-4.04-3.39%206.22%206.22%200%200%201%208.01%203.13c-1.22.5-2.6.62-3.97.26m4.53-6.3a6.21%206.21%200%200%201%202.21-3.31%206.23%206.23%200%200%201-1.3%208.5%206.23%206.23%200%200%201-.91-5.19M2.27%2031.07A6.24%206.24%200%200%201%200%2026.32a6.21%206.21%200%200%201%206%206.17%206.21%206.21%200%200%201-3.73-1.42M9%2027.23c.9-1.1%202.11-1.8%203.4-2.1a6.23%206.23%200%200%201-4.72%207.2A6.23%206.23%200%200%201%209%2027.23M1.2%2022.3a6.24%206.24%200%200%201-.96-5.18%206.21%206.21%200%200%201%204.2%207.51A6.21%206.21%200%200%201%201.2%2022.3m7.5-1.97a6.21%206.21%200%200%201%203.82-1.15A6.23%206.23%200%200%201%206.1%2024.9a6.24%206.24%200%200%201%202.6-4.58M2.12%2013.4a6.23%206.23%200%200%201%20.73-5.22%206.21%206.21%200%200%201%201.6%208.45%206.22%206.22%200%200%201-2.33-3.23m7.74.5a6.22%206.22%200%200%201%203.98.12%206.23%206.23%200%200%201-7.9%203.4%206.24%206.24%200%200%201%203.92-3.51M5.92%206.29c-.23-1.9.42-3.7%201.63-5a6.22%206.22%200%200%201%20.1%208.6%206.21%206.21%200%200%201-1.73-3.6m7.53%201.86a6.22%206.22%200%200%201%203.9.82%206.24%206.24%200%200%201-8.38%201.96%206.24%206.24%200%200%201%204.48-2.78m.97-6.81A6.2%206.2%200%200%201%2018.17%200a6.23%206.23%200%200%201-6.13%206.04%206.23%206.23%200%200%201%202.38-4.7'%20fill='%238E8E93'%20fill-rule='evenodd'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/laurel.trailing.svg b/src/sf-symbols/laurel.trailing.svg
new file mode 100644
index 0000000..f9f2ad0
--- /dev/null
+++ b/src/sf-symbols/laurel.trailing.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2067.9%20120'%3e%3cpath%20d='M24.816,104.881c-2.917,0.198%20-6.261,1.62%20-8.41,3.593c-0.757,0.705%20-0.695,1.525%200.114,2.106c2.347,1.796%205.918,2.72%208.898,2.346c2.845,-0.238%206.23,-1.723%208.452,-3.717c0.871,-0.819%200.746,-1.753%20-0.301,-2.293c-2.399,-1.474%20-5.918,-2.273%20-8.753,-2.035zM14.587,88.412c-0.643,-0.955%20-1.525,-1.017%20-2.241,-0.125c-1.962,2.243%20-3.374,5.587%20-3.561,8.421c-0.312,3.032%200.727,6.603%202.626,8.887c0.592,0.779%201.401,0.779%202.054,0.052c1.911,-2.118%203.271,-5.565%203.437,-8.513c0.145,-2.803%20-0.779,-6.323%20-2.315,-8.722zM38.586,91.673c-2.71,0.924%20-5.607,3.156%20-7.164,5.627c-0.529,0.872%20-0.291,1.639%200.591,1.992c2.813,1.163%206.52,1.152%209.261,0.041c2.71,-0.975%205.596,-3.26%207.206,-5.752c0.529,-0.996%200.29,-1.867%20-0.799,-2.157c-2.751,-0.81%20-6.406,-0.737%20-9.095,0.249zM24.556,78.381c-0.882,-0.83%20-1.754,-0.654%20-2.22,0.363c-1.298,2.658%20-1.797,6.292%20-1.236,9.126c0.499,3.032%202.409,6.189%204.745,7.912c0.809,0.54%201.628,0.415%202.043,-0.519c1.288,-2.512%201.713,-6.156%201.09,-9.085c-0.55,-2.772%20-2.325,-5.928%20-4.422,-7.797zM59.175,72.751c0.176,-1.12%20-0.426,-1.712%20-1.577,-1.587c-2.845,0.333%20-6.105,1.932%20-8.14,3.852c-2.097,1.911%20-3.831,5.119%20-4.319,8.088c-0.124,0.996%200.405,1.639%201.349,1.577c3.062,-0.073%206.426,-1.547%208.471,-3.696c2.098,-2.045%203.769,-5.378%204.216,-8.234zM31.14,68.599c-1.121,-0.415%20-1.826,0.062%20-1.826,1.245c-0.176,2.845%200.872,6.365%202.533,8.846c1.547,2.523%204.569,4.621%207.528,5.306c0.954,0.187%201.628,-0.321%201.69,-1.286c0.125,-3.022%20-0.924,-6.49%20-2.647,-8.774c-1.662,-2.316%20-4.548,-4.475%20-7.278,-5.337zM39.311,57.124c-2.118,-1.796%20-5.503,-3.104%20-8.451,-3.353c-1.058,0%20-1.65,0.653%20-1.473,1.753c0.633,2.855%202.554,6.022%204.765,7.922c2.149,1.983%205.659,3.281%208.68,3.156c0.923,0%201.452,-0.695%201.276,-1.628c-0.572,-2.856%20-2.482,-6.002%20-4.797,-7.85zM59.102,50.758c-0.114,-1.224%20-0.912,-1.629%20-1.971,-1.162c-2.626,0.986%20-5.357,3.322%20-6.914,5.752c-1.558,2.544%20-2.409,6.126%20-2.087,8.95c0.114,0.996%200.819,1.411%201.753,1.12c2.824,-0.747%205.699,-3.031%207.184,-5.689c1.547,-2.524%202.357,-6.106%202.035,-8.971zM36.653,40.738c-2.658,-0.986%20-6.291,-1.059%20-9.064,-0.259c-1.047,0.352%20-1.348,1.172%20-0.767,2.105c1.463,2.461%204.308,4.756%207.164,5.825c2.772,1.174%206.478,1.174%209.25,0.063c0.872,-0.405%201.162,-1.224%200.633,-2.044c-1.547,-2.512%20-4.433,-4.755%20-7.216,-5.69zM53.028,27.924c-0.508,-0.944%20-1.442,-1.121%20-2.272,-0.467c-2.045,1.921%20-3.831,5.077%20-4.444,7.922c-0.633,2.876%20-0.208,6.509%201.101,9.064c0.415,0.83%201.234,1.058%201.992,0.467c2.408,-1.734%204.308,-4.828%204.848,-7.829c0.561,-2.823%200.073,-6.478%20-1.225,-9.157zM27.775,26.647c-2.845,-0.55%20-6.437,-0.125%20-9.033,1.038c-0.996,0.467%20-1.213,1.359%20-0.467,2.23c1.9,2.171%205.005,4.029%207.871,4.652c2.918,0.799%206.603,0.312%209.167,-1.235c0.809,-0.478%200.923,-1.297%200.342,-2.044c-1.816,-2.252%20-4.962,-4.059%20-7.88,-4.641zM42.219,11.662c-0.644,-0.882%20-1.567,-0.944%20-2.283,-0.114c-1.848,2.274%20-3.208,5.617%20-3.395,8.41c-0.249,2.865%200.727,6.385%202.326,8.888c0.539,0.778%201.369,0.881%202.126,0.176c2.139,-2.17%203.613,-5.513%203.675,-8.472c0.187,-2.835%20-0.799,-6.417%20-2.449,-8.888zM17.018,6.709c-1.131,-0.176%20-1.764,0.478%20-1.639,1.639c0.436,2.908%202.035,6.23%204.007,8.224c2.014,2.273%205.347,3.831%208.327,3.977c0.996,0.051%201.514,-0.488%201.4,-1.474c-0.322,-2.855%20-1.931,-6.188%20-4.007,-8.223c-2.025,-2.024%20-5.243,-3.696%20-8.088,-4.143z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/line.3.horizontal.svg b/src/sf-symbols/line.3.horizontal.svg
new file mode 100644
index 0000000..d10ff46
--- /dev/null
+++ b/src/sf-symbols/line.3.horizontal.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2098.752%2049.947'%3e%3cpath%20d='M3.668%2049.947H95.033A3.69%203.69%200%200%200%2098.753%2046.227%203.697%203.697%200%200%200%2095.032%2042.507H3.668C1.648%2042.508%200%2044.156%200%2046.228%200%2048.31%201.648%2049.947%203.668%2049.947ZM3.668%2028.693H95.033A3.697%203.697%200%200%200%2098.753%2024.973%203.697%203.697%200%200%200%2095.032%2021.254H3.668C1.648%2021.254%200%2022.902%200%2024.974%200%2027.047%201.648%2028.694%203.668%2028.694ZM3.668%207.388H95.033C97.105%207.388%2098.753%205.74%2098.753%203.72A3.697%203.697%200%200%200%2095.032%200H3.668C1.648%200%200%201.648%200%203.72A3.675%203.675%200%200%200%203.668%207.388Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/location.fill.svg b/src/sf-symbols/location.fill.svg
new file mode 100644
index 0000000..a2fd10a
--- /dev/null
+++ b/src/sf-symbols/location.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20115.952%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='115.952'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M15%2047.917L50.547%2048.083C51.283%2048.083%2051.532%2048.332%2051.532%2049.017L51.646%2084.355C51.646%2091.58%2060.415%2093.319%2063.658%2086.253L99.725%208.66C103.011%201.57%2097.372-3.115%2090.562%200.015L12.534%2036.216C6.253%2039.077%207.539%2047.866%2015%2047.917Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/macbook.gen2.svg b/src/sf-symbols/macbook.gen2.svg
new file mode 100644
index 0000000..bc02901
--- /dev/null
+++ b/src/sf-symbols/macbook.gen2.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20140.769%2079.424'%3e%3cpath%20d='M0%2073.887C0%2076.936%202.478%2079.424%205.475%2079.424H135.295C138.333%2079.424%20140.77%2076.936%20140.77%2073.887%20140.77%2070.797%20138.333%2068.309%20135.295%2068.309H124.59V10.349C124.59%203.52%20120.956%200%20114.136%200H26.633C20.176%200%2016.181%203.52%2016.181%2010.35V68.308H5.475C2.478%2068.309%200%2070.797%200%2073.887ZM24.086%2068.309V12.585C24.086%209.424%2025.615%207.843%2028.786%207.843H111.984C115.155%207.843%20116.735%209.423%20116.735%2012.585V68.31ZM55.65%207.843H56.894C57.622%207.843%2058.037%208.206%2058.037%209.026V9.617C58.037%2011.62%2059.323%2013.03%2061.429%2013.03H79.465C81.457%2013.03%2082.754%2011.62%2082.754%209.617V9.026C82.754%208.206%2083.169%207.843%2083.937%207.843H85.183V3.823H55.649Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/magnifyingglass.circle.fill.svg b/src/sf-symbols/magnifyingglass.circle.fill.svg
new file mode 100644
index 0000000..b2be244
--- /dev/null
+++ b/src/sf-symbols/magnifyingglass.circle.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='117.045'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M58.496%2094.513C85.719%2094.513%20108.321%2071.963%20108.321%2044.739C108.321%2017.516%2085.667-5.034%2058.444-5.034C31.262-5.034%208.723%2017.516%208.723%2044.739C8.723%2071.963%2031.314%2094.513%2058.496%2094.513Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M53.075%2059.894C41.592%2059.894%2032.145%2050.468%2032.145%2038.986C32.145%2027.452%2041.592%2018.005%2053.075%2018.005C64.598%2018.005%2073.993%2027.401%2073.993%2038.986C73.993%2043.266%2072.663%2047.317%2070.407%2050.63L83.375%2063.67C84.173%2064.479%2084.733%2065.567%2084.733%2066.718C84.733%2069.246%2083.034%2071.081%2080.618%2071.081C79.167%2071.081%2078.058%2070.573%2077.042%2069.494L64.148%2056.682C60.939%2058.689%2057.168%2059.894%2053.075%2059.894ZM53.085%2053.024C60.785%2053.024%2067.082%2046.665%2067.082%2038.976C67.082%2031.224%2060.785%2024.916%2053.085%2024.916C45.344%2024.916%2039.026%2031.276%2039.026%2038.976C39.026%2046.665%2045.344%2053.024%2053.085%2053.024Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/magnifyingglass.svg b/src/sf-symbols/magnifyingglass.svg
new file mode 100644
index 0000000..25be9ff
--- /dev/null
+++ b/src/sf-symbols/magnifyingglass.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.817%20120'%3e%3cpath%20d='M11.215,50.806c0,21.499%2017.472,38.919%2038.972,38.919c8.463,0%2016.231,-2.701%2022.617,-7.313l24.032,24.094c1.15,1.109%202.612,1.638%204.136,1.638c3.297,0%205.629,-2.499%205.629,-5.743c0,-1.555%20-0.612,-2.954%20-1.576,-4.032l-23.928,-23.981c5.038,-6.551%207.999,-14.693%207.999,-23.582c0,-21.49%20-17.42,-38.961%20-38.909,-38.961c-21.5,0%20-38.972,17.471%20-38.972,38.961zM19.575,50.806c0,-16.921%2013.681,-30.601%2030.612,-30.601c16.868,0%2030.6,13.68%2030.6,30.601c0,16.879%20-13.732,30.611%20-30.6,30.611c-16.931,0%20-30.612,-13.732%20-30.612,-30.611z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/message.svg b/src/sf-symbols/message.svg
new file mode 100644
index 0000000..04a8cfd
--- /dev/null
+++ b/src/sf-symbols/message.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2022.5098%2020.459'%3e%3cg%3e%3crect%20height='20.459'%20opacity='0'%20width='22.5098'%20x='0'%20y='0'%20/%3e%3cpath%20d='M4.23828%2020.459C5.55664%2020.459%208.25195%2019.1309%2010.2344%2017.7148C17.041%2017.9004%2022.1484%2013.9941%2022.1484%208.87695C22.1484%203.96484%2017.2266%200%2011.0742%200C4.92188%200%200%203.96484%200%208.87695C0%2012.0801%202.05078%2014.9219%205.13672%2016.3477C4.69727%2017.1973%203.87695%2018.3496%203.4375%2018.9258C2.91992%2019.6094%203.23242%2020.459%204.23828%2020.459ZM5.26367%2018.8379C5.18555%2018.8672%205.15625%2018.8086%205.20508%2018.7402C5.75195%2018.0664%206.5332%2017.0508%206.86523%2016.4258C7.13867%2015.918%207.07031%2015.4688%206.44531%2015.1758C3.37891%2013.75%201.62109%2011.4746%201.62109%208.87695C1.62109%204.87305%205.81055%201.61133%2011.0742%201.61133C16.3477%201.61133%2020.5371%204.87305%2020.5371%208.87695C20.5371%2012.8711%2016.3477%2016.1328%2011.0742%2016.1328C10.8789%2016.1328%2010.5762%2016.123%2010.1855%2016.1133C9.77539%2016.1133%209.46289%2016.2402%209.0918%2016.5332C7.89062%2017.4023%206.15234%2018.4766%205.26367%2018.8379Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/paintbrush.fill.svg b/src/sf-symbols/paintbrush.fill.svg
new file mode 100644
index 0000000..540e608
--- /dev/null
+++ b/src/sf-symbols/paintbrush.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.9817%2023.3408'%3e%3cg%3e%3crect%20height='23.3408'%20opacity='0'%20width='20.9817'%20x='0'%20y='0'/%3e%3cpath%20d='M1.15977%2022.0268C2.68321%2023.56%204.65587%2023.5796%206.15977%2022.0757C7.36095%2020.8745%208.52306%2018.1596%209.41173%2016.8022L11.775%2019.1753C12.4977%2019.9077%2013.3766%2019.9077%2014.0602%2019.2046L14.9%2018.3647C15.6129%2017.6421%2015.6031%2016.8218%2014.8707%2016.0893L7.10704%208.31589C6.36485%207.58347%205.53477%207.57371%204.82188%208.2866L3.98204%209.12644C3.27891%209.82957%203.27891%2010.6792%204.01134%2011.4116L6.37462%2013.7749C5.02696%2014.6636%202.32188%2015.8257%201.11095%2017.0268C-0.383194%2018.5307-0.373429%2020.5132%201.15977%2022.0268ZM3.7672%2020.7573C3.09337%2020.7573%202.55626%2020.2104%202.55626%2019.5464C2.55626%2018.8823%203.09337%2018.3452%203.7672%2018.3452C4.43126%2018.3452%204.96837%2018.8823%204.96837%2019.5464C4.96837%2020.2104%204.43126%2020.7573%203.7672%2020.7573ZM16.0328%2015.6401L19.8219%2011.8511C20.9059%2010.7671%2020.8766%209.478%2019.7731%208.35496L19.275%207.84714C18.2594%209.1655%2015.359%2010.7085%2014.8024%2010.1518C14.7145%2010.0639%2014.7047%209.88816%2014.8316%209.75144C16.0133%208.5698%2016.7848%207.41746%2016.9606%205.54246L11.941%200.51316C11.0133-0.414575%209.40196-0.11184%208.96251%201.64597C8.34727%204.14597%207.79063%205.60105%207.18516%206.80222Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/paintbrush.svg b/src/sf-symbols/paintbrush.svg
new file mode 100644
index 0000000..c0a6ecb
--- /dev/null
+++ b/src/sf-symbols/paintbrush.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20126.24%20120'%3e%3cpath%20d='M17.411,111.954c8.095,8.116%2017.675,8.189%2025.635,0.176c5.935,-5.882%2012.112,-19.815%2016.685,-25.237l10.371,10.423c3.848,3.889%208.432,3.878%2012.114,0.145l6.331,-6.372c3.733,-3.744%203.733,-8.163%20-0.156,-12.051l-38.116,-38.116c-3.847,-3.899%20-8.359,-3.941%20-12.102,-0.156l-6.321,6.331c-3.681,3.682%20-3.754,8.215%200.134,12.114l10.383,10.371c-5.371,4.562%20-19.304,10.75%20-25.187,16.674c-8.012,8.023%20-7.949,17.551%200.229,25.698zM38.637,51.419l3.961,-3.857c1.287,-1.288%202.638,-1.288%203.925,-0.052l35.28,35.28c1.236,1.235%201.225,2.627%20-0.062,3.925l-3.847,3.899c-1.298,1.349%20-2.742,1.339%20-4.029,0.051l-11.634,-11.726c-1.794,-1.846%20-4.127,-1.619%20-6.19,0.351c-3.858,3.774%20-10.485,19.824%20-18.248,27.546c-4.728,4.738%20-10.37,4.717%20-15.243,-0.094c-4.79,-4.852%20-4.811,-10.557%20-0.125,-15.232c7.774,-7.712%2023.875,-14.391%2027.598,-18.238c1.96,-2.074%202.197,-4.448%200.403,-6.19l-11.789,-11.686c-1.236,-1.287%20-1.236,-2.69%200,-3.977zM30.039,105.429c3.29,0%205.999,-2.72%205.999,-6.073c0,-3.29%20-2.709,-6.02%20-5.999,-6.02c-3.353,0%20-6.083,2.73%20-6.083,6.02c0,3.353%202.73,6.073%206.083,6.073zM87.833,83.895l23.161,-23.162c5.449,-5.448%205.345,-11.905%20-0.196,-17.497l-38.324,-38.376c-5.137,-5.137%20-14.006,-3.093%20-15.79,4.845c-4.535,19.728%20-4.738,22.069%20-12.251,32.539l5.636,5.574c8.539,-11.198%209.155,-16.607%2013.867,-34.084c0.645,-2.458%202.677,-3.101%204.329,-1.5l36.555,36.503c2.267,2.267%202.267,4.983%200.145,7.105l-22.582,22.593zM80.137,56.076c2.554,2.555%2016.822,-5.358%2021.744,-11.526l-10.727,-10.716c-0.924,8.66%20-5.42,14.89%20-10.893,20.373c-0.622,0.623%20-0.56,1.433%20-0.124,1.869z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/paperplane.fill.svg b/src/sf-symbols/paperplane.fill.svg
new file mode 100644
index 0000000..6fed1f0
--- /dev/null
+++ b/src/sf-symbols/paperplane.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2021.8262%2021.3965'%3e%3cg%3e%3crect%20height='21.3965'%20opacity='0'%20width='21.8262'%20x='0'%20y='0'/%3e%3cpath%20d='M12.2266%2021.3965C12.9297%2021.3965%2013.4277%2020.791%2013.7891%2019.8535L20.1855%203.14453C20.3613%202.69531%2020.459%202.29492%2020.459%201.96289C20.459%201.32812%2020.0684%200.9375%2019.4336%200.9375C19.1016%200.9375%2018.7012%201.03516%2018.252%201.21094L1.45508%207.64648C0.634766%207.95898%200%208.45703%200%209.16992C0%2010.0684%200.683594%2010.3711%201.62109%2010.6543L6.89453%2012.2559C7.51953%2012.4512%207.87109%2012.4316%208.29102%2012.041L19.0039%202.03125C19.1309%201.91406%2019.2773%201.93359%2019.375%202.02148C19.4727%202.11914%2019.4824%202.26562%2019.3652%202.39258L9.39453%2013.1445C9.01367%2013.5449%208.98438%2013.877%209.16992%2014.5312L10.7227%2019.6875C11.0156%2020.6738%2011.3184%2021.3965%2012.2266%2021.3965Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/paperplane.svg b/src/sf-symbols/paperplane.svg
new file mode 100644
index 0000000..ad9bf5a
--- /dev/null
+++ b/src/sf-symbols/paperplane.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20122.203%20120'%3e%3cpath%20d='M68.558,113.947c3.546,0%206.014,-3.006%207.85,-7.694l31.956,-83.512c0.892,-2.28%201.379,-4.27%201.379,-5.938c0,-3.142%20-1.981,-5.122%20-5.122,-5.122c-1.669,0%20-3.659,0.487%20-5.887,1.378l-83.978,32.153c-4.118,1.587%20-7.279,4.107%20-7.279,7.654c0,4.499%203.43,5.993%208.12,7.414l35.232,10.356l10.262,34.777c1.411,4.938%202.957,8.534%207.467,8.534zM53.015,63.207l-33.69,-10.293c-0.805,-0.23%20-1.014,-0.47%20-1.014,-0.773c0,-0.355%200.167,-0.615%200.919,-0.897l66.024,-25.01c3.875,-1.471%207.605,-3.377%2011.26,-5.065c-3.221,2.621%20-7.275,5.814%20-9.885,8.424zM69.334,103.206c-0.355,0%20-0.543,-0.302%20-0.824,-1.097l-10.304,-33.69l33.625,-33.573c2.651,-2.651%205.896,-6.746%208.486,-10.03c-1.699,3.655%20-3.646,7.427%20-5.128,11.395l-25.009,65.982c-0.282,0.752%20-0.491,1.013%20-0.846,1.013z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/person.circle.slash.svg b/src/sf-symbols/person.circle.slash.svg
new file mode 100644
index 0000000..d33aa10
--- /dev/null
+++ b/src/sf-symbols/person.circle.slash.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M7.36%2017l3.208%203.208A24.23%2024.23%200%20007.39%2032.235c0%2013.44%2010.93%2024.374%2024.366%2024.374%204.374%200%208.483-1.159%2012.035-3.186L47%2056.631A28.616%2028.616%200%200131.756%2061C15.874%2061%203%2048.122%203%2032.235A28.637%2028.637%200%20017.36%2017zM9.77%206.642l47.588%2047.606a2.204%202.204%200%20010%203.11%202.206%202.206%200%2001-3.111%200L6.632%209.753c-.829-.8-.856-2.283%200-3.11.826-.828%202.253-.886%203.138%200zM32.238%203C48.123%203%2061%2015.878%2061%2031.761c0%205.597-1.599%2010.82-4.364%2015.239l-3.208-3.209a24.221%2024.221%200%20003.182-12.03c0-13.437-10.934-24.37-24.372-24.37a24.223%2024.223%200%2000-12.03%203.18L17%207.363A28.628%2028.628%200%200132.238%203zm-7.492%2031L36%2045H18.373C17.44%2045%2017%2044.418%2017%2043.572c0-2.224%202.5-7.11%207.746-9.572zm6.915-20C35.733%2014%2039%2017.634%2039%2022.002c0%202.419-.874%204.529-2.281%205.998L26%2016.923C27.343%2015.145%2029.376%2014%2031.661%2014z'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/person.circle.svg b/src/sf-symbols/person.circle.svg
new file mode 100644
index 0000000..c95111f
--- /dev/null
+++ b/src/sf-symbols/person.circle.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='117.045'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M58.496%2094.513C85.719%2094.513%20108.321%2071.963%20108.321%2044.739C108.321%2017.516%2085.667-5.034%2058.444-5.034C31.262-5.034%208.723%2017.516%208.723%2044.739C8.723%2071.963%2031.314%2094.513%2058.496%2094.513ZM58.496%2086.245C35.51%2086.245%2017.083%2067.777%2017.083%2044.739C17.083%2021.702%2035.458%203.233%2058.444%203.233C81.482%203.233%20100.002%2021.702%20100.002%2044.739C100.002%2067.777%2081.534%2086.245%2058.496%2086.245Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M36.146%2069.507L80.784%2069.507C82.723%2069.507%2083.656%2068.181%2083.656%2066.397C83.656%2061.069%2075.588%2047.203%2058.444%2047.203C41.353%2047.203%2033.285%2061.069%2033.285%2066.397C33.285%2068.181%2034.217%2069.507%2036.146%2069.507ZM58.444%2043.226C65.401%2043.278%2070.988%2037.339%2070.988%2029.509C70.988%2022.189%2065.401%2016.104%2058.444%2016.104C51.539%2016.104%2045.953%2022.189%2045.953%2029.509C45.953%2037.339%2051.539%2043.174%2058.444%2043.226Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/person.crop.rectangle.line.fill.svg b/src/sf-symbols/person.crop.rectangle.line.fill.svg
new file mode 100644
index 0000000..7b46498
--- /dev/null
+++ b/src/sf-symbols/person.crop.rectangle.line.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M52.367%2024h-14.76C36.704%2024%2036%2023.342%2036%2022.51c0-.835.705-1.51%201.606-1.51h14.761c.905%200%201.633.675%201.633%201.51%200%20.832-.728%201.49-1.633%201.49m0%2010h-14.76C36.704%2034%2036%2033.35%2036%2032.52c0-.85.705-1.52%201.606-1.52h14.761c.905%200%201.633.67%201.633%201.52%200%20.83-.728%201.48-1.633%201.48m0%209h-14.76C36.704%2043%2036%2042.33%2036%2041.484c0-.83.705-1.484%201.606-1.484h14.761c.905%200%201.633.654%201.633%201.484C54%2042.33%2053.272%2043%2052.367%2043m-24.04%200H12.66C10.7%2043%2010%2042.459%2010%2041.401%2010%2038.288%2014.028%2034%2020.493%2034%2026.973%2034%2031%2038.288%2031%2041.401%2031%2042.46%2030.305%2043%2028.328%2043m-7.321-22C23.673%2021%2026%2023.31%2026%2026.425%2026%2029.58%2023.686%2032%2021.007%2032%2018.314%2032%2016%2029.58%2016%2026.452%2015.987%2023.359%2018.327%2021%2021.007%2021m32.158-10h-42.33C5.645%2011%203%2013.566%203%2018.645V45.33C3%2050.408%205.644%2053%2010.835%2053h42.33C58.355%2053%2061%2050.408%2061%2045.329V18.645C61%2013.592%2058.356%2011%2053.165%2011'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/person.crop.square.svg b/src/sf-symbols/person.crop.square.svg
new file mode 100644
index 0000000..71a5c04
--- /dev/null
+++ b/src/sf-symbols/person.crop.square.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20114.778%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='114.778'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M27.779%2089.768L87.003%2089.768C97.235%2089.768%20102.321%2084.661%20102.321%2074.605L102.321%2015.019C102.321%204.963%2097.235-0.144%2087.003-0.144L27.779-0.144C17.599-0.144%2012.461%204.942%2012.461%2015.019L12.461%2074.605C12.461%2084.682%2017.599%2089.768%2027.779%2089.768ZM27.904%2081.863C23.027%2081.863%2020.314%2079.305%2020.314%2074.222L20.314%2015.402C20.314%2010.371%2023.027%207.762%2027.904%207.762L86.878%207.762C91.713%207.762%2094.468%2010.371%2094.468%2015.402L94.468%2074.222C94.468%2079.305%2091.713%2081.863%2086.878%2081.863ZM23.812%2084.751L90.99%2084.751C88.151%2071.046%2074.346%2061.089%2057.427%2061.089C40.457%2061.089%2026.652%2071.046%2023.812%2084.751ZM57.417%2052.917C66.71%2053.021%2074.082%2045.109%2074.082%2034.664C74.082%2024.821%2066.71%2016.764%2057.417%2016.764C48.072%2016.764%2040.648%2024.821%2040.7%2034.664C40.752%2045.109%2048.072%2052.866%2057.417%2052.917Z'%20fill='currentColor'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/person.fill.viewfinder.svg b/src/sf-symbols/person.fill.viewfinder.svg
new file mode 100644
index 0000000..823c474
--- /dev/null
+++ b/src/sf-symbols/person.fill.viewfinder.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20120.477%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='120.477'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M16.388%2028.103C18.947%2028.103%2020.314%2026.673%2020.314%2024.114L20.314%2012.55C20.314%207.518%2023.027%204.909%2027.904%204.909L39.726%204.909C42.285%204.909%2043.705%203.49%2043.705%200.982C43.705-1.577%2042.285-2.996%2039.726-2.996L27.779-2.996C17.599-2.996%2012.461%202.09%2012.461%2012.167L12.461%2024.114C12.461%2026.673%2013.88%2028.103%2016.388%2028.103ZM104.089%2028.103C106.658%2028.103%20108.015%2026.673%20108.015%2024.114L108.015%2012.167C108.015%202.111%20102.94-2.996%2092.697-2.996L80.761-2.996C78.191-2.996%2076.771-1.577%2076.771%200.982C76.771%203.49%2078.191%204.909%2080.761%204.909L92.573%204.909C97.407%204.909%20100.172%207.518%20100.172%2012.55L100.172%2024.114C100.172%2026.673%20101.581%2028.103%20104.089%2028.103ZM27.779%2092.621L39.726%2092.621C42.285%2092.621%2043.705%2091.201%2043.705%2088.694C43.705%2086.135%2042.285%2084.715%2039.726%2084.715L27.904%2084.715C23.027%2084.715%2020.314%2082.106%2020.314%2077.074L20.314%2065.562C20.314%2062.951%2018.895%2061.521%2016.388%2061.521C13.829%2061.521%2012.461%2062.951%2012.461%2065.562L12.461%2077.458C12.461%2087.535%2017.599%2092.621%2027.779%2092.621ZM80.761%2092.621L92.697%2092.621C102.94%2092.621%20108.015%2087.514%20108.015%2077.458L108.015%2065.562C108.015%2062.951%20106.606%2061.521%20104.089%2061.521C101.53%2061.521%20100.172%2062.951%20100.172%2065.562L100.172%2077.074C100.172%2082.106%2097.407%2084.715%2092.573%2084.715L80.761%2084.715C78.191%2084.715%2076.771%2086.135%2076.771%2088.694C76.771%2091.201%2078.191%2092.621%2080.761%2092.621Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M35.707%2073.469L84.77%2073.469C86.874%2073.469%2087.91%2072.081%2087.91%2070.069C87.91%2064.232%2079.084%2048.985%2060.207%2048.985C41.381%2048.985%2032.514%2064.232%2032.514%2070.069C32.514%2072.081%2033.55%2073.469%2035.707%2073.469ZM60.207%2044.582C67.849%2044.686%2073.996%2038.134%2073.996%2029.547C73.996%2021.479%2067.849%2014.771%2060.207%2014.771C52.565%2014.771%2046.428%2021.479%2046.428%2029.547C46.428%2038.134%2052.565%2044.53%2060.207%2044.582Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/photo.fill.on.rectangle.fill.svg b/src/sf-symbols/photo.fill.on.rectangle.fill.svg
new file mode 100644
index 0000000..70739bd
--- /dev/null
+++ b/src/sf-symbols/photo.fill.on.rectangle.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20146.34%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='146.34'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M27.779%2071.603L94.487%2071.603C104.615%2071.603%20109.805%2066.548%20109.805%2056.492L109.805%2010.466C109.805%200.4%20104.615-4.696%2094.487-4.696L27.779-4.696C17.547-4.696%2012.461%200.379%2012.461%2010.466L12.461%2056.492C12.461%2066.569%2017.547%2071.603%2027.779%2071.603Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M51.902%20100.884L118.558%20100.884C132.45%20100.884%20140.554%2092.864%20140.554%2079.096L140.554%2033.018C140.554%2019.261%20132.45%2011.229%20118.558%2011.229L51.902%2011.229C38.009%2011.229%2029.906%2019.229%2029.906%2033.018L29.906%2079.096C29.906%2092.885%2038.009%20100.884%2051.902%20100.884Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3cpath%20d='M51.902%2094.207L118.558%2094.207C128.738%2094.207%20133.876%2089.11%20133.876%2079.096L133.876%2033.018C133.876%2022.962%20128.738%2017.907%20118.558%2017.907L51.902%2017.907C41.659%2017.907%2036.584%2022.941%2036.584%2033.018L36.584%2079.096C36.584%2089.183%2041.659%2094.207%2051.902%2094.207ZM52.026%2086.364C47.15%2086.364%2044.427%2083.744%2044.427%2078.671L44.427%2074.189L55.477%2064.529C57.357%2062.929%2059.184%2062.109%2061.075%2062.109C63.195%2062.109%2065.146%2062.888%2067.026%2064.591L74.875%2071.673L94.575%2054.208C96.632%2052.421%2098.781%2051.621%20101.171%2051.621C103.582%2051.621%20105.928%2052.483%20107.809%2054.27L126.022%2071.494L126.022%2078.671C126.022%2083.744%20123.267%2086.364%20118.433%2086.364Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M71.348%2055.327C65.647%2055.327%2061.025%2050.633%2061.025%2044.88C61.025%2039.252%2065.647%2034.547%2071.348%2034.547C77.049%2034.547%2081.681%2039.252%2081.681%2044.88C81.681%2050.633%2077.049%2055.327%2071.348%2055.327Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/plus.heavy.svg b/src/sf-symbols/plus.heavy.svg
new file mode 100644
index 0000000..a81c1c2
--- /dev/null
+++ b/src/sf-symbols/plus.heavy.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='278.636%20115.895%2014%2014'%20width='14'%20height='14'%3e%3cg%20fill='%23C7C7CC'%3e%3cpath%20d='M278.636%20120.895h14v4h-14z'/%3e%3cpath%20d='M287.636%20115.895v14h-4v-14'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/quote.bubble.fill.svg b/src/sf-symbols/quote.bubble.fill.svg
new file mode 100644
index 0000000..ad1c656
--- /dev/null
+++ b/src/sf-symbols/quote.bubble.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2021.8848%2021.5723'%3e%3cg%3e%3crect%20height='21.5723'%20opacity='0'%20width='21.8848'%20x='0'%20y='0'/%3e%3cpath%20d='M21.5234%205.78125L21.5234%2013.2617C21.5234%2016.123%2019.9609%2017.7246%2017.0508%2017.7246L10.4492%2017.7246L6.92383%2020.9473C6.46484%2021.377%206.18164%2021.5723%205.80078%2021.5723C5.24414%2021.5723%204.93164%2021.1719%204.93164%2020.5664L4.93164%2017.7246L4.47266%2017.7246C1.5625%2017.7246%200%2016.1328%200%2013.2617L0%205.78125C0%202.91016%201.5625%201.30859%204.47266%201.30859L17.0508%201.30859C19.9609%201.30859%2021.5234%202.91992%2021.5234%205.78125ZM5.89844%208.45703C5.89844%209.59961%206.61133%2010.4883%207.75391%2010.4883C8.17383%2010.4883%208.59375%2010.4199%208.85742%2010.0879L8.93555%2010.0879C8.56445%2010.918%207.79297%2011.4551%207.13867%2011.6309C6.75781%2011.7285%206.65039%2011.8848%206.65039%2012.1289C6.65039%2012.3828%206.86523%2012.5977%207.14844%2012.5977C8.16406%2012.5977%2010.2051%2011.3867%2010.2051%208.82812C10.2051%207.46094%209.32617%206.41602%208.01758%206.41602C6.80664%206.41602%205.89844%207.25586%205.89844%208.45703ZM11.3379%208.45703C11.3379%209.59961%2012.0508%2010.4883%2013.1836%2010.4883C13.6133%2010.4883%2014.0332%2010.4199%2014.2969%2010.0879L14.375%2010.0879C14.0039%2010.918%2013.2324%2011.4551%2012.5684%2011.6309C12.207%2011.7285%2012.0898%2011.8848%2012.0898%2012.1289C12.0898%2012.3828%2012.3047%2012.5977%2012.5879%2012.5977C13.6035%2012.5977%2015.6445%2011.3867%2015.6445%208.82812C15.6445%207.46094%2014.7559%206.41602%2013.4473%206.41602C12.2363%206.41602%2011.3379%207.25586%2011.3379%208.45703Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/rocket.fill.svg b/src/sf-symbols/rocket.fill.svg
new file mode 100644
index 0000000..b47fdeb
--- /dev/null
+++ b/src/sf-symbols/rocket.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.9044%2020.6543'%3e%3cg%3e%3crect%20height='20.6543'%20opacity='0'%20width='20.9044'%20x='0'%20y='0'/%3e%3cpath%20d='M8.11824%2020.5078C9.14363%2020.2832%2010.96%2019.6484%2011.8585%2018.9551C13.1573%2017.9492%2013.7823%2016.8457%2013.6846%2015.0586L13.6553%2014.1602C14.4854%2013.5938%2015.2862%2012.9004%2016.0577%2012.1094C18.7725%209.32617%2020.5401%204.90234%2020.5401%201.04492C20.5401%200.458984%2020.0714%200%2019.4854%200C15.6378%200%2011.2139%201.76758%208.43074%204.47266C7.61042%205.2832%206.92683%206.06445%206.37019%206.88477L5.48152%206.85547C3.76277%206.77734%202.62019%207.31445%201.58503%208.67188C0.891674%209.58984%200.247143%2011.3867%200.0225333%2012.4219C-0.123951%2013.1348%200.471752%2013.457%200.999096%2013.3203C2.15144%2013.0957%203.39167%2012.5977%204.39753%2012.6758L4.39753%2013.3105C4.378%2013.7598%204.4366%2014.043%204.77839%2014.3945L6.14558%2015.752C6.50691%2016.1035%206.78035%2016.1719%207.22956%2016.1523L7.85456%2016.1328C7.96199%2017.168%207.48347%2018.3789%207.2198%2019.5312C7.03425%2020.1953%207.503%2020.6445%208.11824%2020.5078ZM13.8897%208.71094C12.7471%208.71094%2011.8194%207.79297%2011.8194%206.64062C11.8194%205.48828%2012.7374%204.56055%2013.8897%204.56055C15.0421%204.56055%2015.9698%205.48828%2015.9698%206.64062C15.9698%207.7832%2015.0421%208.71094%2013.8897%208.71094ZM2.59089%2019.1016L4.24128%2019.0527C4.77839%2019.043%205.20808%2018.877%205.55964%2018.5254C5.9991%2018.0859%206.11628%2017.4609%206.03816%2017.0312C5.97956%2016.6797%205.628%2016.582%205.47175%2016.8652C5.40339%2016.9629%205.3448%2017.0605%205.22761%2017.168C4.98347%2017.4219%204.79792%2017.4805%204.48542%2017.5L3.51863%2017.5586C3.36238%2017.5586%203.24519%2017.4414%203.24519%2017.2949L3.30378%2016.3184C3.32331%2015.9961%203.39167%2015.8105%203.63581%2015.5859C3.753%2015.4688%203.85066%2015.4004%203.94831%2015.3418C4.22175%2015.2148%204.1241%2014.8145%203.7823%2014.7656C3.33308%2014.6973%202.72761%2014.8145%202.28816%2015.2539C1.92683%2015.625%201.76081%2016.0352%201.75105%2016.5625L1.70222%2018.2129C1.68269%2018.7598%202.05378%2019.1211%202.59089%2019.1016Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/rocket.svg b/src/sf-symbols/rocket.svg
new file mode 100644
index 0000000..a8f45a2
--- /dev/null
+++ b/src/sf-symbols/rocket.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20129.904%20120'%3e%3cpath%20d='M19.837,77.795c5.799,-1.154%2011.113,-3.155%2015.215,-3.114l0.176,-7.274c-5.013,0.062%20-11.872,2.073%20-15.238,3.011c-1.013,0.345%20-1.41,-0.105%20-1.18,-0.918c1.392,-4.814%204.135,-12.167%206.566,-15.346c4.383,-5.693%208.83,-7.739%2015.863,-7.48l1.284,0.104l4.91,-7.046l-6.039,-0.217c-9.312,-0.426%20-15.904,2.657%20-21.78,10.142c-3.208,4.287%20-6.841,13.123%20-8.119,19.356c-1.472,6.936%203.204,9.895%208.342,8.782zM53.611,28.754c-14.432,14.007%20-20.797,28.2%20-20.797,46.391c0,3.03%200.757,4.856%202.843,7.014l6.739,6.74c2.086,2.075%203.974,2.802%207.015,2.78c18.118,-0.073%2032.405,-6.302%2046.369,-20.735c13.685,-14.121%2022.916,-36.632%2022.916,-55.853c0,-5.631%20-3.662,-9.283%20-9.242,-9.283c-19.221,0%20-41.743,9.261%20-55.843,22.946zM58.647,33.925c12.816,-12.515%2033.451,-20.895%2051.221,-20.947c1.001,0%201.699,0.709%201.699,1.647c0,17.77%20-8.494,38.447%20-20.936,51.263c-12.774,13.054%20-25.071,18.548%20-40.93,18.548c-1.216,0%20-1.78,-0.25%20-2.716,-1.155l-5.732,-5.732c-0.894,-0.946%20-1.185,-1.519%20-1.185,-2.705c0,-15.797%205.546,-28.146%2018.579,-40.919zM77.799,82.064l0.103,2.135c0.208,6.14%20-1.87,10.587%20-7.511,14.919c-3.178,2.472%20-10.543,5.141%20-15.251,6.534c-0.919,0.334%20-1.347,-0.137%20-1.065,-1.097c0.876,-3.366%203.158,-10.225%203.053,-15.249l-7.315,0.177c0.177,4.112%20-1.888,9.436%20-3.052,15.174c-1.197,5.179%201.846,9.814%208.761,8.383c6.243,-1.288%2015.079,-4.912%2019.304,-8.161c7.496,-5.658%2010.578,-12.312%2010.153,-21.79l-0.166,-5.987zM22.585,85.715c-1.795,1.909%20-2.646,3.944%20-2.656,6.549l-0.25,8.266c-0.01,2.749%201.784,4.543%204.492,4.439l8.213,-0.208c2.647,-0.01%204.734,-0.851%206.602,-2.646c2.231,-2.253%202.772,-5.356%202.418,-7.487c-0.26,-1.745%20-2.128,-2.304%20-2.885,-0.883c-0.282,0.522%20-0.645,1.009%20-1.185,1.56c-1.234,1.234%20-2.116,1.546%20-3.735,1.66l-4.838,0.249c-0.725,0%20-1.367,-0.58%20-1.367,-1.275l0.29,-4.837c0.114,-1.568%200.488,-2.553%201.712,-3.673c0.55,-0.561%201.079,-0.945%201.57,-1.248c1.359,-0.726%200.851,-2.646%20-0.883,-2.885c-2.173,-0.384%20-5.266,0.25%20-7.498,2.419zM82.779,52.273c5.804,0%2010.622,-4.755%2010.622,-10.684c0,-5.803%20-4.777,-10.58%20-10.622,-10.58c-5.876,0%20-10.642,4.725%20-10.642,10.58c0,5.991%204.766,10.684%2010.642,10.684z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/square.and.arrow.up.svg b/src/sf-symbols/square.and.arrow.up.svg
new file mode 100644
index 0000000..1499d56
--- /dev/null
+++ b/src/sf-symbols/square.and.arrow.up.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2086.639%20110.16'%3e%3cpath%20d='M15.318%20110.16H71.32C81.553%20110.16%2086.64%20105.053%2086.64%2094.997V46.347C86.64%2036.28%2081.553%2031.182%2071.321%2031.182H57.736V39.078H71.196C75.98%2039.078%2078.787%2041.698%2078.787%2046.73V94.614C78.786%2099.697%2075.979%20102.254%2071.196%20102.254H15.444C10.566%20102.255%207.853%2099.698%207.853%2094.615V46.73C7.853%2041.698%2010.566%2039.078%2015.443%2039.078H28.924V31.183H15.318C5.138%2031.183%200%2036.26%200%2046.346V94.997C0%20105.074%205.138%20110.16%2015.318%20110.16Z'%20fill='%23000000'%3e%3c/path%3e%3cpath%20d='M43.294%2071.932C45.418%2071.932%2047.22%2070.18%2047.22%2068.109V18.013L46.9%2010.673%2050.17%2014.162%2057.573%2022.03A3.662%203.662%200%200%200%2060.215%2023.19C62.266%2023.19%2063.81%2021.75%2063.81%2019.792%2063.81%2018.704%2063.375%2017.948%2062.608%2017.232L46.122%201.306C45.148%200.332%2044.33%200%2043.294%200%2042.309%200%2041.48%200.332%2040.465%201.305L23.97%2017.233C23.254%2017.948%2022.819%2018.704%2022.819%2019.792%2022.819%2021.75%2024.3%2023.19%2026.362%2023.19%2027.295%2023.19%2028.342%2022.796%2029.046%2022.03L36.458%2014.162%2039.74%2010.662%2039.419%2018.013V68.11C39.419%2070.18%2041.222%2071.932%2043.294%2071.932Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/square.grid.2x2.fill.svg b/src/sf-symbols/square.grid.2x2.fill.svg
new file mode 100644
index 0000000..1f535c9
--- /dev/null
+++ b/src/sf-symbols/square.grid.2x2.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20114.778%20120'%3e%3cpath%20d='M70.783,104.997h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.442%20-3.123,-9.502%20-9.346,-9.502h-22.192c-6.223,0%20-9.346,3.06%20-9.346,9.502v21.829c0,6.452%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M21.807,104.997h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.442%20-3.123,-9.502%20-9.346,-9.502h-22.192c-6.223,0%20-9.346,3.06%20-9.346,9.502v21.829c0,6.452%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M70.783,56.021h22.192c6.223,0%209.346,-3.111%209.346,-9.553v-21.829c0,-6.453%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.554v21.829c0,6.442%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M21.807,56.021h22.192c6.223,0%209.346,-3.111%209.346,-9.553v-21.829c0,-6.453%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.554v21.829c0,6.442%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/square.grid.2x2.svg b/src/sf-symbols/square.grid.2x2.svg
new file mode 100644
index 0000000..c6e9045
--- /dev/null
+++ b/src/sf-symbols/square.grid.2x2.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20114.778%20120'%3e%3cpath%20d='M70.783,104.976h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.443%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.111%20-9.346,9.554v21.829c0,6.452%203.123,9.553%209.346,9.553zM70.908,98.054c-1.738,0%20-2.601,-0.863%20-2.601,-2.601v-21.838c0,-1.79%200.863,-2.653%202.601,-2.653h21.994c1.738,0%202.549,0.863%202.549,2.653v21.838c0,1.738%20-0.811,2.601%20-2.549,2.601z'%20fill='currentColor'%20opacity='1'/%3e%3cpath%20d='M21.807,104.976h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.443%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.111%20-9.346,9.554v21.829c0,6.452%203.123,9.553%209.346,9.553zM21.88,98.054c-1.686,0%20-2.549,-0.863%20-2.549,-2.601v-21.838c0,-1.79%200.863,-2.653%202.549,-2.653h21.994c1.738,0%202.601,0.863%202.601,2.653v21.838c0,1.738%20-0.863,2.601%20-2.601,2.601z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M70.783,55.948h22.192c6.223,0%209.346,-3.059%209.346,-9.501v-21.83c0,-6.452%20-3.123,-9.553%20-9.346,-9.553h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.553v21.83c0,6.442%203.123,9.501%209.346,9.501zM70.908,49.078c-1.738,0%20-2.601,-0.863%20-2.601,-2.652v-21.839c0,-1.738%200.863,-2.601%202.601,-2.601h21.994c1.738,0%202.549,0.863%202.549,2.601v21.839c0,1.789%20-0.811,2.652%20-2.549,2.652z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M21.807,55.948h22.192c6.223,0%209.346,-3.059%209.346,-9.501v-21.83c0,-6.452%20-3.123,-9.553%20-9.346,-9.553h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.553v21.83c0,6.442%203.123,9.501%209.346,9.501zM21.88,49.078c-1.686,0%20-2.549,-0.863%20-2.549,-2.652v-21.839c0,-1.738%200.863,-2.601%202.549,-2.601h21.994c1.738,0%202.601,0.863%202.601,2.601v21.839c0,1.789%20-0.863,2.652%20-2.601,2.652z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/star.fill.svg b/src/sf-symbols/star.fill.svg
new file mode 100644
index 0000000..029c0da
--- /dev/null
+++ b/src/sf-symbols/star.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2022.0527%2022.1191'%3e%3cg%3e%3crect%20height='22.1191'%20opacity='0'%20width='22.0527'%20x='0'%20y='0'/%3e%3cpath%20d='M4.16109%2020.5469C4.56149%2020.8594%205.0693%2020.752%205.67477%2020.3125L10.8408%2016.5137L16.0166%2020.3125C16.622%2020.752%2017.1201%2020.8594%2017.5302%2020.5469C17.9306%2020.2441%2018.0185%2019.7461%2017.7744%2019.0332L15.7334%2012.959L20.9482%209.20898C21.5537%208.7793%2021.7978%208.33008%2021.6416%207.8418C21.4853%207.37305%2021.0263%207.14844%2020.2744%207.14844L13.8779%207.14844L11.9345%201.08398C11.7002%200.361328%2011.3486%200%2010.8408%200C10.3427%200%209.99117%200.361328%209.7568%201.08398L7.81344%207.14844L1.41695%207.14844C0.665001%207.14844%200.206017%207.37305%200.0497668%207.8418C-0.116249%208.33008%200.137657%208.7793%200.743126%209.20898L5.95797%2012.959L3.91695%2019.0332C3.67281%2019.7461%203.7607%2020.2441%204.16109%2020.5469Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/star.svg b/src/sf-symbols/star.svg
new file mode 100644
index 0000000..b6a5670
--- /dev/null
+++ b/src/sf-symbols/star.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20128.346%20120'%3e%3cpath%20d='M30.733,107.427c2.063,1.555%204.561,0.985%207.609,-1.223l25.822,-18.946l25.874,18.946c3.007,2.208%205.504,2.778%207.567,1.223c2,-1.503%202.426,-4.042%201.182,-7.567l-10.158,-30.382l26.05,-18.728c3.048,-2.105%204.291,-4.396%203.462,-6.831c-0.777,-2.343%20-3.088,-3.545%20-6.831,-3.494l-31.971,0.218l-9.733,-30.548c-1.141,-3.566%20-2.882,-5.38%20-5.442,-5.38c-2.498,0%20-4.25,1.814%20-5.432,5.38l-9.733,30.548l-31.971,-0.218c-3.743,-0.051%20-6.013,1.151%20-6.831,3.494c-0.777,2.435%200.455,4.726%203.462,6.831l26.05,18.728l-10.157,30.382c-1.245,3.525%20-0.819,6.064%201.181,7.567zM37.777,97.794c-0.084,-0.125%20-0.063,-0.208%200.01,-0.489l9.733,-27.908c0.633,-1.887%200.27,-3.391%20-1.441,-4.594l-24.3,-16.771c-0.219,-0.197%20-0.302,-0.28%20-0.24,-0.416c0.062,-0.135%200.156,-0.135%200.437,-0.135l29.559,0.539c2.001,0.052%203.267,-0.768%203.899,-2.759l8.449,-28.28c0.073,-0.281%200.146,-0.375%200.281,-0.375c0.135,0%200.208,0.094%200.291,0.375l8.449,28.28c0.633,1.991%201.95,2.811%203.951,2.759l29.507,-0.539c0.281,0%200.375,0%200.437,0.135c0.063,0.136%20-0.021,0.219%20-0.239,0.416l-24.301,16.771c-1.711,1.203%20-2.074,2.707%20-1.4,4.594l9.692,27.908c0.073,0.281%200.094,0.364%200.01,0.489c-0.083,0.124%20-0.218,0%20-0.437,-0.146l-23.492,-17.889c-1.556,-1.255%20-3.318,-1.255%20-4.926,0l-23.44,17.889c-0.219,0.146%20-0.354,0.27%20-0.489,0.146z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/text.rectangle.page.fill.svg b/src/sf-symbols/text.rectangle.page.fill.svg
new file mode 100644
index 0000000..12144f0
--- /dev/null
+++ b/src/sf-symbols/text.rectangle.page.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20107.046%20120'%3e%3cpath%20d='M34.149,31.913c-1.824,0%20-3.161,-1.389%20-3.161,-3.151c0,-1.72%201.337,-3.098%203.161,-3.098h40.601c1.783,0%203.12,1.378%203.12,3.098c0,1.762%20-1.337,3.151%20-3.12,3.151zM34.149,46.539c-1.824,0%20-3.161,-1.379%20-3.161,-3.151c0,-1.71%201.337,-3.047%203.161,-3.047h23.795c1.835,0%203.161,1.337%203.161,3.047c0,1.772%20-1.326,3.151%20-3.161,3.151zM34.97,96.294c-4.299,0%20-6.728,-2.314%20-6.728,-6.717v-27.944c0,-4.454%202.429,-6.717%206.728,-6.717h37.107c4.454,0%206.728,2.263%206.728,6.717v27.944c0,4.403%20-2.274,6.717%20-6.728,6.717zM12.461,97.004c0,10.232%205.034,15.318%2015.111,15.318h51.851c10.087,0%2015.163,-5.086%2015.163,-15.318v-74.03c0,-10.18%20-5.076,-15.317%20-15.163,-15.317h-51.851c-10.077,0%20-15.111,5.137%20-15.111,15.317z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/text.rectangle.page.svg b/src/sf-symbols/text.rectangle.page.svg
new file mode 100644
index 0000000..f1192ef
--- /dev/null
+++ b/src/sf-symbols/text.rectangle.page.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20107.046%20120'%3e%3cpath%20d='M34.77,32.741h39.37c1.72,0%203.005,-1.337%203.005,-3.058c0,-1.658%20-1.285,-2.943%20-3.005,-2.943h-39.37c-1.783,0%20-3.109,1.285%20-3.109,2.943c0,1.721%201.326,3.058%203.109,3.058zM34.77,46.901h23.071c1.731,0%203.057,-1.337%203.057,-3.047c0,-1.669%20-1.326,-2.943%20-3.057,-2.943h-23.071c-1.783,0%20-3.109,1.274%20-3.109,2.943c0,1.71%201.326,3.047%203.109,3.047zM35.539,95.208h36.02c4.299,0%206.521,-2.212%206.521,-6.511v-27.116c0,-4.299%20-2.222,-6.51%20-6.521,-6.51h-36.02c-4.144,0%20-6.573,2.211%20-6.573,6.51v27.116c0,4.299%202.429,6.511%206.573,6.511zM12.461,97.004c0,10.232%205.034,15.318%2015.111,15.318h51.851c10.087,0%2015.163,-5.086%2015.163,-15.318v-74.03c0,-10.18%20-5.076,-15.317%20-15.163,-15.317h-51.851c-10.077,0%20-15.111,5.137%20-15.111,15.317zM20.314,96.88v-73.781c0,-4.876%202.61,-7.589%207.693,-7.589h51.033c5.031,0%207.651,2.713%207.651,7.589v73.781c0,4.876%20-2.62,7.589%20-7.651,7.589h-51.033c-5.083,0%20-7.693,-2.713%20-7.693,-7.589z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/textformat.size.svg b/src/sf-symbols/textformat.size.svg
new file mode 100644
index 0000000..b2851a7
--- /dev/null
+++ b/src/sf-symbols/textformat.size.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2022.6074%2014.4434'%3e%3cg%3e%3crect%20height='14.4434'%20opacity='0'%20width='22.6074'%20x='0'%20y='0'/%3e%3cpath%20d='M11.2109%2014.4238C11.7188%2014.4238%2011.9824%2014.2285%2012.168%2013.6816L13.4277%2010.2344L19.1895%2010.2344L20.4492%2013.6816C20.6348%2014.2285%2020.8887%2014.4238%2021.3965%2014.4238C21.9141%2014.4238%2022.2461%2014.1113%2022.2461%2013.623C22.2461%2013.457%2022.2168%2013.3008%2022.1387%2013.0957L17.5586%200.898438C17.334%200.302734%2016.9336%200%2016.3086%200C15.7031%200%2015.293%200.292969%2015.0781%200.888672L10.498%2013.1055C10.4199%2013.3105%2010.3906%2013.4668%2010.3906%2013.6328C10.3906%2014.1211%2010.7031%2014.4238%2011.2109%2014.4238ZM13.9062%208.75L16.2793%202.17773L16.3281%202.17773L18.7012%208.75Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M0.810547%2014.4238C1.21094%2014.4238%201.47461%2014.2188%201.63086%2013.7402L2.44141%2011.3477L6.25977%2011.3477L7.07031%2013.7402C7.22656%2014.2383%207.49023%2014.4238%207.90039%2014.4238C8.38867%2014.4238%208.7207%2014.1211%208.7207%2013.6816C8.7207%2013.4863%208.67188%2013.3008%208.59375%2013.0957L5.55664%205.0293C5.33203%204.43359%204.92188%204.12109%204.3457%204.12109C3.7793%204.12109%203.36914%204.41406%203.14453%205.0293L0.107422%2013.0957C0.0292969%2013.2812%200%2013.4766%200%2013.6816C0%2014.1309%200.3125%2014.4238%200.810547%2014.4238ZM2.87109%2010.0781L4.23828%206.00586L4.46289%206.00586L5.83008%2010.0781Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/tv.svg b/src/sf-symbols/tv.svg
new file mode 100644
index 0000000..81cb112
--- /dev/null
+++ b/src/sf-symbols/tv.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20122.045%2097.575'%3e%3cpath%20d='M12.523%2081.04H109.523C117.75%2081.04%20122.044%2076.733%20122.044%2068.516V12.575C122.045%204.305%20117.75%200%20109.522%200H12.522C4.296%200%200%204.306%200%2012.575V68.517C0%2076.734%204.295%2081.039%2012.523%2081.039ZM36.49%2097.574H85.555A3.93%203.93%200%200%200%2089.492%2093.66C89.492%2091.411%2087.752%2089.681%2085.555%2089.681H36.49C34.293%2089.68%2032.553%2091.41%2032.553%2093.659A3.93%203.93%200%200%200%2036.49%2097.575ZM12.658%2073.186C9.486%2073.186%207.853%2071.564%207.853%2068.392V12.699C7.853%209.475%209.486%207.854%2012.658%207.854H109.388C112.558%207.854%20114.19%209.475%20114.19%2012.699V68.392C114.191%2071.564%20112.56%2073.186%20109.387%2073.186Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/visionpro.svg b/src/sf-symbols/visionpro.svg
new file mode 100644
index 0000000..ecc7268
--- /dev/null
+++ b/src/sf-symbols/visionpro.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20171.057%20120'%3e%3cpath%20d='M45.735,99.548c-19.145,0%20-33.274,-16.993%20-33.274,-39.752c0,-37.652%2033.058,-41.401%2073.067,-41.401c40.01,0%2073.068,3.719%2073.068,41.401c0,22.759%20-14.122,39.752%20-33.238,39.752c-19.916,0%20-29.459,-16.541%20-39.83,-16.541c-10.378,0%20-19.906,16.541%20-39.793,16.541zM125.467,88.987c13.801,0%2022.622,-11.339%2022.622,-29.191c0,-28.302%20-23.431,-30.902%20-62.561,-30.902c-39.13,0%20-62.561,2.637%20-62.561,30.902c0,17.852%208.821,29.191%2022.659,29.191c17.367,0%2023.469,-16.523%2039.902,-16.523c16.426,0%2022.573,16.523%2039.939,16.523z'%20fill='currentColor'%20opacity='1'/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/voice.control.svg b/src/sf-symbols/voice.control.svg
new file mode 100644
index 0000000..81b68a3
--- /dev/null
+++ b/src/sf-symbols/voice.control.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2024.8364%2018.0957'%3e%3cg%3e%3crect%20height='18.0957'%20opacity='0'%20width='24.8364'%20x='0'%20y='0'/%3e%3cpath%20d='M19.7962%2011.2207C19.7962%2011.748%2020.2161%2011.9727%2020.6751%2011.6895L24.1419%209.58008C24.5911%209.30664%2024.5813%208.78906%2024.1419%208.51562L20.6751%206.38672C20.2161%206.10352%2019.7962%206.34766%2019.7962%206.86523Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M16.427%2014.5898C16.8567%2014.5898%2017.1887%2014.248%2017.1887%2013.7988L17.1887%204.29688C17.1887%203.83789%2016.8567%203.48633%2016.427%203.48633C15.9778%203.48633%2015.6165%203.83789%2015.6165%204.29688L15.6165%2013.7988C15.6165%2014.248%2015.9778%2014.5898%2016.427%2014.5898Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M12.2376%2018.0859C12.677%2018.0859%2013.0188%2017.7539%2013.0188%2017.3145L13.0188%200.820312C13.0188%200.351562%2012.677%200%2012.2376%200C11.7981%200%2011.4563%200.351562%2011.4563%200.820312L11.4563%2017.3145C11.4563%2017.7539%2011.7981%2018.0859%2012.2376%2018.0859Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M8.04811%2014.5898C8.50709%2014.5898%208.85865%2014.248%208.85865%2013.7988L8.85865%204.29688C8.85865%203.83789%208.50709%203.48633%208.04811%203.48633C7.61842%203.48633%207.28639%203.83789%207.28639%204.29688L7.28639%2013.7988C7.28639%2014.248%207.61842%2014.5898%208.04811%2014.5898Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M4.67897%2011.2207L4.67897%206.86523C4.67897%206.34766%204.25904%206.10352%203.80006%206.38672L0.333262%208.51562C-0.106191%208.78906-0.115957%209.30664%200.333262%209.58008L3.80006%2011.6895C4.25904%2011.9727%204.67897%2011.748%204.67897%2011.2207Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/voiceover.svg b/src/sf-symbols/voiceover.svg
new file mode 100644
index 0000000..72be6eb
--- /dev/null
+++ b/src/sf-symbols/voiceover.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='1.3379%201.4356%2023.623%2020.0292'%3e%3cg%3e%3cpath%20d='M24.9609%2011.3965C24.9609%2016.8945%2020.498%2021.3574%2015%2021.3574C13.6104%2021.3574%2012.287%2021.0723%2011.086%2020.5558C11.4546%2020.128%2011.7649%2019.6495%2012.0012%2019.1314C12.9283%2019.4996%2013.9407%2019.6973%2015%2019.6973C19.5898%2019.6973%2023.3008%2015.9863%2023.3008%2011.3965C23.3008%206.80664%2019.5898%203.0957%2015%203.0957C10.8072%203.0957%207.34774%206.19246%206.79024%2010.2302C6.62899%2010.2116%206.46462%2010.2051%206.29883%2010.2051C5.88883%2010.2051%205.48727%2010.2453%205.09877%2010.3246C5.63187%205.32952%209.86434%201.43555%2015%201.43555C20.498%201.43555%2024.9609%205.89844%2024.9609%2011.3965ZM20.0586%208.23242C20.0586%208.49609%2019.8926%208.7207%2019.6387%208.78906C19.3262%208.88672%2017.0996%209.14062%2016.8262%209.18945C16.543%209.24805%2016.3867%209.48242%2016.3867%209.81445C16.3867%2010.3223%2016.3867%2011.9434%2016.4941%2012.6855C16.6113%2013.418%2017.4707%2016.9434%2017.5%2017.0996C17.6074%2017.4805%2017.3828%2017.8125%2017.002%2017.8125C16.7285%2017.8125%2016.5332%2017.666%2016.4355%2017.2949C16.2695%2016.582%2015.6348%2014.1406%2015.4688%2013.5547C15.332%2013.125%2015.2246%2012.9785%2014.9902%2012.9785C14.7461%2012.9785%2014.6387%2013.125%2014.5215%2013.5547C14.3262%2014.1406%2013.7109%2016.582%2013.5449%2017.2949C13.4375%2017.666%2013.252%2017.8125%2012.9785%2017.8125C12.7583%2017.8125%2012.5871%2017.7015%2012.5032%2017.534C12.5629%2017.2283%2012.5924%2016.9134%2012.5931%2016.5925C12.8412%2015.5569%2013.3846%2013.2605%2013.4766%2012.6855C13.6035%2011.9434%2013.6133%2010.3223%2013.6035%209.81445C13.5938%209.48242%2013.4277%209.24805%2013.1543%209.18945C12.8711%209.14062%2010.6543%208.88672%2010.332%208.78906C10.0879%208.7207%209.92188%208.49609%209.92188%208.23242C9.92188%207.88086%2010.1758%207.67578%2010.4199%207.67578C10.5176%207.67578%2010.5957%207.70508%2010.6836%207.71484C11.6309%207.86133%2013.6035%208.0957%2014.9902%208.0957C16.3867%208.0957%2018.3496%207.86133%2019.2871%207.71484C19.375%207.70508%2019.4629%207.67578%2019.5508%207.67578C19.7949%207.67578%2020.0586%207.88086%2020.0586%208.23242ZM16.2598%206.18164C16.2598%206.875%2015.6934%207.45117%2014.9902%207.45117C14.2871%207.45117%2013.7207%206.875%2013.7207%206.18164C13.7207%205.47852%2014.2871%204.91211%2014.9902%204.91211C15.6934%204.91211%2016.2598%205.47852%2016.2598%206.18164Z'%20fill='black'%20fill-opacity='0.85'%20/%3e%3cpath%20d='M11.2598%2016.5039C11.2598%2019.2188%208.98438%2021.4648%206.29883%2021.4648C3.58398%2021.4648%201.33789%2019.2383%201.33789%2016.5039C1.33789%2013.7891%203.58398%2011.543%206.29883%2011.543C9.02344%2011.543%2011.2598%2013.7793%2011.2598%2016.5039ZM5.86914%2014.3848L4.77539%2015.4395C4.75586%2015.4492%204.72656%2015.4688%204.70703%2015.4688L3.97461%2015.4688C3.62305%2015.4688%203.4375%2015.6445%203.4375%2016.0254L3.4375%2016.9824C3.4375%2017.3535%203.62305%2017.5391%203.97461%2017.5391L4.70703%2017.5391C4.72656%2017.5391%204.75586%2017.5488%204.77539%2017.5684L5.86914%2018.6133C5.99609%2018.7207%206.09375%2018.7695%206.21094%2018.7695C6.38672%2018.7695%206.51367%2018.6426%206.51367%2018.4766L6.51367%2014.5215C6.51367%2014.3457%206.38672%2014.2285%206.21094%2014.2285C6.09375%2014.2285%205.99609%2014.2773%205.86914%2014.3848ZM8.35938%2014.6484C8.21289%2014.7461%208.18359%2014.9414%208.29102%2015.0977C8.56445%2015.4883%208.71094%2015.9766%208.71094%2016.4941C8.71094%2017.0215%208.55469%2017.5195%208.30078%2017.9102C8.20312%2018.0469%208.21289%2018.2422%208.34961%2018.3496C8.48633%2018.4375%208.67188%2018.4082%208.78906%2018.2715C9.13086%2017.793%209.31641%2017.1582%209.31641%2016.4941C9.31641%2015.8301%209.13086%2015.1953%208.7793%2014.7266C8.66211%2014.5898%208.49609%2014.5703%208.35938%2014.6484ZM7.36328%2015.3223C7.20703%2015.4102%207.1875%2015.6348%207.30469%2015.7715C7.42188%2015.9473%207.5%2016.2207%207.5%2016.4941C7.5%2016.7871%207.43164%2017.0312%207.30469%2017.2266C7.1875%2017.373%207.20703%2017.5586%207.35352%2017.666C7.49023%2017.7734%207.67578%2017.7441%207.7832%2017.5879C7.99805%2017.3047%208.125%2016.9043%208.125%2016.4941C8.125%2016.0938%207.99805%2015.6934%207.79297%2015.4102C7.68555%2015.2637%207.50977%2015.2344%207.36328%2015.3223Z'%20fill='black'%20fill-opacity='0.85'%20/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/xmark.svg b/src/sf-symbols/xmark.svg
new file mode 100644
index 0000000..4f8e136
--- /dev/null
+++ b/src/sf-symbols/xmark.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2077.418%2077.399'%3e%3cpath%20d='M1.247%2076.14C2.977%2077.818%205.784%2077.818%207.473%2076.14L38.688%2044.863%2069.966%2076.14C71.592%2077.818%2074.45%2077.818%2076.14%2076.14%2077.818%2074.4%2077.818%2071.592%2076.14%2069.955L44.863%2038.688%2076.14%207.473C77.818%205.784%2077.87%202.925%2076.14%201.247%2074.4-0.39%2071.592-0.39%2069.966%201.247L38.688%2032.514%207.473%201.247C5.784-0.39%202.925-0.442%201.247%201.247-0.39%202.977-0.39%205.784%201.247%207.473L32.514%2038.688%201.247%2069.955C-0.39%2071.592-0.442%2074.451%201.247%2076.14Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/sf-symbols/xmark.triangle.circle.square.fill.svg b/src/sf-symbols/xmark.triangle.circle.square.fill.svg
new file mode 100644
index 0000000..c5ede3c
--- /dev/null
+++ b/src/sf-symbols/xmark.triangle.circle.square.fill.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2018.5254%2017.9785'%3e%3cg%3e%3crect%20height='17.9785'%20opacity='0'%20width='18.5254'%20x='0'%20y='0'/%3e%3cpath%20d='M11.7871%2017.8418L16.2305%2017.8418C17.4707%2017.8418%2018.0957%2017.2266%2018.0957%2015.9375L18.0957%2011.5723C18.0957%2010.2832%2017.4707%209.66797%2016.2305%209.66797L11.7871%209.66797C10.5469%209.66797%209.92188%2010.2832%209.92188%2011.5723L9.92188%2015.9375C9.92188%2017.2266%2010.5469%2017.8418%2011.7871%2017.8418Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M4.20898%2017.9785C6.5332%2017.9785%208.4082%2016.0938%208.4082%2013.7695C8.4082%2011.4453%206.5332%209.57031%204.20898%209.57031C1.88477%209.57031%200%2011.4453%200%2013.7695C0%2016.0938%201.88477%2017.9785%204.20898%2017.9785Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M10.8691%207.75391L17.1484%207.75391C18.0273%207.75391%2018.3789%207.01172%2017.959%206.25977L14.8828%200.791016C14.4434%200.00976562%2013.5645%200.0195312%2013.1348%200.791016L10.0684%206.25977C9.6582%206.99219%209.98047%207.75391%2010.8691%207.75391Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M6.82617%207.53906C7.08984%207.8125%207.51953%207.7832%207.77344%207.51953C8.03711%207.25586%208.06641%206.83594%207.80273%206.57227L1.5625%200.341797C1.30859%200.0878906%200.878906%200.107422%200.615234%200.371094C0.361328%200.625%200.332031%201.06445%200.585938%201.31836ZM1.57227%207.53906L7.8125%201.31836C8.06641%201.06445%208.03711%200.625%207.7832%200.371094C7.5293%200.107422%207.08984%200.0878906%206.83594%200.341797L0.595703%206.57227C0.332031%206.83594%200.361328%207.25586%200.625%207.51953C0.888672%207.7832%201.30859%207.8125%201.57227%207.53906Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/stores/carousel-media-style.ts b/src/stores/carousel-media-style.ts
new file mode 100644
index 0000000..dd2a642
--- /dev/null
+++ b/src/stores/carousel-media-style.ts
@@ -0,0 +1,5 @@
+import { writable } from 'svelte/store';
+
+type Style = 'light' | 'dark' | 'white';
+
+export const carouselMediaStyle = writable<Style>('light');
diff --git a/src/stores/i18n.ts b/src/stores/i18n.ts
new file mode 100644
index 0000000..740d0b4
--- /dev/null
+++ b/src/stores/i18n.ts
@@ -0,0 +1,73 @@
+import { readable } from 'svelte/store';
+import I18N from '@amp/web-apps-localization';
+import { getContext } from 'svelte';
+import type { Readable } from 'svelte/store';
+import type { Locale, ILocaleJSON } from '@amp/web-apps-localization';
+import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
+import { isEnabled } from '@amp/web-apps-featurekit';
+
+export type { Locale } from '@amp/web-apps-localization';
+
+import { __FF_SHOW_LOC_KEYS } from '~/utils/features/consts';
+
+const CONTEXT_NAME = 'i18n';
+
+export async function setup(
+ context: Map<string, unknown>,
+ loggerFactory: LoggerFactory,
+ locale: Locale,
+): Promise<I18N> {
+ const log = loggerFactory.loggerFor('i18n');
+
+ let alwaysShowScreamers = false;
+ if (isEnabled(__FF_SHOW_LOC_KEYS)) {
+ alwaysShowScreamers = true;
+ }
+
+ const translations = await getTranslations(log, locale);
+ const i18n = new I18N(log, locale, translations, alwaysShowScreamers);
+ const store = readable(i18n);
+
+ context.set(CONTEXT_NAME, store);
+
+ return i18n;
+}
+
+/**
+ * Gets the current i18n store from the Svelte context.
+ *
+ * @return i18n The i18n store
+ */
+export function getI18n(): Readable<I18N> {
+ const i18n = getContext(CONTEXT_NAME) as Readable<I18N> | undefined;
+
+ if (!i18n) {
+ throw new Error('getI18n called before setup');
+ }
+
+ return i18n;
+}
+
+async function getTranslations(
+ log: Logger,
+ locale: Locale,
+): Promise<ILocaleJSON> {
+ try {
+ // TODO: Shoebox logic here
+ const translations = await importLocale(locale);
+ return translations.default;
+ } catch (err) {
+ log.error('failed to load:', err);
+ throw new Error('i18n failed to load');
+ }
+}
+
+interface IDynamicImportJSON {
+ default: ILocaleJSON;
+}
+
+//TODO: rdar://73157638 (Determine if we can use ES modules based on browser matrix)
+// Possibly switch this to fetch instead of dynamic imports?
+function importLocale(locale: Locale): Promise<IDynamicImportJSON> {
+ return import(`../../tmp/locales/${locale}/translations.json`);
+}
diff --git a/src/stores/modalPage.ts b/src/stores/modalPage.ts
new file mode 100644
index 0000000..dca38a0
--- /dev/null
+++ b/src/stores/modalPage.ts
@@ -0,0 +1,35 @@
+import type { GenericPage } from '@jet-app/app-store/api/models';
+import { type Writable, writable, type Readable } from 'svelte/store';
+
+interface Page {
+ page: GenericPage;
+ pageDetail?: string;
+}
+
+const modalPageStore: Writable<Page | undefined> = (() => {
+ // prevent global store on the server
+ if (typeof window === 'undefined') {
+ return {
+ subscribe: () => {
+ return () => {};
+ },
+ set: () => {},
+ update: () => {},
+ } as unknown as Writable<Page | undefined>;
+ }
+
+ return writable();
+})();
+
+interface ModalPageStore extends Readable<Page | undefined> {
+ setPage: (page: Page) => void;
+ clearPage: () => void;
+}
+
+export const getModalPageStore = (): ModalPageStore => {
+ return {
+ subscribe: modalPageStore.subscribe,
+ setPage: (page) => modalPageStore.set(page),
+ clearPage: () => modalPageStore.set(undefined),
+ };
+};
diff --git a/src/utils/app-platforms.ts b/src/utils/app-platforms.ts
new file mode 100644
index 0000000..1adb840
--- /dev/null
+++ b/src/utils/app-platforms.ts
@@ -0,0 +1,25 @@
+import type { AppPlatform } from '@jet-app/app-store/api/models';
+
+export const PlatformToExclusivityText: Partial<Record<AppPlatform, string>> = {
+ watch: 'ASE.Web.AppStore.App.OnlyForWatch',
+ tv: 'ASE.Web.AppStore.App.OnlyForAppleTV',
+ messages: 'ASE.Web.AppStore.App.OnlyForiMessage',
+ mac: 'ASE.Web.AppStore.App.OnlyForMac',
+ phone: 'ASE.Web.AppStore.App.OnlyForPhone',
+};
+
+export function isPlatformSupported(
+ platform: AppPlatform,
+ appPlatforms: AppPlatform[],
+) {
+ const dedupedPlatforms = new Set(appPlatforms);
+ return dedupedPlatforms.has(platform);
+}
+
+export function isPlatformExclusivelySupported(
+ platform: AppPlatform,
+ appPlatforms: AppPlatform[],
+) {
+ const dedupedPlatforms = new Set(appPlatforms);
+ return dedupedPlatforms.has(platform) && dedupedPlatforms.size === 1;
+}
diff --git a/src/utils/array.ts b/src/utils/array.ts
new file mode 100644
index 0000000..de9ef96
--- /dev/null
+++ b/src/utils/array.ts
@@ -0,0 +1,33 @@
+/**
+ * Split an array into two groups based on the result {@linkcode predicate}
+ *
+ * Items for which {@linkcode predicate} returns `true` will be in the "left"
+ * result, and the others in the "right" one
+ */
+export function partition<T>(
+ input: Array<T>,
+ predicate: (element: T) => boolean,
+): [Array<T>, Array<T>] {
+ const left: Array<T> = [];
+ const right: Array<T> = [];
+
+ for (const element of input) {
+ if (predicate(element)) {
+ left.push(element);
+ } else {
+ right.push(element);
+ }
+ }
+
+ return [left, right];
+}
+
+/**
+ * Deduplicate the elements of {@linkcode items} by their `id` property
+ */
+export function uniqueById<T extends { id: string }>(items: T[]): T[] {
+ const entries = items.map((item) => [item.id, item] as const);
+ const mapById = new Map<string, T>(entries);
+
+ return Array.from(mapById.values());
+}
diff --git a/src/utils/color.ts b/src/utils/color.ts
new file mode 100644
index 0000000..1d1c334
--- /dev/null
+++ b/src/utils/color.ts
@@ -0,0 +1,168 @@
+import { isSome } from '@jet/environment/types/optional';
+import type {
+ Artwork,
+ Color,
+ RGBColor,
+ NamedColor,
+} from '@jet-app/app-store/api/models';
+
+export type RGB = [number, number, number];
+
+/**
+ * Represents a valid RGB color string, in the format "rgb(r, g, b)" or "rgb(r,g,b)".
+ * @example
+ * "rgb(255, 0, 128)"
+ * "rgb(255,0,128)"
+ */
+type RGBString =
+ | `rgb(${number},${number},${number})`
+ | `rgb(${number}, ${number}, ${number})`;
+
+export const isRGBColor = (value: Color): value is RGBColor =>
+ value.type === 'rgb';
+
+export const isNamedColor = (value: Color): value is NamedColor =>
+ value.type === 'named';
+
+const rgbColorAsString = ({ red, green, blue }: RGBColor): string =>
+ `rgb(${[red, green, blue].map((color) => Math.floor(255 * color)).join()})`;
+
+export const colorAsString = (color: Color): string => {
+ switch (color.type) {
+ case 'named':
+ // `ios-appstore-app` makes use of the this `placeholderBackground` named color,
+ // which it leaves up to the client to manage. Ideally, we could define a CSS property
+ // named `--placeholderBackground`, but the media-apps shared logic to determine Artwork
+ // background color doesn't respect CSS properties, so we are specifying the hex value.
+ // https://github.pie.apple.com/amp-web/media-apps/blame/main/shared/components/src/components/Artwork/utils/validateBackground.ts
+ if (color.name === 'placeholderBackground') {
+ return '#f1f1f1';
+ }
+
+ return `var(--${color.name})`;
+ case 'rgb':
+ return rgbColorAsString(color);
+ case 'dynamic':
+ return colorAsString(color.lightColor);
+ }
+};
+
+/**
+ * Parses an RGB string and returns an array of red, green, and blue values.
+ *
+ * This function extracts the numeric values from an RGB string (e.g., "rgb(255, 0, 128)")
+ * and returns them as an array of numbers.
+ *
+ * @param {RGBString} rgbString - The RGB string to parse.
+ * @returns {RGB} An array of three numbers representing the red, green, and blue values, each between 0 and 255.
+ *
+ * @example
+ * getRGBFromString("rgb(255, 0, 128)") = [255, 0, 128]
+ */
+export const getRGBFromString = (rgbString: RGBString): RGB => {
+ const rgbValues = rgbString.match(/\d+/g) ?? [];
+ const rgb: RGB = [0, 0, 0];
+
+ for (const [index] of rgb.entries()) {
+ rgb[index] = parseInt(rgbValues[index]);
+ }
+
+ return rgb;
+};
+
+/**
+ * Calculates the relative luminance for an RGB color.
+ *
+ * This function uses a standardized formula for luminance, which weights the red, green, and blue
+ * channels differently to account for human perception.
+ * @see {@link https://en.wikipedia.org/wiki/Relative_luminance|Wikipedia: Relative Luminance}
+ *
+ * @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
+ * @returns {number} The calculated luminance value, a number between 0 (darkest) and 255 (lightest).
+ */
+export const getLuminanceForRGB = ([r, g, b]: RGB): number => {
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+
+export function isRGBDarkerThanThreshold([r, g, b]: RGB, threshold = 10) {
+ return r <= threshold && g <= threshold && b <= threshold;
+}
+
+export function isDark(rgbColor: RGBColor): boolean {
+ const { red, green, blue } = rgbColor;
+ const rgbValues = [red, green, blue].map((channel) =>
+ Math.floor(channel * 255),
+ ) as RGB;
+
+ return isRGBDarkerThanThreshold(rgbValues, 127);
+}
+
+/**
+ * Determines whether an RGB color is approximately grey based on channel similarity.
+ *
+ * @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255.
+ * @param {number} [threshold=10] - Maximum allowed difference between color channels to still be considered grey-ish.
+ * @returns {boolean} True if the RGB values are close enough to be considered grey.
+ */
+function isKindOfGrey([r, g, b]: RGB, threshold = 10) {
+ return (
+ Math.abs(r - g) <= threshold &&
+ Math.abs(r - b) <= threshold &&
+ Math.abs(g - b) <= threshold
+ );
+}
+
+/**
+ * Generates CSS variables (custom properties) for a background gradient based on the background
+ * colors in the specified list of artworks.
+ *
+ * @param {Artwork[]} artworks - An array of Artwork, each containing a `backgroundColor` property.
+ * @param {Object} [options={}] - Optional configuration options.
+ * @param {string[]} [options.variableNames=['bottom-left', 'top-right', 'bottom-right', 'top-left']] -
+ * The names of the CSS variables to assign to the extracted colors. The number of colors
+ * used will match the length of this array.
+ * @param {(a: RGB, b: RGB) => number} [options.sortFn=() => 0] -
+ * A sorting function for ordering the colors (e.g., by luminance). Defaults to no sorting,
+ * which preserves input order.
+ *
+ * @returns {string} A CSS string containing custom properties, e.g.,
+ * "--bottom-left: rgb(255, 0, 0); --top-right: rgb(0, 255, 0);".
+ */
+export const getBackgroundGradientCSSVarsFromArtworks = (
+ artworks: Artwork[],
+ {
+ variableNames = [
+ 'bottom-left',
+ 'top-right',
+ 'bottom-right',
+ 'top-left',
+ ],
+ sortFn = () => 0,
+ shouldRemoveGreys = false,
+ }: {
+ variableNames?: string[];
+ sortFn?: (a: RGB, b: RGB) => number;
+ shouldRemoveGreys?: boolean;
+ } = {},
+): string => {
+ return artworks
+ .map(({ backgroundColor }) => backgroundColor)
+ .filter(isSome)
+ .filter(isRGBColor)
+ .map(
+ ({ red, green, blue }): RGB => [
+ Math.floor(255 * red),
+ Math.floor(255 * green),
+ Math.floor(255 * blue),
+ ],
+ )
+ .filter((rgb) => !isRGBDarkerThanThreshold(rgb, 33))
+ .filter((rgb) => (shouldRemoveGreys ? !isKindOfGrey(rgb, 10) : true))
+ .sort(sortFn)
+ .slice(0, variableNames.length)
+ .map(
+ ([red, green, blue], index) =>
+ `--${variableNames[index]}: rgb(${red}, ${green}, ${blue})`,
+ )
+ .join('; ');
+};
diff --git a/src/utils/error.ts b/src/utils/error.ts
new file mode 100644
index 0000000..40f0fc0
--- /dev/null
+++ b/src/utils/error.ts
@@ -0,0 +1,28 @@
+/**
+ * Tries to call {@linkcode fn} throwing an exception with the {@linkcode message}
+ * if an error occurs
+ *
+ * @example
+ * // Before
+ * let value;
+ * try {
+ * value = someMethod();
+ * } catch(e) {
+ * throw new Error('My specific message', { cause: e })
+ * }
+ *
+ * // After
+ * const value = mapError(
+ * () => someMethod(),
+ * 'My specific message'
+ * );
+ */
+export function mapException<T>(fn: () => T, message: string): T {
+ try {
+ return fn();
+ } catch (e) {
+ throw new Error(message, {
+ cause: e,
+ });
+ }
+}
diff --git a/src/utils/features/consts.ts b/src/utils/features/consts.ts
new file mode 100644
index 0000000..393fea9
--- /dev/null
+++ b/src/utils/features/consts.ts
@@ -0,0 +1,13 @@
+/**
+ * This file is a place for defining any Feature Flag Constants used throughout the app
+ * In order to keep the API Interface same for Build Time vs Runtime Feature Flags
+ * We have ensured that all the flags have to be defined in this file
+ */
+// Actual Feature Flag Values have to be defined in the /apps/app-store/featureFlags.external.cjs
+// BUILD BASED FEATURE FLAGS DUMMY FLAG DEFINITIONS TO FIX THE NAME OF THE FEATURE FLAGS TO BE USED
+// Values of the BUILD BASED FLAGS will decide if they are evaluated to true in DEV mode
+export const __FF_SHOW_RADAR = 'r01234e98765';
+
+export const __FF_SHOW_LOC_KEYS = 'ffShowLocKeys';
+
+export const __FF_ARYA = 'asha123e7z124';
diff --git a/src/utils/features/runtime.ts b/src/utils/features/runtime.ts
new file mode 100644
index 0000000..ebb83ad
--- /dev/null
+++ b/src/utils/features/runtime.ts
@@ -0,0 +1,44 @@
+import {
+ buildFeatureConfig,
+ buildRuntimeFeatureKitConfig,
+ ENVIRONMENT,
+ loadFeatureKit,
+ type OnyxFeatures,
+} from '@amp/web-apps-featurekit';
+import type { LoggerFactory } from '@amp/web-apps-logger';
+import { BUILD } from '~/config/build';
+
+export async function setupRuntimeFeatures(
+ logger: LoggerFactory,
+): Promise<OnyxFeatures | void> {
+ // load featureKit only for internal builds
+ if (import.meta.env.APP_SCOPE === 'internal' || import.meta.env.DEV) {
+ const features = await import('./consts');
+
+ // Build FeatureKit Config with overrides
+ const config = buildRuntimeFeatureKitConfig(features, {
+ [features.__FF_SHOW_RADAR]: buildFeatureConfig({
+ [ENVIRONMENT.DEV]: true,
+ }),
+ [features.__FF_ARYA]: {
+ ...buildFeatureConfig({ [ENVIRONMENT.DEV]: false }),
+ itfe: ['y9ttlj15'],
+ },
+ });
+ // Load runtime featureKit
+ return loadFeatureKit(
+ 'com.apple.apps',
+ ENVIRONMENT.DEV,
+ config,
+ logger,
+ {
+ enableToolbar: true,
+ radarConfig: {
+ component: 'ASE Web',
+ app: 'App Store',
+ build: BUILD,
+ },
+ },
+ );
+ }
+}
diff --git a/src/utils/file-size.ts b/src/utils/file-size.ts
new file mode 100644
index 0000000..f71c4f4
--- /dev/null
+++ b/src/utils/file-size.ts
@@ -0,0 +1,23 @@
+const ROUND_TO = 10;
+const SIZE_INCREMENT = 1000;
+const UNITS = ['byte', 'KB', 'MB', 'GB'];
+
+/**
+ * Converts a byte count into a scaled value with a unit label (e.g. KB, MB, GB).
+ *
+ * @param {number} bytes - The number of bytes.
+ * @returns {{ count: number, unit: string }} Scaled value and its corresponding unit.
+ */
+export function getFileSizeParts(bytes: number) {
+ let index = 0;
+
+ while (bytes >= SIZE_INCREMENT && index < UNITS.length - 1) {
+ bytes /= SIZE_INCREMENT;
+ index++;
+ }
+
+ const count = Math.round(bytes * ROUND_TO) / ROUND_TO;
+ const unit = UNITS[index];
+
+ return { count, unit };
+}
diff --git a/src/utils/launch-client.ts b/src/utils/launch-client.ts
new file mode 100644
index 0000000..5202726
--- /dev/null
+++ b/src/utils/launch-client.ts
@@ -0,0 +1,13 @@
+import { platform } from '@amp/web-apps-utils';
+
+const setupUrlForMac = (url: string) => {
+ const incomingUrl = new URL(url);
+ incomingUrl.searchParams.set('mt', '12');
+ return incomingUrl.toString();
+};
+
+export const launchAppOnMac = (url: string) => {
+ const appUrl = setupUrlForMac(url);
+
+ platform.launchClient(appUrl, () => {});
+};
diff --git a/src/utils/locale.ts b/src/utils/locale.ts
new file mode 100644
index 0000000..cd1151a
--- /dev/null
+++ b/src/utils/locale.ts
@@ -0,0 +1,142 @@
+import type { Opt } from '@jet/environment';
+import { DEFAULT_STOREFRONT_CODE } from '~/constants/storefront';
+
+import type {
+ NormalizedLocale,
+ NormalizedStorefront,
+ NormalizedLanguage,
+} from '@jet-app/app-store/api/locale';
+import type { Locale } from '@jet-app/app-store/foundation/dependencies/locale/locale';
+
+import { TEXT_DIRECTION } from '@amp/web-app-components/src/constants';
+import { getLocAttributes } from '@amp/web-apps-localization';
+
+import { regions } from '~/utils/storefront-data';
+import { getJet } from '~/jet/svelte';
+
+export type NormalizedLocaleWithDefault = NormalizedLocale & {
+ isDefaultLanguage: boolean;
+};
+
+type LanguageDetails = {
+ languages: NormalizedLanguage[];
+ defaultLanguage: NormalizedLanguage;
+};
+
+export function normalizeStorefront(storefront: Opt<string>): {
+ storefront: NormalizedStorefront;
+ languages: NormalizedLanguage[];
+ defaultLanguage: NormalizedLanguage;
+} {
+ const storefronts: Record<NormalizedStorefront, LanguageDetails> = {};
+
+ for (const { locales } of regions) {
+ for (const { id, language, isDefault } of locales) {
+ if (isDefault) {
+ storefronts[id as NormalizedStorefront] = {
+ languages: [],
+ defaultLanguage: language as NormalizedLanguage,
+ };
+ }
+
+ if (id in storefronts) {
+ storefronts[id as NormalizedStorefront].languages.push(
+ language as NormalizedLanguage,
+ );
+ }
+ }
+ }
+
+ const normalizedStorefront = (storefront || '').toLowerCase();
+ const chosenStorefront =
+ normalizedStorefront in storefronts
+ ? (normalizedStorefront as NormalizedStorefront)
+ : DEFAULT_STOREFRONT_CODE;
+
+ return {
+ storefront: chosenStorefront,
+ ...storefronts[chosenStorefront],
+ };
+}
+
+export function normalizeLanguage(
+ language: string,
+ languages: NormalizedLanguage[],
+ defaultLanguage: NormalizedLanguage,
+): { language: NormalizedLanguage; isDefaultLanguage: boolean } {
+ function annotateReturn(language: NormalizedLanguage): {
+ language: NormalizedLanguage;
+ isDefaultLanguage: boolean;
+ } {
+ return {
+ language,
+ isDefaultLanguage: language === defaultLanguage,
+ };
+ }
+
+ // Prefer an exact match (ex. en-US matches en-US)
+ const exactMatch = findMatch(language, languages, (a, b) => a === b);
+ if (exactMatch) {
+ return annotateReturn(exactMatch);
+ }
+
+ // Try partial match (ex. fr-CA or fr matches fr-FR)
+ const partialMatch = findMatch(
+ language,
+ languages,
+ (a, b) => a.split('-')[0] === b.split('-')[0],
+ );
+ if (partialMatch) {
+ return annotateReturn(partialMatch);
+ }
+
+ // The only remaining choice is the storefront default
+ return annotateReturn(defaultLanguage);
+}
+
+function findMatch<T extends string>(
+ needle: string,
+ haystack: T[],
+ matches: (a: string, b: string) => boolean,
+): Opt<T> {
+ return haystack.find((possibility) =>
+ matches(possibility.toLowerCase(), needle.toLowerCase()),
+ );
+}
+
+/**
+ * Gets the current Locale instance from the Svelte context.
+ *
+ * @return the active {@linkcode NormalizedLocale}
+ */
+export function getLocale(): NormalizedLocale {
+ let locale: Locale | undefined;
+
+ try {
+ const { objectGraph } = getJet();
+
+ locale = objectGraph.locale;
+ } catch {
+ throw new Error('`getLocale` called before `Jet.load`');
+ }
+
+ return {
+ storefront: locale.activeStorefront,
+ language: locale.activeLanguage,
+ };
+}
+
+/**
+ * Returns whether or not the document is in RTL mode, first based on the document's direction,
+ * with a fallback to the storefronts default writing direction.
+ */
+export function isRtl() {
+ const { storefront } = getLocale();
+ const { dir } = getLocAttributes(storefront);
+
+ return (
+ (typeof document !== 'undefined' &&
+ document.dir === TEXT_DIRECTION.RTL) ||
+ dir === TEXT_DIRECTION.RTL
+ );
+}
diff --git a/src/utils/media-queries.ts b/src/utils/media-queries.ts
new file mode 100644
index 0000000..d189b94
--- /dev/null
+++ b/src/utils/media-queries.ts
@@ -0,0 +1,12 @@
+import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
+import { getMediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions';
+import { buildMediaQueryStore } from '@amp/web-app-components/src/stores/media-query';
+
+const { BREAKPOINTS } = ArtworkConfig.get();
+
+const mediaQueryStore = buildMediaQueryStore(
+ 'medium',
+ getMediaConditions(BREAKPOINTS, { offset: 260 }),
+);
+
+export default mediaQueryStore;
diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts
new file mode 100644
index 0000000..9e3b015
--- /dev/null
+++ b/src/utils/metrics.ts
@@ -0,0 +1,4 @@
+export const APP_PRIVACY_MODAL_ID = 'ModalAppPrivacy';
+export const CUSTOMER_REVIEW_MODAL_ID = 'ModalCustomerReview';
+export const VERSION_HISTORY_MODAL_ID = 'ModalVersionHistory';
+export const LICENSE_AGREEMENT_MODAL_ID = 'LicenseAgreement';
diff --git a/src/utils/number-formatting.ts b/src/utils/number-formatting.ts
new file mode 100644
index 0000000..8e9ef71
--- /dev/null
+++ b/src/utils/number-formatting.ts
@@ -0,0 +1,39 @@
+/**
+ * Normalizes and makes sure we include some unicode option for number formating.
+ */
+function localeWithOptionsForNumbers(locale: string) {
+ locale = locale.toLowerCase().replace('_', '-');
+
+ if (locale === 'hi-in') {
+ // nu-latn makes the formatter use latin numbers.
+ // See BCP47 Unicode extensions for number (nu):
+ // http://unicode.org/repos/cldr/trunk/common/bcp47/number.xml
+ // TL;DR -u- means the start of unicode extension.
+ // nu-latn means numeric (nu) extension, latn value
+ return 'hi-in-u-nu-latn';
+ } else if (locale === 'my') {
+ // For the `my` locale, we want to display functional numbers as Latin numerals rather than in Burmese,
+ // so we are overriding the locale to give us the Latin functional numbers. See radar for more context:
+ // rdar://155236306 (LOC: MS-MY: ASOTW | Product Page: Functional: Numbers are not displayed in MS/EN format)
+ return 'my-u-nu-latn';
+ }
+
+ return locale;
+}
+
+/**
+ * Abbreviate a number into a compact shorthand
+ *
+ * @example
+ * const abbr = abbreviateNumber(10_000, 'en-US'); // '10K'
+ */
+export function abbreviateNumber(value: number, locale: string): string {
+ const formatter = new Intl.NumberFormat(
+ localeWithOptionsForNumbers(locale),
+ {
+ notation: 'compact',
+ },
+ );
+
+ return formatter.format(value);
+}
diff --git a/src/utils/portal.ts b/src/utils/portal.ts
new file mode 100644
index 0000000..0c61ed0
--- /dev/null
+++ b/src/utils/portal.ts
@@ -0,0 +1,34 @@
+/**
+ * Svelte action to move an element to a different part of the DOM (as specified by the `targetId`
+ * provided), effectively creating a "portal."
+ *
+ * @param {HTMLElement} node - The element to be moved (provided by Svelte's `use:action` syntax).
+ * @param {string} targetId - The ID of the target element where `node` should be moved.
+ * @returns {{ destroy(): void } | void} - An object with a `destroy` method to remove `node` from the target when unmounted.
+ *
+ * @example
+ * ```svelte
+ * <div use:portal={'target-container'}>
+ * This content will be moved to the element with ID "target-container".
+ * </div>
+ * ```
+ */
+export default function portal(node: HTMLElement, targetId: string) {
+ if (typeof document === 'undefined') {
+ return;
+ }
+
+ let targetElement: HTMLElement | null = document.getElementById(targetId);
+
+ if (!targetElement) {
+ return;
+ }
+
+ targetElement.appendChild(node);
+
+ return {
+ destroy() {
+ targetElement.removeChild(node);
+ },
+ };
+}
diff --git a/src/utils/seo/app-event-detail-page.ts b/src/utils/seo/app-event-detail-page.ts
new file mode 100644
index 0000000..7b6c270
--- /dev/null
+++ b/src/utils/seo/app-event-detail-page.ts
@@ -0,0 +1,43 @@
+import type { GenericPage } from '@jet-app/app-store/api/models';
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+import { isAppEventDetailShelf } from '~/components/jet/shelf/AppEventDetailShelf.svelte';
+import { truncateAroundLimit } from '~/utils/string-formatting';
+import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common';
+
+export function seoDataForAppEventDetailPage(
+ page: GenericPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const appEventDetailShelf = page.shelves.find(isAppEventDetailShelf);
+
+ const { appEvent } = appEventDetailShelf?.items[0] || {};
+
+ if (!appEvent) {
+ return {};
+ }
+
+ const title = appEvent.title;
+ const description = truncateAroundLimit(
+ appEvent.detail,
+ MAX_DESCRIPTION_LENGTH,
+ language,
+ );
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ crop: 'fo',
+ twitterCropCode: 'fo',
+ artworkUrl: appEvent?.moduleArtwork?.template,
+ imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', {
+ title: title,
+ }),
+ };
+}
diff --git a/src/utils/seo/arcade-see-all-page.ts b/src/utils/seo/arcade-see-all-page.ts
new file mode 100644
index 0000000..14d1474
--- /dev/null
+++ b/src/utils/seo/arcade-see-all-page.ts
@@ -0,0 +1,40 @@
+import type I18N from '@amp/web-apps-localization';
+import type { GenericPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import { isAppTrailerLockupShelf } from '~/components/jet/shelf/AppTrailerLockupShelf.svelte';
+
+export function seoDataForArcadeSeeAllPage(
+ page: GenericPage,
+ i18n: I18N,
+): SeoData {
+ const titleWithSiteName = i18n.t(
+ 'ASE.Web.AppStore.Meta.TitleWithSiteName',
+ {
+ title: i18n.t('ASE.Web.AppStore.ArcadeSeeAll.Meta.Title'),
+ },
+ );
+
+ const appNames = page.shelves
+ .filter(isAppTrailerLockupShelf)
+ .flatMap((shelf) => shelf.items)
+ .slice(0, 3)
+ .map((item) => item.title);
+
+ const description = i18n.t(
+ 'ASE.Web.AppStore.ArcadeSeeAll.Meta.Description',
+ {
+ listing1: appNames[0],
+ listing2: appNames[1],
+ listing3: appNames[2],
+ },
+ );
+
+ return {
+ pageTitle: titleWithSiteName,
+ socialTitle: titleWithSiteName,
+ appleTitle: titleWithSiteName,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/article-page.ts b/src/utils/seo/article-page.ts
new file mode 100644
index 0000000..371e63e
--- /dev/null
+++ b/src/utils/seo/article-page.ts
@@ -0,0 +1,276 @@
+import type { Opt } from '@jet/environment/types/optional';
+import type {
+ Article,
+ CollectionPage,
+ CreativeWork,
+ WithContext,
+} from 'schema-dts';
+
+import type { ArticlePage } from '@jet-app/app-store/api/models';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import {
+ type DataContainer,
+ type Data,
+ dataFromDataContainer,
+} from '@jet-app/app-store/foundation/media/data-structure';
+import {
+ attributeAsDictionary,
+ attributeAsString,
+} from '@jet-app/app-store/foundation/media/attributes';
+import { relationshipCollection } from '@jet-app/app-store/foundation/media/relationships';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+
+import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte';
+import { isLockupOverlay } from '~/components/jet/today-card/TodayCardOverlay.svelte';
+import { isLockupListOverlay } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte';
+import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+import { isTodayCardMediaVideo } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+import { isTodayCardMediaRiver } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte';
+import { isTodayCardMediaBrandedSingleApp } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte';
+import { isTodayCardMediaAppEvent } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte';
+
+import { AppleOrganization } from './common';
+import { buildOpenGraphImageURL } from './image-url';
+import { basicSoftwareApplicationSchema } from './product-page';
+import { stripTags, truncateAroundLimit } from '~/utils/string-formatting';
+
+/// MARK: Schema Data
+
+/**
+ * SEO-related props that have already been computed, and will be re-used within the schema
+ */
+interface SeoProps {
+ title: string;
+ description: string | undefined;
+}
+
+function commonSchemaForArticlePage(
+ data: Data,
+ { title, description }: SeoProps,
+): WithContext<CreativeWork> {
+ const artwork =
+ attributeAsDictionary(
+ data,
+ 'editorialArtwork.storyCenteredStatic16x9',
+ ) ?? undefined;
+ const lastPublishedDate =
+ attributeAsString(data, 'lastPublishedDate') ?? undefined;
+
+ return {
+ '@type': 'CreativeWork',
+ '@context': 'https://schema.org',
+
+ description,
+ headline: title,
+ name: title,
+
+ dateModified: lastPublishedDate,
+ datePublished: lastPublishedDate,
+ image: artwork ? buildOpenGraphImageURL(artwork) : undefined,
+
+ author: AppleOrganization,
+ publisher: AppleOrganization,
+ };
+}
+
+function articleSchemaForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+): WithContext<Article> {
+ const cardContents = relationshipCollection(data, 'card-contents') ?? [];
+ const [app] = cardContents;
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+
+ mainEntityOfPage: app
+ ? basicSoftwareApplicationSchema(objectGraph, app)
+ : undefined,
+ };
+}
+
+function collectionPageSchemaForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+): WithContext<CollectionPage> {
+ const cardContents = relationshipCollection(data, 'card-contents') ?? [];
+
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+
+ mentions: cardContents.map((app) =>
+ basicSoftwareApplicationSchema(objectGraph, app),
+ ),
+ };
+}
+
+/**
+ *
+ * @param objectGraph
+ * @param response the API response for the Article page
+ * @param props SEO-related props that have already been derrived for the page
+ */
+export function schemaDataForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ response: Opt<DataContainer>,
+ props: SeoProps,
+): Partial<SeoData> {
+ if (!response) {
+ return {};
+ }
+
+ const articleData = dataFromDataContainer(objectGraph, response);
+ if (!articleData) {
+ return {};
+ }
+
+ let schemaContent = commonSchemaForArticlePage(articleData, props);
+
+ const kind = attributeAsString(articleData, 'kind');
+
+ if (kind === 'Collection') {
+ schemaContent = {
+ ...schemaContent,
+ ...collectionPageSchemaForArticlePage(objectGraph, articleData),
+ };
+ } else {
+ schemaContent = {
+ ...schemaContent,
+ ...articleSchemaForArticlePage(objectGraph, articleData),
+ };
+ }
+
+ return {
+ schemaName: 'article-page',
+ schemaContent,
+ };
+}
+
+/// MARK: Full SEO Data
+
+export function seoDataForArticlePage(
+ objectGraph: AppStoreObjectGraph,
+ i18n: I18N,
+ page: ArticlePage,
+ response: Opt<DataContainer>,
+ language: string,
+): SeoData {
+ const { card } = page;
+
+ if (!card) {
+ return {};
+ }
+
+ const storyTitle = stripTags(card.title);
+ const pageTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: storyTitle,
+ });
+
+ let artwork = '';
+ let crop: CropCode = 'fo';
+ let appNames = [];
+
+ if (card.overlay && isLockupListOverlay(card.overlay)) {
+ appNames = card.overlay.lockups.slice(0, 3).map((item) => item.title);
+ } else {
+ appNames = page.shelves
+ .filter(isSmallLockupShelf)
+ .flatMap((shelf) => shelf.items)
+ .slice(0, 3)
+ .map((item) => item.title);
+ }
+
+ const firstParagraphShelf = page.shelves.find(
+ (shelf) => shelf.contentType === 'paragraph',
+ );
+ let description;
+
+ // If an article has a paragraph shelf, we use that to populate the meta description,
+ // otherwise, we build a list of app names for the description.
+ if (page.shelves.length > 1 && firstParagraphShelf?.items) {
+ // The article paragraphs can contain HTML tags, so we strip them out here
+ const text = stripTags(firstParagraphShelf.items[0].text);
+
+ const articleContent = truncateAroundLimit(text, 110, language);
+
+ description = i18n.t(
+ 'ASE.Web.AppStore.Meta.Story.Description.WithArticleContent',
+ { articleContent },
+ );
+ } else if (appNames.length === 1) {
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.One', {
+ storyTitle,
+ featuredAppName: appNames[0],
+ });
+ } else if (appNames.length === 2) {
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.Two', {
+ storyTitle,
+ featuredAppName: appNames[0],
+ featuredAppName2: appNames[1],
+ });
+ } else if (appNames.length >= 3) {
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.Three', {
+ storyTitle,
+ featuredAppName: appNames[0],
+ featuredAppName2: appNames[1],
+ featuredAppName3: appNames[2],
+ });
+ } else if (card.overlay && isLockupOverlay(card.overlay)) {
+ const featuredAppName = card.overlay.lockup.title;
+
+ description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.One', {
+ storyTitle,
+ featuredAppName,
+ });
+ }
+
+ if (card.media && isTodayCardMediaWithArtwork(card.media)) {
+ artwork = card.media.artworks[0].template;
+ } else if (card.media && isTodayCardMediaVideo(card.media)) {
+ artwork = card.media.videos[0].preview.template;
+ } else if (card.media && isTodayCardMediaRiver(card.media)) {
+ artwork = card.media.lockups[0].icon.template;
+ crop = 'wa';
+ } else if (
+ card.media &&
+ (isTodayCardMediaBrandedSingleApp(card.media) ||
+ isTodayCardMediaAppEvent(card.media))
+ ) {
+ if (card.media.artworks.length > 0) {
+ artwork = card.media.artworks[0].template;
+ } else if (card.media.videos.length > 0) {
+ artwork = card.media.videos[0].preview.template;
+ }
+ }
+
+ // We are setting the `link rel="canonical"` tag for iPad, Watch and TV story pages to point to
+ // the iPhone page.
+ let canonicalUrl = page.canonicalURL?.replace(
+ /\/([a-z]{2})\/(ipad|watch|tv)\/story\//,
+ '/$1/iphone/story/',
+ );
+
+ return {
+ pageTitle,
+ crop,
+ canonicalUrl,
+ socialTitle: pageTitle,
+ description: description,
+ socialDescription: description,
+ appleDescription: description,
+ artworkUrl: artwork,
+ twitterCropCode: crop,
+ imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', {
+ title: storyTitle,
+ }),
+ ...schemaDataForArticlePage(objectGraph, response, {
+ title: pageTitle,
+ description,
+ }),
+ };
+}
diff --git a/src/utils/seo/charts-hub-page.ts b/src/utils/seo/charts-hub-page.ts
new file mode 100644
index 0000000..1b670ad
--- /dev/null
+++ b/src/utils/seo/charts-hub-page.ts
@@ -0,0 +1,46 @@
+import type { ChartsHubPage, Lockup } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+import { getPlatformFromPage } from '~/utils/seo/common';
+import { truncateAroundLimit } from '~/utils/string-formatting';
+
+export function seoDataForChartsHubPage(
+ page: ChartsHubPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const platform = getPlatformFromPage(page);
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.ChartsHub.Title', {
+ platform,
+ }),
+ });
+
+ let description;
+ const items = page.charts[0].segments[0].shelves[0].items as Array<Lockup>;
+
+ if (items) {
+ const appsTitles = items.map(({ title }) => title);
+
+ description = truncateAroundLimit(
+ i18n.t('ASE.Web.AppStore.Meta.ChartsHub.Description', {
+ platform,
+ listing1: appsTitles[0],
+ listing2: appsTitles[1],
+ listing3: appsTitles[2],
+ listing4: appsTitles[3],
+ }),
+ 160,
+ language,
+ );
+ }
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/charts-page.ts b/src/utils/seo/charts-page.ts
new file mode 100644
index 0000000..14de925
--- /dev/null
+++ b/src/utils/seo/charts-page.ts
@@ -0,0 +1,58 @@
+import type { TopChartsPage, Lockup } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+import { getPlatformFromPage } from '~/utils/seo/common';
+import {
+ commaSeparatedList,
+ truncateAroundLimit,
+} from '~/utils/string-formatting';
+
+export function seoDataForChartsPage(
+ page: TopChartsPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ // Genre 36 and 6014 are the "All Apps" and "All Games" genres, which we do not want to
+ // include in the page title, since it would then read as "Best All Games Apps - App Store".
+ const category = page.categoriesButtonTitle;
+ const isAllAppsOrGames = ['36', '6014'].includes(page.genreId);
+ const titleLocKey =
+ isAllAppsOrGames || !category
+ ? 'ASE.Web.AppStore.Meta.ChartsHub.Title'
+ : 'ASE.Web.AppStore.Meta.Charts.Title';
+ const platform = getPlatformFromPage(page);
+
+ const title = i18n.t(titleLocKey, {
+ category,
+ platform,
+ });
+
+ let description;
+ const items = page.segments[0].shelves[0].items as Array<Lockup>;
+
+ if (items) {
+ const appTitles = items.map(({ title }) => title).slice(0, 3);
+ const locKey =
+ category && !isAllAppsOrGames
+ ? 'ASE.Web.AppStore.Meta.Charts.Description'
+ : 'ASE.Web.AppStore.Meta.Charts.DescriptionWithoutCategory';
+
+ description = truncateAroundLimit(
+ i18n.t(locKey, {
+ category,
+ platform,
+ listOfApps: commaSeparatedList(appTitles, language),
+ }),
+ 160,
+ );
+ }
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/common.ts b/src/utils/seo/common.ts
new file mode 100644
index 0000000..8873dbd
--- /dev/null
+++ b/src/utils/seo/common.ts
@@ -0,0 +1,75 @@
+import type { Opt } from '@jet/environment/types/optional';
+import type { Organization } from 'schema-dts';
+import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+export const MAX_DESCRIPTION_LENGTH = 160;
+
+export const AppleOrganization: Organization = {
+ '@type': 'Organization',
+ name: 'Apple Inc',
+ url: 'http://www.apple.com',
+ logo: {
+ '@type': 'ImageObject',
+ url: 'https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png',
+ },
+};
+
+export function updateCanonicalURL(
+ page: WebRenderablePage,
+ canonicalURL: string,
+): void {
+ const seoData = page.seoData as Opt<SeoData>;
+
+ if (!seoData) {
+ return;
+ }
+
+ seoData.url = canonicalURL;
+}
+
+export function seoDataForAnyPage(
+ page: WebRenderablePage,
+ i18n: I18N,
+): SeoData {
+ const pageTitle =
+ 'title' in page
+ ? i18n.t('ASE.Web.AppStore.Meta.TitleWithPlatformAndSiteName', {
+ title: page.title,
+ platform: getPlatformFromPage(page),
+ })
+ : i18n.t('ASE.Web.AppStore.Meta.SiteName');
+
+ const description = i18n.t('ASE.Web.AppStore.Meta.Description');
+
+ return {
+ url: page.canonicalURL ?? '',
+ siteName: i18n.t('ASE.Web.AppStore.Meta.SiteName'),
+
+ pageTitle,
+ socialTitle: pageTitle,
+ appleTitle: pageTitle,
+
+ description,
+ socialDescription: description,
+ appleDescription: description,
+
+ width: 1200,
+ height: 630,
+ twitterWidth: 1200,
+ twitterHeight: 630,
+ twitterCropCode: 'wa',
+ crop: 'wa',
+ fileType: 'jpg',
+ artworkUrl: '/assets/images/share/app-store.png',
+
+ twitterSite: '@AppStore',
+ };
+}
+
+export function getPlatformFromPage(page: WebRenderablePage): Opt<string> {
+ return page.webNavigation?.platforms.find((platform) => platform.isActive)
+ ?.action.title;
+}
diff --git a/src/utils/seo/developer-page.ts b/src/utils/seo/developer-page.ts
new file mode 100644
index 0000000..914dd08
--- /dev/null
+++ b/src/utils/seo/developer-page.ts
@@ -0,0 +1,174 @@
+import {
+ type Opt,
+ unwrapOptional as unwrap,
+} from '@jet/environment/types/optional';
+import type { Organization, WithContext } from 'schema-dts';
+
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import {
+ type Data,
+ type DataContainer,
+ dataFromDataContainer,
+} from '@jet-app/app-store/foundation/media/data-structure';
+import { attributeAsString } from '@jet-app/app-store/foundation/media/attributes';
+import { relationshipCollection } from '@jet-app/app-store/foundation/media/relationships';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+import { uniqueById } from '~/utils/array';
+import { basicSoftwareApplicationSchema } from '~/utils/seo/product-page';
+
+/**
+ * Generate a basic {@linkcode Person} schema for a "developer" page
+ *
+ * Note: this is appropriate to be embedded into another schema that
+ * needs to reference the developer
+ */
+export function basicDeveloperSchema(data: Data) {
+ return {
+ '@type': 'Organization',
+ name: attributeAsString(data, 'name') ?? undefined,
+ url: attributeAsString(data, 'url') ?? undefined,
+ } satisfies Organization;
+}
+
+export function buildDeveloperDescription(
+ props: {
+ name: string;
+ },
+ appData: Data[],
+ i18n: I18N,
+) {
+ const { name: developerName } = props;
+
+ switch (appData.length) {
+ case 0:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.ZeroApps',
+ {
+ developerName,
+ },
+ );
+ case 1:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.OneApp',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ },
+ );
+ case 2:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.TwoApps',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ listing2: attributeAsString(appData[1], 'name'),
+ },
+ );
+ case 3:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.ThreeApps',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ listing2: attributeAsString(appData[1], 'name'),
+ listing3: attributeAsString(appData[2], 'name'),
+ },
+ );
+ default:
+ return i18n.t(
+ 'ASE.Web.AppStore.Meta.Developer.Description.ManyApps',
+ {
+ developerName,
+ listing1: attributeAsString(appData[0], 'name'),
+ listing2: attributeAsString(appData[1], 'name'),
+ listing3: attributeAsString(appData[2], 'name'),
+ },
+ );
+ }
+}
+
+/**
+ * Builds the Schema.org meta-data for a "Developer" page
+ *
+ * @param objectGraph The Object Graph
+ * @param developerPageData The `Data` for the Developer page
+ * @param appData The `Data` for all apps related to the Developer apge
+ * @param props Pre-formatted properties also used outside of the Schema
+ * @returns
+ */
+function developerOrganizationSchemaSeoData(
+ objectGraph: AppStoreObjectGraph,
+ developerPageData: Data,
+ appData: Data[],
+ props: {
+ description: string;
+ },
+): Opt<Partial<SeoData>> {
+ const { description } = props;
+
+ const schemaContent: WithContext<Organization> = {
+ '@context': 'https://schema.org',
+
+ ...basicDeveloperSchema(developerPageData),
+
+ description,
+ hasOfferCatalog: {
+ '@type': 'OfferCatalog',
+ itemListElement: appData.map((app) =>
+ basicSoftwareApplicationSchema(objectGraph, app),
+ ),
+ },
+ };
+
+ return {
+ schemaName: 'developer',
+ schemaContent,
+ };
+}
+
+/**
+ * Builds the full `SeoData` requirements for a "Developer" page
+ */
+export function seoDataForDeveloperPage(
+ objectGraph: AppStoreObjectGraph,
+ container: Opt<DataContainer>,
+ i18n: I18N,
+): Partial<SeoData> {
+ if (!container) {
+ return {};
+ }
+
+ const developerPageData = dataFromDataContainer(objectGraph, container);
+ if (!developerPageData) {
+ return {};
+ }
+
+ const allApps = uniqueById([
+ ...unwrap(relationshipCollection(developerPageData, 'atv-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'app-bundles')),
+ ...unwrap(relationshipCollection(developerPageData, 'imessage-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'ios-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'mac-apps')),
+ ...unwrap(relationshipCollection(developerPageData, 'watch-apps')),
+ ]);
+
+ const name = unwrap(attributeAsString(developerPageData, 'name'));
+ const description = buildDeveloperDescription({ name }, allApps, i18n);
+
+ return {
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ ...developerOrganizationSchemaSeoData(
+ objectGraph,
+ developerPageData,
+ allApps,
+ {
+ description,
+ },
+ ),
+ };
+}
diff --git a/src/utils/seo/editorial-shelf-collection-page.ts b/src/utils/seo/editorial-shelf-collection-page.ts
new file mode 100644
index 0000000..dd152df
--- /dev/null
+++ b/src/utils/seo/editorial-shelf-collection-page.ts
@@ -0,0 +1,51 @@
+import type I18N from '@amp/web-apps-localization';
+import type { GenericPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import { isPageHeaderShelf } from '~/components/jet/shelf/PageHeaderShelf.svelte';
+import { getPlatformFromPage } from '~/utils/seo/common';
+import { commaSeparatedList } from '../string-formatting';
+
+export function seoDataForEditorialShelfCollectionPage(
+ page: GenericPage,
+ i18n: I18N,
+): SeoData {
+ let title = page.title;
+ let description;
+ const headerShelf = page.shelves.find(isPageHeaderShelf);
+
+ if (headerShelf) {
+ title = headerShelf.items[0].title;
+ description = headerShelf.items[0].subtitle;
+ }
+
+ if (!description) {
+ const platform = getPlatformFromPage(page);
+ const titles = page.shelves
+ .filter((shelf) => !isPageHeaderShelf(shelf))
+ .flatMap(({ items }) => items)
+ .slice(0, 3)
+ .map((item) => item.title);
+
+ description = i18n.t(
+ 'ASE.Web.AppStore.Meta.EditorialShelfCollection.Description',
+ {
+ platform,
+ listOfApps: commaSeparatedList(titles),
+ },
+ );
+ }
+
+ const titleWithSiteName = i18n.t(
+ 'ASE.Web.AppStore.Meta.TitleWithSiteName',
+ { title },
+ );
+
+ return {
+ pageTitle: titleWithSiteName,
+ socialTitle: titleWithSiteName,
+ appleTitle: titleWithSiteName,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/image-url.ts b/src/utils/seo/image-url.ts
new file mode 100644
index 0000000..b2295f7
--- /dev/null
+++ b/src/utils/seo/image-url.ts
@@ -0,0 +1,71 @@
+import type { URL } from 'schema-dts';
+import type { Opt } from '@jet/environment/types/optional';
+
+import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+import { buildSrcSeo } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+
+const RECOMMENDED_OPEN_GRAPH_IMAGE_WIDTH = 1200;
+const RECOMMENDED_OPEN_GRAPH_IMAGE_HEIGHT = 630;
+
+const DEFAULT_OPEN_GRAPH_IMAGE_CROP = 'bb';
+const DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE = 'png';
+
+/**
+ * Generate an OpenGraph image URL from a Media API artwork definition
+ *
+ * This overrides the default size of the image with the recommendations
+ * from the Open Graph documentation
+ */
+export function buildOpenGraphImageURL(
+ artworkDefinition: Opt<MapLike<JSONValue>>,
+ crop: CropCode = DEFAULT_OPEN_GRAPH_IMAGE_CROP,
+): URL | undefined {
+ if (!artworkDefinition) {
+ return undefined;
+ }
+
+ const { url } = artworkDefinition;
+
+ if (typeof url !== 'string') {
+ return undefined;
+ }
+
+ return (
+ buildSrcSeo(url, {
+ crop,
+ width: RECOMMENDED_OPEN_GRAPH_IMAGE_WIDTH,
+ height: RECOMMENDED_OPEN_GRAPH_IMAGE_HEIGHT,
+ fileType: DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE,
+ }) ?? undefined
+ );
+}
+
+/**
+ * Construct a metadata-friendly URL for some Media API-provided artwork
+ */
+export function buildImageURL(
+ artworkDefinition: Opt<MapLike<JSONValue>>,
+): URL | undefined {
+ if (!artworkDefinition) {
+ return undefined;
+ }
+
+ const { url, width, height } = artworkDefinition;
+
+ if (
+ typeof url !== 'string' ||
+ typeof width !== 'number' ||
+ typeof height !== 'number'
+ ) {
+ return undefined;
+ }
+
+ return (
+ buildSrcSeo(url, {
+ crop: DEFAULT_OPEN_GRAPH_IMAGE_CROP,
+ width,
+ height,
+ fileType: DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE,
+ }) ?? undefined
+ );
+}
diff --git a/src/utils/seo/product-page.ts b/src/utils/seo/product-page.ts
new file mode 100644
index 0000000..bc518ea
--- /dev/null
+++ b/src/utils/seo/product-page.ts
@@ -0,0 +1,353 @@
+import type { Offer, SoftwareApplication, WithContext } from 'schema-dts';
+
+import {
+ type Opt,
+ unwrapOptional as unwrap,
+} from '@jet/environment/types/optional';
+import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models';
+import type { PreviewPlatform } from '@jet-app/app-store/api/models/preview-platform';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import {
+ type AttributePlatform,
+ type Data,
+ type DataContainer,
+ dataFromDataContainer,
+} from '@jet-app/app-store/foundation/media/data-structure';
+import {
+ attributeAsArrayOrEmpty,
+ attributeAsDictionary,
+ attributeAsNumber,
+ attributeAsString,
+} from '@jet-app/app-store/foundation/media/attributes';
+import {
+ platformAttributeAsBooleanOrFalse,
+ platformAttributeAsDictionary,
+ platformAttributeAsString,
+} from '@jet-app/app-store/foundation/media/platform-attributes';
+import {
+ relationship,
+ relationshipCollection,
+} from '@jet-app/app-store/foundation/media/relationships';
+import {
+ asString,
+ asNumber,
+} from '@jet-app/app-store/foundation/json-parsing/server-data';
+import { bestAttributePlatformFromData } from '@jet-app/app-store/common/content/attributes';
+import { offerDataFromData } from '@jet-app/app-store/common/offers/offers';
+
+import type I18N from '@amp/web-apps-localization';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+
+import { basicDeveloperSchema } from './developer-page';
+import { buildOpenGraphImageURL, buildImageURL } from './image-url';
+import { truncateAroundLimit } from '~/utils/string-formatting';
+import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common';
+import { isProductBadgeShelf } from '~/components/jet/shelf/ProductBadgeShelf.svelte';
+
+/// MARK: Primary Image
+
+/**
+ * Determine if the data for a product represents an app that **only** supports iMessage
+ */
+function isMessagesOnly(data: Data, attributePlatform: AttributePlatform) {
+ const hasMessagesExtension = platformAttributeAsBooleanOrFalse(
+ data,
+ attributePlatform,
+ 'hasMessagesExtension',
+ );
+ const isHiddenFromSpringboard = platformAttributeAsBooleanOrFalse(
+ data,
+ attributePlatform,
+ 'isHiddenFromSpringboard',
+ );
+
+ return hasMessagesExtension && isHiddenFromSpringboard;
+}
+
+function buildProductArtworkImage(
+ data: Data,
+ attributePlatform: AttributePlatform,
+) {
+ let iconCropCode: CropCode | undefined = undefined;
+
+ if (isMessagesOnly(data, attributePlatform)) {
+ iconCropCode = 'wb';
+ }
+
+ const deviceFamilies = attributeAsArrayOrEmpty(data, 'deviceFamilies');
+ const hasIOSApp = deviceFamilies.includes('iphone');
+
+ if (hasIOSApp) {
+ iconCropCode = 'wa';
+ }
+
+ const artworkDefinition =
+ platformAttributeAsDictionary(data, attributePlatform, 'artwork') ??
+ attributeAsDictionary(data, 'artwork');
+
+ return buildOpenGraphImageURL(artworkDefinition, iconCropCode);
+}
+
+/// MARK: Screenshots
+
+const PREFERRED_SCREENSHOT_TYPE_BY_PLATFORM: Record<PreviewPlatform, string[]> =
+ {
+ iphone: [
+ 'iphone_d74',
+ 'iphone_d73',
+ 'iphone_6_5',
+ 'iphone_5_8',
+ 'iphone6+',
+ 'iphone6',
+ 'iphone5',
+ 'iphone',
+ ],
+ ipad: ['ipadPro_2018', 'ipad_11', 'ipad', 'ipad_10_5', 'ipadPro'],
+ watch: [
+ 'appleWatch_2024',
+ 'appleWatch_2022',
+ 'appleWatch_2021',
+ 'appleWatch_2018',
+ 'appleWatch',
+ ],
+ tv: ['appletv', 'appleTV'],
+ mac: [],
+ vision: [],
+ };
+
+function buildProductScreenshots(
+ data: Data,
+ attributePlatform: AttributePlatform,
+ previewPlatform: PreviewPlatform,
+) {
+ const screenshotsByType = platformAttributeAsDictionary(
+ data,
+ attributePlatform,
+ 'screenshotsByType',
+ );
+ if (!screenshotsByType) {
+ return undefined;
+ }
+
+ const preferredScreenshotType = PREFERRED_SCREENSHOT_TYPE_BY_PLATFORM[
+ previewPlatform
+ ]?.find((preferredType) => preferredType in screenshotsByType);
+ if (!preferredScreenshotType) {
+ return undefined;
+ }
+
+ const screenshotArtworkDefinitions = screenshotsByType[
+ preferredScreenshotType
+ ] as Array<MapLike<JSONValue>>;
+
+ return screenshotArtworkDefinitions
+ .map((screenshotArtworkDefinition) =>
+ buildImageURL(screenshotArtworkDefinition),
+ )
+ .filter((screenshot) => typeof screenshot !== 'undefined');
+}
+
+function buildOffer(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+ attributePlatform: AttributePlatform,
+): Offer | undefined {
+ const offer = offerDataFromData(objectGraph, data, attributePlatform);
+ if (!offer) {
+ return undefined;
+ }
+
+ const price = asNumber(offer, 'price') ?? undefined;
+ const priceCurrency = asString(offer, 'currencyCode') ?? undefined;
+ const category = !price || price === 0 ? 'free' : undefined;
+
+ return {
+ '@type': 'Offer',
+ price,
+ priceCurrency,
+ category,
+ };
+}
+
+function buildAvailableDevices(data: Data): string | undefined {
+ const deviceFamilies = attributeAsArrayOrEmpty(data, 'deviceFamilies');
+ if (!deviceFamilies) {
+ return undefined;
+ }
+
+ return deviceFamilies
+ .filter((device) => typeof device === 'string')
+ .map((device) => {
+ if (device === 'mac') {
+ return 'Mac';
+ } else if (device.indexOf('ip') === 0) {
+ return device.replace(/^.{2}/g, 'iP');
+ } else if (device === 'tvos') {
+ return 'Apple TV';
+ } else if (device === 'watch') {
+ return 'Apple Watch';
+ }
+
+ return undefined;
+ })
+ .filter((device) => !!device)
+ .join(', ');
+}
+
+/**
+ * Produces a minimal {@linkcode SoftwareApplication} definition from a Media API `app` response
+ *
+ * Appropriate for embedding within another schema
+ */
+export function basicSoftwareApplicationSchema(
+ objectGraph: AppStoreObjectGraph,
+ data: Data,
+) {
+ const allGenreData = relationshipCollection(data, 'genres');
+ const firstGenreData = (allGenreData && allGenreData[0]) ?? undefined;
+
+ const attributePlatformFromData: Opt<AttributePlatform> =
+ bestAttributePlatformFromData(objectGraph, data);
+
+ if (!attributePlatformFromData) {
+ return null;
+ }
+
+ const attributePlatform = unwrap(attributePlatformFromData);
+
+ return {
+ '@type': 'SoftwareApplication',
+
+ name: attributeAsString(data, 'name') ?? undefined,
+ description:
+ platformAttributeAsString(
+ data,
+ attributePlatform,
+ 'description.standard',
+ ) ?? undefined,
+ image: buildProductArtworkImage(data, attributePlatform),
+ availableOnDevice: buildAvailableDevices(data),
+ operatingSystem:
+ platformAttributeAsString(
+ data,
+ attributePlatform,
+ 'requirementsString',
+ ) ?? undefined,
+ offers: buildOffer(objectGraph, data, attributePlatform),
+ applicationCategory: firstGenreData
+ ? attributeAsString(firstGenreData, 'name') ?? undefined
+ : undefined,
+
+ aggregateRating: {
+ '@type': 'AggregateRating',
+ ratingValue:
+ attributeAsNumber(data, 'userRating.value') ?? undefined,
+ reviewCount:
+ attributeAsNumber(data, 'userRating.ratingCount') ?? undefined,
+ },
+ } satisfies SoftwareApplication;
+}
+
+/// MARK: Schema Definition
+
+function softwareApplicationSchemaSeoData(
+ objectGraph: AppStoreObjectGraph,
+ container: Opt<DataContainer>,
+): Opt<Partial<SeoData>> {
+ if (!container) {
+ return null;
+ }
+
+ const productPageData = dataFromDataContainer(objectGraph, container);
+ if (!productPageData) {
+ return null;
+ }
+
+ const developerDataContainer = relationship(productPageData, 'developer');
+ const developerData = dataFromDataContainer(
+ objectGraph,
+ developerDataContainer,
+ );
+
+ const attributePlatform = unwrap(
+ bestAttributePlatformFromData(objectGraph, productPageData),
+ );
+
+ const schemaContent: WithContext<SoftwareApplication> = {
+ '@context': 'https://schema.org',
+
+ ...basicSoftwareApplicationSchema(objectGraph, productPageData),
+
+ author: developerData ? basicDeveloperSchema(developerData) : undefined,
+ screenshot: buildProductScreenshots(
+ productPageData,
+ attributePlatform,
+ unwrap(objectGraph.activeIntent?.previewPlatform),
+ ),
+ };
+
+ return {
+ schemaName: 'software-application',
+ schemaContent,
+ };
+}
+
+export function seoDataForProductPage(
+ objectGraph: AppStoreObjectGraph,
+ page: ShelfBasedProductPage,
+ data: Opt<DataContainer>,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const artworkUrl = page.lockup.icon?.template;
+ const badgeShelf = Object.values(page.shelfMapping).find(
+ isProductBadgeShelf,
+ );
+ const developerName = badgeShelf?.items.find(
+ ({ key }) => key === 'developer',
+ )?.caption;
+
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.Product.Title', {
+ appName: page.lockup.title,
+ }),
+ });
+
+ const descriptionLocKey = developerName
+ ? 'ASE.Web.AppStore.Meta.Product.Description'
+ : 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName';
+
+ const description = truncateAroundLimit(
+ i18n.t(descriptionLocKey, {
+ appName: page.lockup.title,
+ developerName,
+ }),
+ MAX_DESCRIPTION_LENGTH,
+ language,
+ );
+
+ // Removes all query parameters (including `platform=*`) to form the canonical version
+ // of the URL for the `link rel="canonical"` tag.
+ let url = page.canonicalURL;
+ if (url) {
+ const cleanCanonicalUrl = new URL(url);
+ cleanCanonicalUrl.search = '';
+ url = cleanCanonicalUrl.toString();
+ }
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ canonicalUrl: url,
+ artworkUrl,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', {
+ title: page.title,
+ }),
+ ...softwareApplicationSchemaSeoData(objectGraph, data),
+ };
+}
diff --git a/src/utils/seo/reviews-page.ts b/src/utils/seo/reviews-page.ts
new file mode 100644
index 0000000..041d7b8
--- /dev/null
+++ b/src/utils/seo/reviews-page.ts
@@ -0,0 +1,56 @@
+import type {
+ ReviewsPage,
+ ShelfBasedProductPage,
+} from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
+import type I18N from '@amp/web-apps-localization';
+
+import { truncateAroundLimit } from '~/utils/string-formatting';
+import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common';
+import { isProductBadgeShelf } from '~/components/jet/shelf/ProductBadgeShelf.svelte';
+
+export function seoDataForReviewsPage(
+ i18n: I18N,
+ page: ReviewsPage,
+ productPage: ShelfBasedProductPage,
+ objectGraph: AppStoreObjectGraph,
+): SeoData {
+ const appName = productPage.lockup.title;
+ const artworkUrl = productPage.lockup.icon?.template;
+ const badgeShelf = Object.values(productPage.shelfMapping).find(
+ isProductBadgeShelf,
+ );
+ const developerName = badgeShelf?.items.find(
+ ({ key }) => key === 'developer',
+ )?.caption;
+
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.Reviews.Title', {
+ appName,
+ }),
+ });
+
+ const descriptionLocKey = developerName
+ ? 'ASE.Web.AppStore.Meta.Product.Description'
+ : 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName';
+
+ const description = truncateAroundLimit(
+ i18n.t(descriptionLocKey, {
+ appName,
+ developerName,
+ }),
+ MAX_DESCRIPTION_LENGTH,
+ objectGraph.locale.activeLanguage,
+ );
+
+ return {
+ artworkUrl,
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ };
+}
diff --git a/src/utils/seo/search-landing-page.ts b/src/utils/seo/search-landing-page.ts
new file mode 100644
index 0000000..70a8bd4
--- /dev/null
+++ b/src/utils/seo/search-landing-page.ts
@@ -0,0 +1,18 @@
+import type { SearchResultsPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+
+export function seoDataForSearchLandingPage(
+ page: SearchResultsPage,
+ i18n: I18N,
+): SeoData {
+ const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'),
+ });
+
+ return {
+ pageTitle: title,
+ socialTitle: title,
+ appleTitle: title,
+ };
+}
diff --git a/src/utils/seo/search-results-page.ts b/src/utils/seo/search-results-page.ts
new file mode 100644
index 0000000..48bcdce
--- /dev/null
+++ b/src/utils/seo/search-results-page.ts
@@ -0,0 +1,56 @@
+import type { SearchResultsPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+import type I18N from '@amp/web-apps-localization';
+import {
+ isSearchResultShelf,
+ isRenderableInSearchResultsShelf,
+} from '~/components/jet/shelf/SearchResultShelf.svelte';
+import { commaSeparatedList } from '../string-formatting';
+
+export function seoDataForSearchResultsPage(
+ page: SearchResultsPage,
+ i18n: I18N,
+ language: string,
+): SeoData {
+ const term = page?.searchTermContext?.term;
+ const pageTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: page?.searchTermContext?.term,
+ });
+ const shareTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', {
+ title: i18n.t('ASE.Web.AppStore.Meta.SearchResults.Title', {
+ term: page?.searchTermContext?.term,
+ }),
+ });
+
+ const resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null;
+
+ const renderableItems = (resultsShelf?.items ?? []).filter(
+ isRenderableInSearchResultsShelf,
+ );
+
+ const appNames = renderableItems
+ .slice(0, 3)
+ .map((item) => item.lockup.title);
+
+ let description;
+ if (appNames.length) {
+ description = i18n.t(
+ 'ASE.Web.AppStore.Meta.SearchResults.Description',
+ {
+ term,
+ listOfApps: commaSeparatedList(appNames, language),
+ },
+ );
+ }
+
+ return term
+ ? {
+ pageTitle,
+ socialTitle: shareTitle,
+ appleTitle: shareTitle,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ }
+ : {};
+}
diff --git a/src/utils/seo/see-all-page.ts b/src/utils/seo/see-all-page.ts
new file mode 100644
index 0000000..bbdf369
--- /dev/null
+++ b/src/utils/seo/see-all-page.ts
@@ -0,0 +1,47 @@
+import type I18N from '@amp/web-apps-localization';
+import type { SeeAllPage } from '@jet-app/app-store/api/models';
+import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+
+export function seoDataForSeeAllPage(page: SeeAllPage, i18n: I18N): SeoData {
+ let title = i18n.t('ASE.Web.AppStore.Meta.Product.Title');
+ const shelfName = {
+ reviews: 'productRatings',
+ 'customers-also-bought-apps': 'similarItems',
+ 'developer-other-apps': 'moreByDeveloper',
+ }[page.seeAllType];
+
+ if (shelfName) {
+ const shelf = page.shelfMapping[shelfName];
+ title = `${page.title} - ${shelf.title}`;
+ }
+
+ const titleWithSiteName = i18n.t(
+ 'ASE.Web.AppStore.Meta.TitleWithSiteName',
+ { title },
+ );
+
+ const descriptionLocKey =
+ {
+ reviews: 'ASE.Web.AppStore.SeeAll.Reviews.Meta.Description',
+ 'customers-also-bought-apps':
+ 'ASE.Web.AppStore.SeeAll.CustomersAlsoBoughtApps.Meta.Description',
+ 'developer-other-apps':
+ 'ASE.Web.AppStore.SeeAll.DeveloperOtherApps.Meta.Description',
+ }[page.seeAllType] ||
+ 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName';
+ const description = i18n.t(descriptionLocKey, {
+ appName: page.title,
+ });
+
+ const artworkUrl = page.lockup.icon?.template;
+
+ return {
+ pageTitle: titleWithSiteName,
+ socialTitle: titleWithSiteName,
+ appleTitle: titleWithSiteName,
+ description,
+ socialDescription: description,
+ appleDescription: description,
+ artworkUrl,
+ };
+}
diff --git a/src/utils/shelves.ts b/src/utils/shelves.ts
new file mode 100644
index 0000000..e144f4b
--- /dev/null
+++ b/src/utils/shelves.ts
@@ -0,0 +1,56 @@
+import type {
+ ShelfBasedProductPage,
+ Shelf,
+} from '@jet-app/app-store/api/models';
+import { isProductMediaShelf } from '~/components/jet/shelf/ProductMediaShelf.svelte';
+
+type ShelfWithExpandedMedia = Shelf & {
+ expandedMedia?: ShelfWithExpandedMedia[];
+};
+
+export const getProductPageShelvesForOrdering = (
+ page: ShelfBasedProductPage,
+ shelfOrder: string,
+): Shelf[] => {
+ return (
+ page.shelfOrderings[shelfOrder]
+ ?.map((shelfIdentifier) => page.shelfMapping[shelfIdentifier])
+ // The type system doesn't reflect this, but ordering identifier may be provided for
+ // shelves that do not exist. We should probably filter those out
+ .filter((shelf): shelf is Shelf => !!shelf)
+ );
+};
+
+export const getProductPageShelvesWithExpandedMedia = (
+ page: ShelfBasedProductPage,
+): ShelfWithExpandedMedia[] => {
+ const { defaultShelfOrdering = 'notPurchasedOrdering' } = page;
+
+ const shelves = getProductPageShelvesForOrdering(
+ page,
+ defaultShelfOrdering,
+ ) as ShelfWithExpandedMedia[];
+
+ // find the location of the product media of selected platform in shelves
+ const mainMediaShelfIndex = shelves.findIndex((shelf) =>
+ isProductMediaShelf(shelf),
+ );
+
+ let expandedMedia: ShelfWithExpandedMedia[] | undefined;
+
+ if (mainMediaShelfIndex !== -1) {
+ expandedMedia = getProductPageShelvesForOrdering(
+ page,
+ 'notPurchasedOrdering_ExpandedMedia',
+ )
+ .filter((shelf) => isProductMediaShelf(shelf))
+ // filter out the product media shelf of selected platform to avoid duplicate shelves
+ .filter(({ id }) => id !== shelves[mainMediaShelfIndex].id);
+ }
+
+ if (expandedMedia) {
+ shelves[mainMediaShelfIndex].expandedMedia = expandedMedia;
+ }
+
+ return shelves;
+};
diff --git a/src/utils/storefront-data.ts b/src/utils/storefront-data.ts
new file mode 100644
index 0000000..9e2d848
--- /dev/null
+++ b/src/utils/storefront-data.ts
@@ -0,0 +1,15 @@
+import type {
+ Region,
+ Languages,
+} from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types';
+import type { StorefrontNames } from '@amp/web-app-components/src/components/banners/types';
+import {
+ regions as outputtedRegions,
+ languages as outputtedLanguages,
+} from 'virtual:storefronts';
+import { getFormattedStorefrontNameTranslations } from '@amp/web-app-storefronts';
+
+export const regions: Region[] = outputtedRegions;
+export const languages: Languages = outputtedLanguages;
+export const storefrontNameTranslations: StorefrontNames =
+ getFormattedStorefrontNameTranslations(regions);
diff --git a/src/utils/string-formatting.ts b/src/utils/string-formatting.ts
new file mode 100644
index 0000000..ff9f8bb
--- /dev/null
+++ b/src/utils/string-formatting.ts
@@ -0,0 +1,126 @@
+import type I18N from '@amp/web-apps-localization';
+import he from 'he';
+
+export function isString(string: unknown): string is string {
+ return typeof string === 'string';
+}
+
+export function concatWithMiddot(pieces: string[], i18n: I18N): string {
+ if (!pieces.length) {
+ return '';
+ }
+
+ return (
+ pieces.reduce((memo, current) => {
+ return i18n.t('ASE.Web.AppStore.ContentA.Middot.ContentB', {
+ contentA: memo,
+ contentB: current,
+ });
+ }) || ''
+ );
+}
+
+/**
+ * Truncates a block of text to fit within a character limit, with a bias towards ending on a
+ * full sentence. If no complete sentence fits within the limit, it falls back to a word-based
+ * truncation with an ellipsis.
+ *
+ * @param {string} text - The text to truncate.
+ * @param {number} limit - The maximum number of characters allowed before truncation.
+ * @param {string} [locale=en_US] - The locale to use when breaking the text into segments.
+ * @returns {string} Truncated text clipped to the limit, ideally ending on a natural stopping point.
+ */
+export function truncateAroundLimit(
+ text: string,
+ limit: number,
+ locale: string = 'en-US',
+): string {
+ // If the text is shorter than the limit, return all the text, unaltered.
+ if (text.length <= limit) {
+ return text;
+ }
+
+ const decodedText = he.decode(text);
+
+ const isSegemnterSupported = typeof Intl.Segmenter === 'function';
+ const terminatingPunctuation = '…';
+
+ // A very naive fallback if the browser doesn't support `Segementer`,
+ // which just truncates the text to the last space before the `limit`.
+ if (!isSegemnterSupported) {
+ const truncatedText = decodedText.slice(0, limit);
+ const indexOfLastSpace = truncatedText.lastIndexOf(' ');
+ if (indexOfLastSpace) {
+ return (
+ truncatedText.slice(0, indexOfLastSpace).trim() +
+ terminatingPunctuation
+ );
+ } else {
+ // If the text is an _exteremly_ long word or block of text, like a URL
+ return truncatedText.trim() + terminatingPunctuation;
+ }
+ }
+
+ const sentences = Array.from(
+ new Intl.Segmenter(locale, { granularity: 'sentence' }).segment(text),
+ (s) => s.segment,
+ );
+
+ let result = '';
+ for (const sentence of sentences) {
+ // If there is still room to add another sentence without going over the limit, add it.
+ if (result.length + sentence.length <= limit) {
+ result += sentence;
+ } else {
+ break;
+ }
+ }
+
+ result = result.trim();
+
+ // If the result we built based on full sentences is close-enough to the desired limit
+ // (e.g. within the threshold of 75% of 160), we can use it.
+ if (result.length >= limit * 0.75) {
+ return result;
+ }
+
+ // Otherwise, fallback to building up single words until we approach the limit.
+ const segments = Array.from(
+ new Intl.Segmenter(locale, { granularity: 'word' }).segment(
+ decodedText,
+ ),
+ );
+
+ result = '';
+ for (const { segment } of segments) {
+ if (result.length + segment.length <= limit) {
+ result += segment;
+ } else {
+ break;
+ }
+ }
+
+ return result.trim() + terminatingPunctuation;
+}
+
+export function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
+
+export function commaSeparatedList(items: Array<string>, locale = 'en') {
+ return new Intl.ListFormat(locale, {
+ style: 'long',
+ type: 'conjunction',
+ }).format(items);
+}
+
+export function stripTags(text: string) {
+ return text.replace(/(<([^>]+)>)/gi, '');
+}
+
+export function stripUnicodeWhitespace(text: string) {
+ return text.replace(/[\u0000-\u001F]/g, '');
+}
diff --git a/src/utils/transition.ts b/src/utils/transition.ts
new file mode 100644
index 0000000..e89b038
--- /dev/null
+++ b/src/utils/transition.ts
@@ -0,0 +1,45 @@
+import { cubicOut } from 'svelte/easing';
+import type { EasingFunction, TransitionConfig } from 'svelte/transition';
+
+interface FlyAndBlurParams {
+ // Time (ms) before the animation starts.
+ delay?: number;
+ // Total animation time (ms).
+ duration?: number;
+ // Easing function (defaults to cubicOut).
+ easing?: EasingFunction;
+ // Horizontal offset in pixels at start (like `fly`).
+ x?: number;
+ // Vertical offset in pixels at start (like `fly`).
+ y?: number;
+ // Initial blur radius in pixels.
+ blur?: number;
+}
+
+export function flyAndBlur(
+ node: Element,
+ {
+ delay = 0,
+ duration = 420,
+ easing = cubicOut,
+ x = 0,
+ y = 0,
+ blur = 3,
+ }: FlyAndBlurParams = {},
+): TransitionConfig {
+ const style = getComputedStyle(node);
+ const initialOpacity = +style.opacity;
+
+ return {
+ delay,
+ duration,
+ easing,
+ css: (t: number, u: number) => {
+ return `
+ transform: translate(${x * u}px, ${y * u}px);
+ opacity: ${initialOpacity * t};
+ filter: blur(${blur * u}px);
+ `;
+ },
+ };
+}
diff --git a/src/utils/types.ts b/src/utils/types.ts
new file mode 100644
index 0000000..4cb85aa
--- /dev/null
+++ b/src/utils/types.ts
@@ -0,0 +1,17 @@
+/**
+ * Determine if {@linkcode input} matches the `"object"` type
+ */
+export function isObject(input: unknown): input is object {
+ return typeof input === 'object' && !!input;
+}
+
+type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
+
+/**
+ * Helper type for creating an exclusive union between two types
+ *
+ * @see {@link https://stackoverflow.com/a/53229567/2250435 | StackOverflow Post}
+ */
+export type XOR<T, U> = T | U extends object
+ ? (Without<T, U> & U) | (Without<U, T> & T)
+ : T | U;
diff --git a/src/utils/url.ts b/src/utils/url.ts
new file mode 100644
index 0000000..8596d89
--- /dev/null
+++ b/src/utils/url.ts
@@ -0,0 +1,13 @@
+/**
+ * Removes the protcol, host and port from a URL, returning
+ * just the path and search portions
+ *
+ * This is useful for taking a URL that points to the production site
+ * and removing anything specific to the location that it is deployed,
+ * creating a partial URL that works both locally or when deployed
+ */
+export function stripHost(input: string): string {
+ const url = new URL(input);
+
+ return url.pathname + url.search;
+}
diff --git a/src/utils/video-poster.ts b/src/utils/video-poster.ts
new file mode 100644
index 0000000..e2e32ed
--- /dev/null
+++ b/src/utils/video-poster.ts
@@ -0,0 +1,27 @@
+import type { Artwork } from '@jet-app/app-store/api/models';
+import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
+import type { Size } from '@amp/web-app-components/src/types';
+import type { NamedProfile } from 'src/config/components/artwork';
+import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+import { getDataFromProfile } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
+
+export const buildPoster = (
+ preview: Artwork,
+ profile: NamedProfile | Profile,
+ mediaQuery: string,
+): ReturnType<typeof buildSrc> => {
+ const profileData = getDataFromProfile(profile);
+ const imageAttributes = profileData[mediaQuery as Size] || preview;
+ const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 2;
+
+ return buildSrc(
+ preview.template,
+ {
+ crop: 'sr',
+ width: imageAttributes.width * dpr,
+ height: imageAttributes.height * dpr,
+ fileType: 'webp',
+ },
+ {},
+ );
+};