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..3184b5a7
--- /dev/null
+++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss
@@ -0,0 +1,108 @@
+.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;
+ // Design height, but let real (longer) library copy grow the card instead
+ // of overflowing the fixed 314px box.
+ min-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%;
+ min-height: 0;
+ }
+}
+
+@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..492817b6
--- /dev/null
+++ b/src/components/library/organisms/InteractiveCover/InteractiveCover.module.scss
@@ -0,0 +1,106 @@
+// 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;
+ // Only hint the cheap compositor-friendly props; promoting clip-path and
+ // filter as well permanently allocated 4 layers per hotspot (20 total).
+ will-change: opacity, transform;
+
+ &.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..0727170b
--- /dev/null
+++ b/src/components/library/organisms/InteractiveCover/InteractiveCover.tsx
@@ -0,0 +1,141 @@
+import classNames from 'classnames';
+import Image from 'next/image';
+import React, {
+ Dispatch,
+ SetStateAction,
+ 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: Dispatch>;
+}
+
+function Hotspot({ hotspot, mode, activeId, setActiveId }: HotspotProps) {
+ const { isActive, triggerProps } = useHotspotTrigger({
+ id: hotspot.id,
+ mode,
+ activeId,
+ setActiveId,
+ });
+
+ const { hit, highlight, card, library } = hotspot;
+
+ return (
+ <>
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+ >
+ );
+}
+
+export function InteractiveCover({
+ src,
+ alt,
+ mode = 'hover',
+ className,
+}: InteractiveCoverProps) {
+ const [activeId, setActiveId] = useState(null);
+ const frameRef = useRef(null);
+
+ // In click mode the card stays open until dismissed, so an outside click or
+ // Escape needs to close it. Hover mode dismisses itself via onMouseLeave.
+ useEffect(() => {
+ if (mode !== 'click' || !activeId) {
+ return;
+ }
+
+ const onMouseDown = (event: MouseEvent) => {
+ if (!frameRef.current?.contains(event.target as Node)) {
+ setActiveId(null);
+ }
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setActiveId(null);
+ }
+ };
+
+ document.addEventListener('mousedown', onMouseDown);
+ document.addEventListener('keydown', onKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', onMouseDown);
+ document.removeEventListener('keydown', onKeyDown);
+ };
+ }, [mode, activeId]);
+
+ const clearActive = useCallback(() => setActiveId(null), []);
+
+ return (
+
+
+
+
+ {coverHotspots.map(hotspot => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts b/src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts
new file mode 100644
index 00000000..2f99557f
--- /dev/null
+++ b/src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts
@@ -0,0 +1,13 @@
+import type { HotspotMode } from './useHotspotTrigger';
+
+export interface InteractiveCoverProps {
+ /** Cover artwork rendered behind the hotspots. */
+ src: string;
+ alt: string;
+ /**
+ * How a hotspot reveals its card. Defaults to 'hover'; flip to 'click' for
+ * touch-first contexts without touching any markup.
+ */
+ mode?: HotspotMode;
+ className?: string;
+}
diff --git a/src/components/library/organisms/InteractiveCover/coverHotspots.ts b/src/components/library/organisms/InteractiveCover/coverHotspots.ts
new file mode 100644
index 00000000..1ba9875a
--- /dev/null
+++ b/src/components/library/organisms/InteractiveCover/coverHotspots.ts
@@ -0,0 +1,135 @@
+import type { LibraryInfoCardProps } from '@components/library/molecules/LibraryInfoCard';
+
+/**
+ * All geometry is expressed in percentages of the cover's 1440x852 design
+ * frame, so hotspots track the artwork as it scales responsively without any
+ * runtime measurement. Library content is static for now and will later be fed
+ * from the six real libraries — keeping geometry and data separate means that
+ * swap is data-only.
+ */
+
+interface Box {
+ left: number;
+ top: number;
+ width: number;
+ height?: number;
+}
+
+export interface CoverHotspot {
+ id: string;
+ /** Invisible trigger region over the building itself. */
+ hit: Box;
+ /** Glow silhouette overlay (house + tree + leader line). */
+ highlight: { src: string; alt: string } & Box;
+ /** Top-left anchor of the info card at the end of the leader line. */
+ card: { left: number; top: number };
+ library: Omit;
+}
+
+export const coverHotspots: CoverHotspot[] = [
+ {
+ id: 'house-1',
+ hit: { left: 38.6, top: 11.9, width: 16.8, height: 35.9 },
+ highlight: {
+ src: '/library/images/hotspots/house-1.svg',
+ alt: '',
+ left: 38,
+ top: 6.57,
+ width: 34.55,
+ },
+ card: { left: 59.03, top: 19.01 },
+ library: {
+ libraryName: 'John’s Library',
+ about:
+ 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cih sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus',
+ bookCount: 123,
+ videoCount: 52,
+ songCount: 17,
+ },
+ },
+ {
+ id: 'house-2',
+ // Estimated from the screenshot — confirm/refine via devtools like house-1.
+ hit: { left: 7, top: 2, width: 30, height: 60 },
+ highlight: {
+ src: '/library/images/hotspots/house-2.svg',
+ alt: '',
+ left: 6.8,
+ top: 0,
+ width: 63,
+ },
+ card: { left: 59.03, top: 19.01 },
+ library: {
+ libraryName: 'Sarah’s Library',
+ about:
+ 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cih sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus',
+ bookCount: 88,
+ videoCount: 34,
+ songCount: 12,
+ },
+ },
+ {
+ id: 'house-3',
+ // Estimated from the screenshot — confirm/refine via devtools like house-1.
+ hit: { left: 25, top: 43, width: 17, height: 34 },
+ highlight: {
+ src: '/library/images/hotspots/house-3.svg',
+ alt: '',
+ left: 24,
+ top: 16.5,
+ width: 27,
+ },
+ card: { left: 50.3, top: 10.5 },
+ library: {
+ libraryName: 'Liam’s Library',
+ about:
+ 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cih sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus',
+ bookCount: 64,
+ videoCount: 21,
+ songCount: 9,
+ },
+ },
+ {
+ id: 'house-4',
+ // Estimated from the screenshot — confirm/refine via devtools like house-1.
+ hit: { left: 11, top: 57, width: 14, height: 17 },
+ highlight: {
+ src: '/library/images/hotspots/house-4.svg',
+ alt: '',
+ left: 10.4,
+ top: 25.9,
+ width: 15,
+ },
+ card: { left: 24.7, top: 26.4 },
+ library: {
+ libraryName: 'Mia’s Library',
+ about:
+ 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cih sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus',
+ bookCount: 47,
+ videoCount: 18,
+ songCount: 6,
+ },
+ },
+ {
+ id: 'house-5',
+ // Estimated from the screenshot — confirm/refine via devtools like house-1.
+ // Leader line exits to the LEFT, so the card sits left of the lantern.
+ hit: { left: 71, top: 38, width: 12, height: 26 },
+ highlight: {
+ src: '/library/images/hotspots/house-5.svg',
+ alt: '',
+ left: 67.5,
+ top: 19,
+ width: 16,
+ },
+ card: { left: 40, top: 14 },
+ library: {
+ libraryName: 'Noah’s Library',
+ about:
+ 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cih sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus',
+ bookCount: 31,
+ videoCount: 14,
+ songCount: 4,
+ },
+ },
+];
diff --git a/src/components/library/organisms/InteractiveCover/index.tsx b/src/components/library/organisms/InteractiveCover/index.tsx
new file mode 100644
index 00000000..8a56739a
--- /dev/null
+++ b/src/components/library/organisms/InteractiveCover/index.tsx
@@ -0,0 +1,2 @@
+export * from './InteractiveCover';
+export * from './InteractiveCover.types';
diff --git a/src/components/library/organisms/InteractiveCover/useHotspotTrigger.ts b/src/components/library/organisms/InteractiveCover/useHotspotTrigger.ts
new file mode 100644
index 00000000..d83945f1
--- /dev/null
+++ b/src/components/library/organisms/InteractiveCover/useHotspotTrigger.ts
@@ -0,0 +1,55 @@
+import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
+
+/**
+ * Interaction model for the cover hotspots. The whole feature is wired through
+ * this single switch so flipping the trigger from hover to tap (e.g. for touch
+ * or a future product decision) is a one-line change in the organism — the
+ * hotspot markup and the active-id state never move.
+ */
+export type HotspotMode = 'hover' | 'click';
+
+interface UseHotspotTriggerArgs {
+ id: string;
+ mode: HotspotMode;
+ activeId: string | null;
+ setActiveId: Dispatch>;
+}
+
+/** Event handlers to spread onto a hotspot trigger, shaped by the active mode. */
+export function useHotspotTrigger({
+ id,
+ mode,
+ activeId,
+ setActiveId,
+}: UseHotspotTriggerArgs) {
+ const isActive = activeId === id;
+
+ // Functional updaters so the handlers never read a stale activeId from
+ // closure scope under React's batched/concurrent updates.
+ const open = useCallback(() => setActiveId(id), [id, setActiveId]);
+ const close = useCallback(
+ () => setActiveId(prev => (prev === id ? null : prev)),
+ [id, setActiveId],
+ );
+ const toggle = useCallback(
+ () => setActiveId(prev => (prev === id ? null : id)),
+ [id, setActiveId],
+ );
+
+ const triggerProps = useMemo(() => {
+ if (mode === 'click') {
+ return { onClick: toggle };
+ }
+
+ // Pointer drives hover; focus/blur mirror it so keyboard users get the
+ // same reveal without needing the click path.
+ return {
+ onMouseEnter: open,
+ onMouseLeave: close,
+ onFocus: open,
+ onBlur: close,
+ };
+ }, [mode, toggle, open, close]);
+
+ return { isActive, triggerProps };
+}
diff --git a/src/layouts/library/Home/Home.module.scss b/src/layouts/library/Home/Home.module.scss
index 9d3c793e..b476e71d 100644
--- a/src/layouts/library/Home/Home.module.scss
+++ b/src/layouts/library/Home/Home.module.scss
@@ -1,17 +1,19 @@
.banner {
position: relative;
+ // The artwork is authored at 1440px wide; on wider screens the cover is
+ // centered and the surrounding gutters are filled with the painting's own
+ // misty green so the banner reads edge-to-edge.
+ background: #5b7d6f;
- .image {
- height: auto;
- display: flex;
+ .bannerInner {
+ position: relative;
width: 100%;
- max-width: 100%;
+ max-width: 1440px;
+ margin: 0 auto;
+ }
- @media (max-width: 590px) {
- min-height: 448px;
- object-fit: cover;
- object-position: left;
- }
+ .image {
+ width: 100%;
}
.button {
@@ -19,6 +21,9 @@
left: 38px;
max-width: 175px;
position: absolute;
+ // The cover's highlight/card layers (z-index 1–3) share this stacking
+ // context, so the button needs to sit above them to stay clickable.
+ z-index: 4;
@media (max-width: 590px) {
top: 20px;
diff --git a/src/layouts/library/Home/Home.tsx b/src/layouts/library/Home/Home.tsx
index 4db862dc..9dfafd53 100644
--- a/src/layouts/library/Home/Home.tsx
+++ b/src/layouts/library/Home/Home.tsx
@@ -1,5 +1,4 @@
import { mapStrapiLibrariesResponseToCards } from '@utils/library/mapStrapiLibraries';
-import Image from 'next/image';
import React, { useEffect, useMemo, useState } from 'react';
import type { HomeLibraryCardView } from '@local-types/library/library';
@@ -15,6 +14,7 @@ import {
} from '@components/library/molecules/Button';
import { Input } from '@components/library/molecules/Input';
import { Pagination } from '@components/library/molecules/Pagination';
+import { InteractiveCover } from '@components/library/organisms/InteractiveCover';
import { LibraryCard } from '@components/library/organisms/LibraryCard';
import { HomeTemplateProps } from './Home.types';
@@ -140,22 +140,21 @@ export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) {
return (