From 86b87b9382e33b71b179148d5bf519911a09dd7d Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Tue, 16 Jun 2026 18:17:52 +0200 Subject: [PATCH 1/2] library: add interactive cover banner with hover hotspots (initial state) Initial version of the library cover animation: hovering a building reveals a glow silhouette and a glass info card, driven by percentage-based hotspot geometry over the 1440x852 design frame. Hover-by-default with a switchable click mode. Hotspot coordinates and placeholder library data are a first pass and will be refined; real library content is still to come. Co-Authored-By: Claude Opus 4.7 --- public/library/images/hotspots/house-1.svg | 18 ++ public/library/images/hotspots/house-2.svg | 23 +++ public/library/images/hotspots/house-3.svg | 23 +++ public/library/images/hotspots/house-4.svg | 23 +++ public/library/images/hotspots/house-5.svg | 23 +++ public/library/images/icons/all.svg | 182 +++++++++--------- .../LibraryInfoCard.module.scss | 106 ++++++++++ .../LibraryInfoCard/LibraryInfoCard.tsx | 73 +++++++ .../LibraryInfoCard/LibraryInfoCard.types.ts | 10 + .../molecules/LibraryInfoCard/index.tsx | 2 + .../InteractiveCover.module.scss | 104 ++++++++++ .../InteractiveCover/InteractiveCover.tsx | 134 +++++++++++++ .../InteractiveCover.types.ts | 13 ++ .../InteractiveCover/coverHotspots.ts | 135 +++++++++++++ .../organisms/InteractiveCover/index.tsx | 2 + .../InteractiveCover/useHotspotTrigger.ts | 52 +++++ src/layouts/library/Home/Home.module.scss | 23 ++- src/layouts/library/Home/Home.tsx | 33 ++-- 18 files changed, 862 insertions(+), 117 deletions(-) create mode 100644 public/library/images/hotspots/house-1.svg create mode 100644 public/library/images/hotspots/house-2.svg create mode 100644 public/library/images/hotspots/house-3.svg create mode 100644 public/library/images/hotspots/house-4.svg create mode 100644 public/library/images/hotspots/house-5.svg create mode 100644 src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss create mode 100644 src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.tsx create mode 100644 src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts create mode 100644 src/components/library/molecules/LibraryInfoCard/index.tsx create mode 100644 src/components/library/organisms/InteractiveCover/InteractiveCover.module.scss create mode 100644 src/components/library/organisms/InteractiveCover/InteractiveCover.tsx create mode 100644 src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts create mode 100644 src/components/library/organisms/InteractiveCover/coverHotspots.ts create mode 100644 src/components/library/organisms/InteractiveCover/index.tsx create mode 100644 src/components/library/organisms/InteractiveCover/useHotspotTrigger.ts diff --git a/public/library/images/hotspots/house-1.svg b/public/library/images/hotspots/house-1.svg new file mode 100644 index 00000000..d9d10001 --- /dev/null +++ b/public/library/images/hotspots/house-1.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-2.svg b/public/library/images/hotspots/house-2.svg new file mode 100644 index 00000000..5631cb18 --- /dev/null +++ b/public/library/images/hotspots/house-2.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-3.svg b/public/library/images/hotspots/house-3.svg new file mode 100644 index 00000000..a13d5d47 --- /dev/null +++ b/public/library/images/hotspots/house-3.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-4.svg b/public/library/images/hotspots/house-4.svg new file mode 100644 index 00000000..7a426df4 --- /dev/null +++ b/public/library/images/hotspots/house-4.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-5.svg b/public/library/images/hotspots/house-5.svg new file mode 100644 index 00000000..d56bd049 --- /dev/null +++ b/public/library/images/hotspots/house-5.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/icons/all.svg b/public/library/images/icons/all.svg index cb1ce8a0..847fde7c 100644 --- a/public/library/images/icons/all.svg +++ b/public/library/images/icons/all.svg @@ -1,91 +1,91 @@ - + diff --git a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss new file mode 100644 index 00000000..9ae89c9c --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss @@ -0,0 +1,106 @@ +.card { + // Translucent green wash + blur so the cover art reads through the card, + // matching the Figma "glass" panel. Exposed as a variable so it can be + // dropped to fully transparent without touching the rest of the styles. + --info-card-bg: rgba(32, 54, 44, 0.35); + + width: 392px; + height: 314px; + max-width: 100%; + padding: 20px 16px; + display: flex; + flex-direction: column; + color: #ffffff; + border-radius: 4px; + // Start fully transparent with no blur, then ramp both up on activation. + // Animating the blur *value* (not just the parent's opacity) forces the + // browser to composite the backdrop progressively, so the glass fades in + // smoothly instead of popping in once the reveal settles. + background: transparent; + border: 1px solid rgba(255, 255, 255, 0); + backdrop-filter: blur(0); + -webkit-backdrop-filter: blur(0); + transition: + background 0.45s ease, + border-color 0.45s ease, + backdrop-filter 0.45s ease, + -webkit-backdrop-filter 0.45s ease; + + &.active { + background: var(--info-card-bg); + border-color: #ffffff; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + + @media (max-width: 768px) { + width: 100%; + height: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: + background 0.2s ease, + border-color 0.2s ease; + } +} + +.heading { + margin: 0; + font-family: var(--font-source-serif); + font-weight: 700; + font-size: 24px; + line-height: 1.25; + color: #ffffff; +} + +.divider { + display: block; + height: 1px; + width: 100%; + margin: 16px 0; + background: rgba(255, 255, 255, 0.7); +} + +.section + .section { + margin-top: 20px; +} + +.label { + font-weight: 700; + font-size: 16px; + line-height: 1.4; + color: #ffffff; +} + +.about { + margin-top: 8px; + font-weight: 400; + font-size: 16px; + line-height: 1.5; + color: #ffffff; +} + +.objects { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 20px; +} + +.object { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.count { + font-weight: 400; + font-size: 16px; + line-height: 1; + color: #ffffff; + white-space: nowrap; +} diff --git a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.tsx b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.tsx new file mode 100644 index 00000000..5115d2d3 --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.tsx @@ -0,0 +1,73 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { Icon, IconName } from '@components/library/atoms/Icon'; +import { + TagType, + Text, + TypographyVariant, +} from '@components/library/atoms/Text'; + +import type { LibraryInfoCardProps } from './LibraryInfoCard.types'; + +import styles from './LibraryInfoCard.module.scss'; + +export function LibraryInfoCard({ + libraryName, + about, + bookCount, + videoCount, + songCount, + isActive, + className, +}: LibraryInfoCardProps) { + const objects: { name: IconName; count: number; label: string }[] = [ + { name: IconName.Book, count: bookCount, label: 'Books' }, + { name: IconName.Video, count: videoCount, label: 'Videos' }, + { name: IconName.Audio, count: songCount, label: 'Music' }, + ]; + + return ( +
+ + {libraryName} + + + + +
+ + About + + + {about} + +
+ +
+ + Objects + +
+ {objects.map(({ name, count, label }) => ( + + + + {count} {label} + + + ))} +
+
+
+ ); +} diff --git a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts new file mode 100644 index 00000000..a2ea684a --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts @@ -0,0 +1,10 @@ +export interface LibraryInfoCardProps { + libraryName: string; + about: string; + bookCount: number; + videoCount: number; + songCount: number; + /** Drives the glass background/blur fade-in so it ramps instead of popping. */ + isActive?: boolean; + className?: string; +} diff --git a/src/components/library/molecules/LibraryInfoCard/index.tsx b/src/components/library/molecules/LibraryInfoCard/index.tsx new file mode 100644 index 00000000..9364ec99 --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/index.tsx @@ -0,0 +1,2 @@ +export * from './LibraryInfoCard'; +export * from './LibraryInfoCard.types'; diff --git a/src/components/library/organisms/InteractiveCover/InteractiveCover.module.scss b/src/components/library/organisms/InteractiveCover/InteractiveCover.module.scss new file mode 100644 index 00000000..e9a35a4f --- /dev/null +++ b/src/components/library/organisms/InteractiveCover/InteractiveCover.module.scss @@ -0,0 +1,104 @@ +// Coordinates in coverHotspots.ts are percentages of the cover frame, so the +// frame is locked to the artwork's native 1536x1024 (3:2) ratio and every +// hotspot tracks the art as it scales. No runtime measurement needed. +$cover-aspect: calc(3 / 2); + +.frame { + position: relative; + width: 100%; + aspect-ratio: $cover-aspect; + overflow: hidden; +} + +.image { + object-fit: cover; + object-position: center; +} + +.layer { + position: absolute; + inset: 0; +} + +.trigger { + position: absolute; + margin: 0; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + // Sits above the highlight art so the pointer always hits the building. + z-index: 2; + + &:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 2px; + border-radius: 4px; + } +} + +.highlight { + position: absolute; + z-index: 1; + pointer-events: none; + user-select: none; + // 0 -> 100 reveal: invisible glow, scaled down, clipped from the bottom up. + opacity: 0; + transform: scale(0.96); + transform-origin: 50% 100%; + clip-path: inset(100% 0 0 0); + filter: drop-shadow(0 0 0 rgba(255, 255, 255, 0)); + transition: + opacity 0.45s ease, + transform 0.55s cubic-bezier(0.22, 1, 0.36, 1), + clip-path 0.65s cubic-bezier(0.22, 1, 0.36, 1), + filter 0.6s ease; + will-change: opacity, transform, clip-path, filter; + + &.active { + opacity: 1; + transform: scale(1); + clip-path: inset(0 0 0 0); + filter: drop-shadow(0 0 18px rgba(255, 255, 255, 0.55)); + } +} + +.card { + position: absolute; + z-index: 3; + width: 392px; + max-width: 100%; + pointer-events: none; + opacity: 0; + transform: translateY(12px); + transition: + opacity 0.4s ease 0.12s, + transform 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.12s; + + &.cardActive { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } +} + +// Respect reduced-motion: reveal everything instantly, no rise or glow ramp. +@media (prefers-reduced-motion: reduce) { + .highlight, + .card { + transition: opacity 0.2s ease; + transform: none; + clip-path: none; + } + + .highlight.active { + transform: none; + } +} + +@media (max-width: 768px) { + .card { + width: 60%; + min-width: 0; + } +} diff --git a/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx b/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx new file mode 100644 index 00000000..806e2ec6 --- /dev/null +++ b/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx @@ -0,0 +1,134 @@ +import classNames from 'classnames'; +import Image from 'next/image'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { LibraryInfoCard } from '@components/library/molecules/LibraryInfoCard'; + +import { CoverHotspot, coverHotspots } from './coverHotspots'; +import type { InteractiveCoverProps } from './InteractiveCover.types'; +import { HotspotMode, useHotspotTrigger } from './useHotspotTrigger'; + +import styles from './InteractiveCover.module.scss'; + +interface HotspotProps { + hotspot: CoverHotspot; + mode: HotspotMode; + activeId: string | null; + setActiveId: (id: string | null) => void; +} + +function Hotspot({ hotspot, mode, activeId, setActiveId }: HotspotProps) { + const { isActive, triggerProps } = useHotspotTrigger({ + id: hotspot.id, + mode, + activeId, + setActiveId, + }); + + const { hit, highlight, card, library } = hotspot; + + return ( + <> +