Promote to main: Feed browser luxury redesign #7

Merged
calcazar merged 49 commits from develop into main 2026-10-20 10:33:01 -06:00
12 changed files with 838 additions and 53 deletions
Showing only changes of commit 048b8ec8d1 - Show all commits
Binary file not shown.
Binary file not shown.
+2
View File
@@ -73,6 +73,8 @@
--media-fade-top: rgba(0,0,0,.45);
--media-fade-0: rgba(0,0,0,0);
--media-glass: rgba(0,0,0,.28);
/* Behind an open caption: darker than the fade, so long text reads over any photo. */
--media-scrim: rgba(0,0,0,.72);
--media-shadow: rgba(0,0,0,.35);
/* HexAvatar initials: dark muted gradients, same in both themes. */
+224 -24
View File
@@ -6,9 +6,11 @@ import { GrowButton, SweepButton } from "./Buttons";
import { HexAvatar } from "./HexAvatar";
import { PlayIcon, PostMenu } from "./PostCard";
import { VideoPlayer } from "./VideoPlayer";
import { CaptionText, QuoteText } from "./SlideText";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { Logo } from "./Logo";
import { useTheme } from "@/lib/use-theme";
import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
import { MobileDock } from "./MobileDock";
import { BottomSheet } from "./BottomSheet";
import { CommentsSection } from "./CommentsSection";
@@ -41,7 +43,7 @@ export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_
export function isTapTarget(target: EventTarget | null): boolean {
const el = target as Element | null;
if (!el || typeof el.closest !== "function") return true;
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem']");
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem'], [data-expanded-text]");
}
/** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */
@@ -76,29 +78,187 @@ function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
else if (ref) (ref as { current: T | null }).current = value;
}
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
/** Aspect ratio (width / height) at or below which a video fills the slide (4:5 or taller). */
export const COVER_MAX_RATIO = 0.8;
/** "cover" for portrait and near-portrait video, "contain" for wider, "cover" while the size is unknown. */
export function videoFit(width: number, height: number): "cover" | "contain" {
if (!(width > 0) || !(height > 0)) return "cover";
return width / height <= COVER_MAX_RATIO ? "cover" : "contain";
}
/** True when the person asked for less motion. Updates when the setting changes; false on the server. */
function useReducedMotion(): boolean | null {
const [reduced, setReduced] = useState<boolean | null>(null);
useEffect(() => {
if (typeof window.matchMedia !== "function") return setReduced(false);
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const read = () => setReduced(mq.matches);
read();
mq.addEventListener?.("change", read);
return () => mq.removeEventListener?.("change", read);
}, []);
return reduced;
}
function SoundIcon({ muted }: { muted: boolean }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4V5z" />
{muted ? <path d="m16 9 5 6M21 9l-5 6" /> : <path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" />}
</svg>
);
}
/**
* One media item of a slide. A video plays by itself, muted and inline, while
* `active` (its slide and pane are on screen) unless the person asked for less
* motion or the browser refuses: then the play button starts it as before.
* `hold` (a sheet is open) pauses a playing video and resumes it after.
*/
function SlideMedia({ media, author, active = false, hold = false }: { media: CardMedia; author: string; active?: boolean; hold?: boolean }) {
const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
// Real video size once metadata loads; until then the thumbnail's size decides.
const [videoSize, setVideoSize] = useState<[number, number] | null>(null);
const [thumbSize, setThumbSize] = useState<[number, number] | null>(null);
const playable = isPlayableVideo(media);
const reduced = useReducedMotion();
// The browser refused autoplay: fall back to the play button.
const [blocked, setBlocked] = useState(false);
const auto = reduced === false && !blocked;
const [muted, setMuted] = useState(getSessionMuted);
// Paused by a tap: it stays paused until tapped again or the slide leaves.
const [userPaused, setUserPaused] = useState(false);
const userPausedRef = useRef(false);
const resumeAfterHold = useRef(false);
const video = () => box.current?.querySelector("video") ?? null;
useEffect(() => {
const v = video();
if (!v) return;
const read = () => {
if (v.videoWidth > 0 && v.videoHeight > 0) setVideoSize([v.videoWidth, v.videoHeight]);
};
read();
v.addEventListener("loadedmetadata", read);
return () => v.removeEventListener("loadedmetadata", read);
}, [playable, media.mp4Url, media.hlsUrl]);
const autoplay = useCallback((v: HTMLVideoElement) => {
v.muted = getSessionMuted();
// Like a reel, an autoplayed video loops while it is on screen.
v.loop = true;
setMuted(v.muted);
v.play().catch((err: unknown) => {
if (!isAutoplayBlocked(err)) return;
// Sound not allowed yet: play muted this time, keeping the session's choice.
if (!v.muted) {
v.muted = true;
setMuted(true);
v.play().catch((again: unknown) => {
if (isAutoplayBlocked(again)) setBlocked(true);
});
} else setBlocked(true);
});
}, []);
useEffect(() => {
const v = video();
if (!v || !playable) return;
if (!active) {
userPausedRef.current = false;
setUserPaused(false);
resumeAfterHold.current = false;
v.pause();
return;
}
if (hold) {
if (!v.paused) {
resumeAfterHold.current = true;
v.pause();
}
return;
}
if (resumeAfterHold.current) {
resumeAfterHold.current = false;
void v.play().catch(() => {});
return;
}
if (auto && !userPausedRef.current) autoplay(v);
}, [active, hold, auto, playable, autoplay]);
if (playable) {
const size = videoSize ?? thumbSize;
const fit = size ? videoFit(size[0], size[1]) : "cover";
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
void box.current?.querySelector("video")?.play().catch(() => {});
const v = video();
if (!v) return;
v.muted = false;
void v.play().catch(() => {});
};
// Autoplay: a tap on the video pauses or resumes it (no native controls).
const togglePause = () => {
const v = video();
if (!v) return;
const pause = !v.paused;
userPausedRef.current = pause;
setUserPaused(pause);
if (pause) v.pause();
else void v.play().catch(() => {});
};
const toggleMute = () => {
const v = video();
const next = !muted;
if (v) v.muted = next;
setSessionMuted(next);
setMuted(next);
};
const showPlay = auto ? userPaused : !started;
return (
<div className="slide-media slide-video" ref={box}>
<div className="slide-art">
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} controls={started} />
<div className="slide-media slide-video" data-fit={fit} data-autoplay={auto ? "true" : undefined} ref={box}>
{media.src ? (
// The thumbnail doubles as the blurred backdrop behind a contained video, and as the size fallback.
// eslint-disable-next-line @next/next/no-img-element
<img
className="slide-backdrop"
src={media.src}
alt=""
aria-hidden="true"
onLoad={(e) => setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])}
/>
) : null}
<div className="slide-art" onClick={auto ? togglePause : undefined}>
<VideoPlayer
mp4Url={media.mp4Url}
hlsUrl={media.hlsUrl}
poster={media.src}
label={`Video from ${author}`}
controls={!auto && started}
muted={auto ? muted : undefined}
/>
</div>
{started ? null : (
{auto ? (
<button
type="button"
className="slide-mute"
data-testid="slide-mute"
aria-label={muted ? "Unmute video" : "Mute video"}
aria-pressed={!muted}
onClick={toggleMute}
>
<SoundIcon muted={muted} />
</button>
) : null}
{showPlay ? (
<>
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={start}>
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={auto ? togglePause : start}>
<PlayIcon />
</button>
{duration ? <span className="post-dur slide-dur">{duration}</span> : null}
{duration && !auto ? <span className="post-dur slide-dur">{duration}</span> : null}
</>
)}
) : null}
</div>
);
}
@@ -118,7 +278,7 @@ function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
}
/** Several media items in one slide: a sideways scroll-snap track with a dot per item. */
function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
function MediaTrack({ items, author, current, hold }: { items: CardMedia[]; author: string; current: boolean; hold: boolean }) {
const track = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
const onScroll = () => {
@@ -137,7 +297,7 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
<div className="slide-track" data-testid="slide-track" ref={track} onScroll={onScroll}>
{items.map((m, i) => (
<div className="slide-pane" key={i} aria-label={`Media ${i + 1} of ${items.length}`}>
<SlideMedia media={m} author={author} />
<SlideMedia media={m} author={author} active={current && i === active} hold={hold} />
</div>
))}
</div>
@@ -150,13 +310,39 @@ function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
);
}
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
function PostSlide({
post,
index,
current,
hold,
p,
onExpandedChange,
}: {
post: CardPost;
index: number;
current: boolean;
/** A sheet is open over the feed: a playing video pauses until it closes. */
hold: boolean;
p: FeedViewProps;
onExpandedChange?: (index: number, open: boolean) => void;
}) {
const items = post.media.filter((m) => m.kind === "video" || m.src);
const media = items[0] ?? null;
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
// While a video plays, the author and text step aside so the native controls stay clear.
// While a video plays with native controls (play button, not autoplay), the author and text step aside.
const [playing, setPlaying] = useState(false);
const cls = `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}`;
// Long text opened with "Read more" or "more". It closes when the slide leaves the screen.
const [textOpen, setTextOpen] = useState(false);
useEffect(() => {
if (!current) setTextOpen(false);
}, [current]);
useEffect(() => {
onExpandedChange?.(index, textOpen);
}, [index, textOpen, onExpandedChange]);
useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]);
const cls =
`slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` +
`${own ? " slide-own" : ""}${textOpen ? " slide-text-open" : ""}`;
const lastTap = useRef<number | null>(null);
const [burst, setBurst] = useState(0);
useEffect(() => {
@@ -181,20 +367,24 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
data-liked={post.isLiked ? "true" : "false"}
onClick={onTap}
aria-label={`Post by ${post.authorName}`}
onPlayCapture={() => setPlaying(true)}
onPlayCapture={(e) => {
if ((e.target as HTMLVideoElement).controls) setPlaying(true);
}}
onPauseCapture={() => setPlaying(false)}
onEndedCapture={() => setPlaying(false)}
>
{media ? (
<>
{items.length > 1 ? <MediaTrack items={items} author={post.authorName} /> : <SlideMedia media={media} author={post.authorName} />}
{items.length > 1 ? (
<MediaTrack items={items} author={post.authorName} current={current} hold={hold} />
) : (
<SlideMedia media={media} author={post.authorName} active={current} hold={hold} />
)}
<div className="slide-scrim-top" aria-hidden="true" />
<div className="slide-scrim" aria-hidden="true" />
</>
) : (
<div className="slide-quote-w">
<p className="slide-quote">{post.text}</p>
</div>
<QuoteText text={post.text} open={textOpen} onOpenChange={setTextOpen} />
)}
{burst ? (
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
@@ -216,7 +406,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
</div>
</div>
{media && post.text ? <p className="slide-text">{post.text}</p> : null}
{media && post.text ? <CaptionText text={post.text} open={textOpen} onOpenChange={setTextOpen} /> : null}
</div>
</section>
);
@@ -313,6 +503,13 @@ function LoadSlide({ index, current, sentinelRef }: { index: number; current: bo
export function MobileFeed(p: FeedViewProps) {
const reel = useRef<HTMLDivElement | null>(null);
const [current, setCurrent] = useState(0);
// The slide whose long text is open: the feed holds still so swipes scroll the text.
const [expandedAt, setExpandedAt] = useState<number | null>(null);
const locked = useRef(false);
locked.current = expandedAt !== null;
const onExpandedChange = useCallback((at: number, open: boolean) => {
setExpandedAt((cur) => (open ? at : cur === at ? null : cur));
}, []);
// The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
const closeSheet = useCallback(() => setSheet(null), []);
@@ -366,6 +563,8 @@ export function MobileFeed(p: FeedViewProps) {
let lastStep = 0;
let from = 0;
const onWheel = (e: WheelEvent) => {
// Open text scrolls natively; the reel is overflow-hidden meanwhile.
if (locked.current) return;
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
e.preventDefault();
const now = performance.now();
@@ -377,11 +576,11 @@ export function MobileFeed(p: FeedViewProps) {
};
let startY: number | null = null;
const onTouchStart = (e: TouchEvent) => {
startY = e.touches.length === 1 ? e.touches[0].clientY : null;
startY = e.touches.length === 1 && !locked.current ? e.touches[0].clientY : null;
from = at();
};
const onTouchEnd = (e: TouchEvent) => {
if (startY === null || e.touches.length) return;
if (startY === null || e.touches.length || locked.current) return;
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
startY = null;
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
@@ -430,6 +629,7 @@ export function MobileFeed(p: FeedViewProps) {
className="reel"
data-testid="feed-reel"
data-current={index}
data-locked={expandedAt !== null ? "true" : undefined}
ref={(el) => {
reel.current = el;
setRef(p.listRef, el);
@@ -448,7 +648,7 @@ export function MobileFeed(p: FeedViewProps) {
) : null}
{p.status === "ready"
? p.posts.flatMap((post, i) => {
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} p={p} />;
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} hold={sheet !== null} p={p} onExpandedChange={onExpandedChange} />;
return i === featuredAt - 1
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
: [node];
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
/** Quote sizes for a text-only post, largest first. The smallest one clamps and offers "Read more". */
export const QUOTE_SIZES = [31, 26, 22, 19] as const;
/** Space kept under a clamped quote for the "Read more" button (44px tall plus its gap). */
export const QUOTE_MORE_RESERVE = 52;
export interface QuoteFit {
size: number;
/** Lines shown before "Read more"; 0 when the whole text fits. */
lines: number;
}
/**
* The first quote size whose rendered text fits, or the smallest size with as
* many whole lines as fit above the "Read more" button. `fits` measures the
* rendered text at a size; `linesAt` counts the lines that fit at the smallest one.
*/
export function pickQuoteFit(fits: (size: number) => boolean, linesAt: (size: number) => number): QuoteFit {
for (const size of QUOTE_SIZES) if (fits(size)) return { size, lines: 0 };
const size = QUOTE_SIZES[QUOTE_SIZES.length - 1];
return { size, lines: Math.max(1, Math.floor(linesAt(size))) };
}
/** Re-runs `measure` when the box resizes, web fonts finish loading, or the theme changes. */
function useRemeasure(box: React.RefObject<HTMLElement | null>, measure: () => void, deps: unknown[]) {
useLayoutEffect(() => {
measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
const el = box.current;
if (!el) return;
let frame = 0;
const later = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(later) : null;
ro?.observe(el);
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
void fonts?.ready.then(later);
fonts?.addEventListener?.("loadingdone", later);
const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(later) : null;
mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
return () => {
cancelAnimationFrame(frame);
ro?.disconnect();
mo?.disconnect();
fonts?.removeEventListener?.("loadingdone", later);
};
}, [box, measure]);
}
/**
* Text-only post: the serif quote steps down 31 → 26 → 22 → 19px until it fits
* between the top bar and the author block. When it still does not fit, it
* clamps and ends with "Read more", which opens the full text over the slide.
*/
export function QuoteText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const box = useRef<HTMLDivElement>(null);
const inner = useRef<HTMLDivElement>(null);
const quote = useRef<HTMLParagraphElement>(null);
const moreBtn = useRef<HTMLButtonElement>(null);
const closeBtn = useRef<HTMLButtonElement>(null);
const [fit, setFit] = useState<QuoteFit>({ size: QUOTE_SIZES[0], lines: 0 });
const measure = useCallback(() => {
const b = box.current;
const q = quote.current;
const w = inner.current;
if (!b || !q || !w || !b.clientHeight) return;
const apply = (f: QuoteFit) => {
q.style.fontSize = `${f.size}px`;
q.style.webkitLineClamp = f.lines ? String(f.lines) : "";
q.classList.toggle("is-clamped", f.lines > 0);
b.classList.toggle("is-clamped", f.lines > 0);
};
const result = pickQuoteFit(
(size) => {
apply({ size, lines: 0 });
return w.offsetHeight <= b.clientHeight + 0.5;
},
() => {
// Height left for the text: the box minus the quote mark, its gap and the button.
const other = w.offsetHeight - q.offsetHeight;
const lineHeight = parseFloat(getComputedStyle(q).lineHeight) || 19 * 1.2;
return (b.clientHeight - other - QUOTE_MORE_RESERVE) / lineHeight;
},
);
apply(result);
setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result));
}, []);
useRemeasure(box, measure, [text]);
// Focus follows the reader: into it on open, back to "Read more" on close.
const wasOpen = useRef(false);
useEffect(() => {
if (open) closeBtn.current?.focus({ preventScroll: true });
else if (wasOpen.current) moreBtn.current?.focus({ preventScroll: true });
wasOpen.current = open;
}, [open]);
return (
<>
<div className={`slide-quote-w${fit.lines ? " is-clamped" : ""}`} ref={box} data-testid="slide-quote-box">
<div className="slide-quote-in" ref={inner}>
<span className="slide-quote-mark" aria-hidden="true">{"“"}</span>
<p
className={`slide-quote${fit.lines ? " is-clamped" : ""}`}
ref={quote}
data-fit-size={fit.size}
style={{ fontSize: `${fit.size}px`, WebkitLineClamp: fit.lines || undefined }}
>
{text}
</p>
</div>
{fit.lines ? (
<button
type="button"
className="slide-more slide-more-quote"
ref={moreBtn}
aria-expanded={open}
onClick={() => onOpenChange(true)}
>
Read more
</button>
) : null}
</div>
{open ? (
<div
className="slide-reader"
data-testid="slide-reader"
data-expanded-text="true"
role="region"
aria-label="Full post"
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
>
<div className="slide-reader-scroll" data-testid="slide-reader-scroll" tabIndex={0}>
<p className="slide-reader-text">{text}</p>
</div>
<button type="button" className="slide-more slide-reader-close" ref={closeBtn} aria-expanded="true" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null}
</>
);
}
/**
* Caption over a photo or video: three lines, then "more" when it is longer.
* Open, the whole caption shows over the media on a darker scrim, scrolling
* inside the slide when it is very long, with "less" to collapse it.
*/
export function CaptionText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const caption = useRef<HTMLParagraphElement>(null);
const [long, setLong] = useState(false);
const measure = useCallback(() => {
const c = caption.current;
// Measured only while clamped: an open caption is long by definition.
if (!c || c.classList.contains("is-open") || !c.clientHeight) return;
setLong(c.scrollHeight > c.clientHeight + 1);
}, []);
useRemeasure(caption, measure, [text]);
useEffect(() => {
if (!open && caption.current) caption.current.scrollTop = 0;
}, [open]);
return (
<>
<p
className={`slide-text${open ? " is-open" : ""}`}
ref={caption}
data-expanded-text={open ? "true" : undefined}
data-testid="slide-caption"
tabIndex={open ? 0 : undefined}
>
{text}
</p>
{long || open ? (
<button type="button" className="slide-more slide-more-caption" aria-expanded={open} onClick={() => onOpenChange(!open)}>
{open ? "less" : "more"}
</button>
) : null}
</>
);
}
+5 -2
View File
@@ -10,6 +10,8 @@ export interface VideoPlayerProps {
label: string;
/** Native controls; off until the caller's own play button starts the video. Default on. */
controls?: boolean;
/** Start muted (the mobile feed's autoplay). Desktop leaves it unset. */
muted?: boolean;
}
const UNPLAYABLE = "Video can't be played here.";
@@ -18,9 +20,9 @@ const UNPLAYABLE = "Video can't be played here.";
* Real <video controls>. Order: direct mp4Url (plain progressive source), else
* hls.js (lazy-loaded, only for the HLS path; native HLS only where hls.js is
* unsupported), else thumbnail only. Never
* autoplays. Any failure falls back to the thumbnail with a short note.
* autoplays by itself (the mobile feed starts it). Any failure falls back to the thumbnail with a short note.
*/
export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }: VideoPlayerProps) {
export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true, muted }: VideoPlayerProps) {
const ref = useRef<HTMLVideoElement>(null);
const [failed, setFailed] = useState(false);
@@ -80,6 +82,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }:
ref={ref}
src={mp4Url ?? undefined}
controls={controls}
muted={muted}
playsInline
preload="none"
poster={poster ?? undefined}
+5 -4
View File
@@ -9,19 +9,20 @@ import { DiscoverRailsView } from "@/components/DiscoverRails";
import { ExpertSearchView } from "@/components/ExpertSearch";
import { FeedView } from "@/components/FeedView";
import { toComments } from "@/lib/comment-model";
import { toCardPosts, type CardPost } from "@/lib/feed-model";
import { type CardPost } from "@/lib/feed-model";
import {
FIXTURE_CATEGORIES,
FIXTURE_COMMENTS,
FIXTURE_DISCOVER,
FIXTURE_FEED_ITEMS,
fixtureCards,
FIXTURE_NOW,
FIXTURE_PERSON,
FIXTURE_SEARCH_RESULTS,
} from "./fixtures";
export function FeedPreview() {
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
const [posts, setPosts] = useState<CardPost[]>(() => fixtureCards(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
const [file, setFile] = useState<File | null>(null);
const [query, setQuery] = useState("");
@@ -55,7 +56,7 @@ export function FeedPreview() {
const it = raw as { post: { id: string } };
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
});
setPosts((cur) => [...cur, ...toCardPosts(items)]);
setPosts((cur) => [...cur, ...fixtureCards(items)]);
setPage(next);
setPaging(false);
}, 900);
@@ -152,7 +153,7 @@ export function FeedPreview() {
list.current?.scrollTo({ top: 0, behavior: "smooth" });
setRefreshing(true);
window.setTimeout(() => {
setPosts(toCardPosts(FIXTURE_FEED_ITEMS));
setPosts(fixtureCards(FIXTURE_FEED_ITEMS));
setRefreshing(false);
}, 600);
}}
+98 -5
View File
@@ -2,21 +2,22 @@
// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
// so the preview never calls the network. Later tasks extend this file.
import type { Person } from "@/lib/auth-client";
import { toCardPosts, type CardPost } from "@/lib/feed-model";
import type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import type { ExpertResult } from "@/lib/experts-search";
/** Fixed "now" so relative times in the preview are stable. */
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = ""): string {
/** A gradient placeholder image (16:9 unless a size is given), drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = "", width = 1280, height = 720): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">` +
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
`</linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/>` +
`</linearGradient></defs><rect width="${width}" height="${height}" fill="url(#g)"/>` +
(label
? `<text x="64" y="660" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
? `<text x="64" y="${height - 60}" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
: "") +
`</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
@@ -25,6 +26,22 @@ export function placeholderImage(hue: number, label = ""): string {
/** Not a real file: preload is off, so the preview video shows only its poster, play button and length. */
const FIXTURE_VIDEO_URL = "https://cdn.feed.sharedservices.vt.com/dev-fixture.mp4";
/** Fixture videos with real (tiny) files in public/dev: the feed only accepts CDN-host mp4 URLs, so these are swapped to local paths after parsing. */
const FIXTURE_LANDSCAPE_URL = "https://cdn.feed.sharedservices.vt.com/dev-landscape.mp4";
const FIXTURE_PORTRAIT_URL = "https://cdn.feed.sharedservices.vt.com/dev-portrait.mp4";
const LOCAL_FIXTURE_VIDEOS: Record<string, string> = {
[FIXTURE_LANDSCAPE_URL]: "/dev/landscape.mp4",
[FIXTURE_PORTRAIT_URL]: "/dev/portrait.mp4",
};
/** toCardPosts for the preview, with the fixture video URLs pointed at the local files. */
export function fixtureCards(items: unknown[]): CardPost[] {
return toCardPosts(items).map((p) => ({
...p,
media: p.media.map((m) => (m.mp4Url && LOCAL_FIXTURE_VIDEOS[m.mp4Url] ? { ...m, mp4Url: LOCAL_FIXTURE_VIDEOS[m.mp4Url] } : m)),
}));
}
/** Comments for the preview, in the shape /api/posts/[id]/comments returns. p2 holds a removed one; p4 has none. */
export const FIXTURE_COMMENTS: Record<string, unknown[]> = {
p1: [{ id: "c1", content: "Congratulations, the flow reads well.", createdAt: "2026-10-19T11:50:00Z", author: { displayName: "Priya Natarajan" } }],
@@ -64,6 +81,58 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
mediaItems: [],
},
},
{
// Medium text-only post: too long for 31px, fits at 26px or 22px with no "Read more".
type: "post",
publishedAt: "2026-10-19T11:30:00Z",
likesCount: 12,
commentsCount: 0,
post: {
id: "p10",
content: "The best advice I got as a new manager: your job is not to have the answers. It is to make sure the right question gets asked in the right room, by the person closest to the customer, early enough that the answer still changes what we build. Everything else, the rituals and the dashboards and the planning decks, exists to protect that one habit. When a meeting ends without a better question than it started with, it was a status update, and those can be an email.",
author: author("a10", "Grace Whitfield"),
mediaItems: [],
},
},
{
// Very long text-only post: does not fit at 19px, so it clamps and ends with "Read more".
type: "post",
publishedAt: "2026-10-19T11:10:00Z",
likesCount: 87,
commentsCount: 0,
post: {
id: "p11",
content: "I spent ten years building products before I learned the most useful habit of all: write down what you expect to happen before you ship, then check it a month later.\n\nMost teams skip the first half. They launch, watch a dashboard for a week and call whatever they see a win. Without the written guess there is nothing to be wrong about, so nobody learns.\n\nHere is the routine we use now. Every change gets one sentence: who it is for, what they will do differently, and the number that will move if we are right. The sentence goes in the ticket, not in a slide.\n\nThirty days later the person who wrote it reads it out loud in the weekly review and says what actually happened. No blame, no speeches. Just the guess, the result and one line on why they differ.\n\nAfter a quarter you get something no analytics tool gives you: a record of how good your team is at predicting its own customers. Ours was humbling. We were right about a third of the time.\n\nThat number changed how we plan. We now run smaller bets, we talk to five customers before any big build, and we kill features faster when the guess was wrong.\n\nIf you try one thing this month, try the sentence. It takes two minutes and it will teach you more than the next roadmap offsite.",
author: author("a11", "Daniel Moreau"),
mediaItems: [],
},
},
{
// Photo post with a caption longer than three lines: "more" / "less".
type: "post",
publishedAt: "2026-10-19T10:50:00Z",
likesCount: 64,
commentsCount: 0,
post: {
id: "p12",
content: "Notes from the panel on hiring a first sales lead, since a few people asked. Hire for the next eighteen months, not the next five years: the person who closes your first fifty customers is rarely the one who builds a team of twenty. Ask every candidate to walk you through a deal they lost and what they changed afterwards. Give them real numbers in the interview, including the bad ones, and watch how they react. Pay them on outcomes you can measure from day one, and write down what success looks like at thirty, sixty and ninety days. Last point: the founder keeps selling until the new lead has closed at least three deals alone. Handing over too early was the most common regret in the room.",
author: author("a12", "Aiko Tanaka"),
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(48, "Sales panel", 1080, 1350), order: 0 }],
},
},
{
// Video post with a long caption.
type: "post",
publishedAt: "2026-10-19T10:30:00Z",
likesCount: 39,
commentsCount: 0,
post: {
id: "p13",
content: "Full recording of the Thursday session on pricing experiments. Chapters: why we test price at all, how to pick the first segment, the three-week test plan, what to tell existing customers, reading the results without fooling yourself, and the questions from the audience at the end. Slides and the spreadsheet template are linked in the comments. If you only have five minutes, skip to the part on reading results: that is where most teams get it wrong, and where we lost two months last year before we noticed.",
author: author("a13", "Rafael Duarte"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(230, "Pricing session"), mp4Url: FIXTURE_LANDSCAPE_URL, videoDuration: 2, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T10:15:00Z",
@@ -89,6 +158,30 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T07:50:00Z",
likesCount: 31,
commentsCount: 4,
post: {
id: "p8",
content: "Landscape recording of the quarterly walkthrough. It should show whole, centered, over a blurred copy of its own thumbnail.",
author: author("a8", "Tomas Reyes"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(180, "Landscape 16:9"), mp4Url: FIXTURE_LANDSCAPE_URL, videoDuration: 2, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T07:20:00Z",
likesCount: 44,
commentsCount: 5,
post: {
id: "p9",
content: "Portrait clip shot on a phone. It should fill the whole slide.",
author: author("a9", "Ines Moreau"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(300, "Portrait 9:16", 720, 1280), mp4Url: FIXTURE_PORTRAIT_URL, videoDuration: 2, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-18T21:20:00Z",
+24
View File
@@ -0,0 +1,24 @@
/**
* Mobile feed autoplay: the session's mute choice and how a failed play() is read.
* The choice lives in memory only, for this page session: videos start muted
* (browsers only autoplay muted video) until the person unmutes one.
*/
let sessionMuted = true;
/** True until the person unmutes a video in this session. */
export function getSessionMuted(): boolean {
return sessionMuted;
}
/** Remember the person's mute choice for the videos that play after this one. */
export function setSessionMuted(muted: boolean): void {
sessionMuted = muted;
}
/**
* True when play() was refused by the browser's autoplay policy. A play() that
* was cut short by pause() (AbortError) is not a refusal.
*/
export function isAutoplayBlocked(error: unknown): boolean {
return !!error && typeof error === "object" && (error as { name?: unknown }).name === "NotAllowedError";
}
+177 -14
View File
@@ -24,6 +24,8 @@
scrollbar-width: none;
}
.reel::-webkit-scrollbar { display: none; }
/* Long text is open on the slide on screen: the reel holds still, swipes scroll the text. */
.reel[data-locked="true"] { overflow-y: hidden; }
.slide {
--slide-text-bg: radial-gradient(120% 70% at 0% 0%, var(--accent-soft), transparent 62%), var(--bg);
@@ -52,9 +54,51 @@
.slide-video .slide-art video,
.slide-video .slide-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
.slide-video .slide-art > div { height: 100%; }
.slide-video .slide-art video { background: transparent; }
/* Wider than 4:5: show the whole video, centered, over a blurred, darkened, enlarged copy of its own thumbnail. */
.slide-backdrop { display: none; }
.slide-video[data-fit="contain"] .slide-backdrop {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.25);
filter: blur(28px) brightness(0.5);
pointer-events: none;
}
.slide-video[data-fit="contain"] .slide-art video,
.slide-video[data-fit="contain"] .slide-art img { object-fit: contain; }
.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
.slide-play { z-index: 4; }
.slide-dur { top: 128px; right: 16px; z-index: 4; }
/* Autoplay: a small frosted sound toggle on the video; a tap on the video pauses or resumes it. */
.slide-video[data-autoplay="true"] .slide-art { cursor: pointer; }
.slide-mute {
position: absolute;
top: calc(72px + env(safe-area-inset-top, 0px));
right: 16px;
z-index: 4;
display: grid;
place-items: center;
width: 36px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
appearance: none;
color: var(--on-media);
background: var(--media-glass);
-webkit-backdrop-filter: blur(16px) saturate(1.6);
backdrop-filter: blur(16px) saturate(1.6);
box-shadow: inset 0 0 0 1px var(--on-media-line);
cursor: pointer;
transition: transform .2s var(--ease);
}
.slide-mute svg { width: 18px; height: 18px; }
.slide-mute:active { transform: scale(.92); }
/* Several media items: a sideways snap track; each pane is one full-slide item. */
.slide-track {
@@ -138,14 +182,35 @@
.slide-error { margin: 0; color: var(--danger); font-size: 13px; font-weight: 600; }
.slide-on-media .slide-error { color: var(--on-media); }
/* Text-only post: the text is the hero, a serif quote with a crimson opening mark. */
.slide-quote-w { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.slide-quote {
/* Text-only post: the text is the hero, a serif quote with a crimson opening mark.
The box spans from under the top bar (or the own-post menu) to above the author
block; SlideText steps the size 31 → 26 → 22 → 19px until the text fits. */
.slide-quote-w {
position: absolute;
top: calc(72px + env(safe-area-inset-top, 0px));
bottom: 181px; /* .slide-meta bottom (108px) + the author block (53px) + 20px */
left: 24px;
right: 28px;
top: 46%;
transform: translateY(-50%);
z-index: 2;
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: none;
}
.slide-own .slide-quote-w { top: calc(122px + env(safe-area-inset-top, 0px)); }
/* Auto margins center the quote and, unlike justify-content, never push overflow above the box. */
.slide-quote-in { margin: auto 0; }
.slide-quote-w.is-clamped .slide-quote-in { margin-top: 0; }
.slide-quote-mark {
display: block;
margin-bottom: 6px;
color: var(--accent);
font-family: var(--font-serif), Georgia, serif;
font-size: 84px;
font-weight: 600;
line-height: .62;
}
.slide-quote {
margin: 0;
font-family: var(--font-serif), Georgia, serif;
font-size: 31px;
@@ -153,19 +218,114 @@
line-height: 1.2;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.slide-quote.is-clamped {
display: -webkit-box;
-webkit-line-clamp: 8;
-webkit-box-orient: vertical;
overflow: hidden;
}
.slide-quote::before {
content: "\201C";
display: block;
margin-bottom: 6px;
color: var(--accent);
font-size: 84px;
line-height: .62;
/* "Read more", "Close", "more" and "less": real buttons, 44px tall to tap. */
.slide-more {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin: 0;
padding: 0 18px;
border: 0;
border-radius: 14px;
font: inherit;
font-size: 14px;
font-weight: 700;
letter-spacing: .01em;
color: var(--ink);
background: var(--glass);
-webkit-backdrop-filter: blur(16px) saturate(1.6);
backdrop-filter: blur(16px) saturate(1.6);
box-shadow: inset 0 0 0 1px var(--line);
cursor: pointer;
pointer-events: auto;
transition: transform .15s;
}
.slide-more:active { transform: scale(.96); }
.slide-more:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.slide-more-quote { position: absolute; left: 0; bottom: 0; }
/* The full text of a long text-only post, over the slide between the top bar and the dock. */
@keyframes slide-reader-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.slide-reader {
position: absolute;
top: calc(64px + env(safe-area-inset-top, 0px));
bottom: calc(88px + env(safe-area-inset-bottom, 0px));
left: 12px;
right: 12px;
z-index: 7;
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 14px 14px 20px;
border-radius: 22px;
color: var(--ink);
background: var(--bg);
box-shadow: var(--shadow-pop), inset 0 0 0 1px var(--line);
animation: slide-reader-in .28s var(--ease) both;
}
.slide-reader-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 6px;
}
.slide-reader-scroll:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 6px; }
.slide-reader-text {
margin: 0;
font-family: var(--font-serif), Georgia, serif;
font-size: 22px;
font-weight: 600;
line-height: 1.35;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.slide-reader-close { flex: none; align-self: flex-end; }
/* Caption "more" / "less": a text button right under the three-line clamp. */
.slide-more.slide-more-caption {
align-self: flex-start;
flex: none;
margin: -12px 0 -10px -6px;
padding: 0 6px;
min-width: 44px;
background: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
box-shadow: none;
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
text-shadow: 0 1px 12px var(--media-shadow);
}
/* Open caption: the full text over the media on a darker scrim, scrolling inside the slide. */
.slide-who,
.slide-error { flex: none; }
.slide-text-open .slide-meta {
top: calc(122px + env(safe-area-inset-top, 0px));
justify-content: flex-end;
}
.slide-text.is-open {
display: block;
-webkit-line-clamp: unset;
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
animation: slide-reader-in .28s var(--ease) both;
}
.slide-text.is-open:focus-visible { outline: 2px solid var(--on-media); outline-offset: 4px; }
.slide-text-open .slide-scrim { height: 100%; background: var(--media-scrim); }
.slide-text-open .slide-scrim-top { opacity: 0; }
/* The slide on screen: its content rises in when it snaps into place. */
.is-cur .slide-meta,
@@ -375,10 +535,13 @@
.is-cur .slide-quote-w,
.slide-skel i,
.slide-burst,
.slide-reader,
.slide-text.is-open,
.slide-hint svg,
.reel-pill,
.dk-beat svg { animation: none !important; }
.slide-art { transition: none !important; }
.slide-art,
.slide-more { transition: none !important; }
}
/* Top bar (spec "Top bar"): frosted pills over the feed. Over a media slide they use a
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
import { VideoPlayer } from "@/components/VideoPlayer";
describe("session mute choice", () => {
it("starts muted and keeps the person's choice", () => {
expect(getSessionMuted()).toBe(true);
setSessionMuted(false);
expect(getSessionMuted()).toBe(false);
setSessionMuted(true);
expect(getSessionMuted()).toBe(true);
});
});
describe("autoplay refusal", () => {
it("only counts the browser's NotAllowedError", () => {
expect(isAutoplayBlocked({ name: "NotAllowedError" })).toBe(true);
expect(isAutoplayBlocked({ name: "AbortError" })).toBe(false);
expect(isAutoplayBlocked(null)).toBe(false);
expect(isAutoplayBlocked("NotAllowedError")).toBe(false);
});
});
describe("VideoPlayer", () => {
it("is inline and not muted unless asked (desktop unchanged)", () => {
const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v" }));
expect(html).toContain("playsInline");
expect(html).toContain("controls");
expect(html).not.toContain("muted");
});
it("renders muted inline video for the mobile autoplay", () => {
const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v", controls: false, muted: true }));
expect(html).toContain("muted");
expect(html).not.toContain("controls");
});
});
describe("mobile autoplay styles", () => {
it("has a frosted mute button", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
expect(css).toMatch(/\.slide-mute \{[^}]*backdrop-filter/);
});
});
+66 -4
View File
@@ -7,6 +7,7 @@ import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex, railPercent
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
import { toCardPosts } from "@/lib/feed-model";
import { pickQuoteFit, QUOTE_SIZES } from "@/components/SlideText";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const item = (id: string, authorId: string, mediaItems: unknown[] = []) => ({
@@ -50,10 +51,10 @@ describe("mobile feed", () => {
it("renders one slide per post, text as a quote and media under the fades", () => {
const html = render({});
expect(html.match(/data-testid="feed-slide"/g)).toHaveLength(3);
expect(html).toContain('class="slide-quote">text t1</p>');
expect(html).toMatch(/class="slide-quote" data-fit-size="31" style="font-size:31px">text t1<\/p>/);
expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/);
expect(html).toContain("slide-scrim-top");
expect(html).toContain('class="slide-text">text i1</p>');
expect(html).toMatch(/class="slide-text"[^>]*>text i1<\/p>/);
expect(html).toContain('aria-label="Play video"');
// The quote slide shows its text once, as the quote.
expect(html.match(/text t1/g)).toHaveLength(1);
@@ -61,7 +62,7 @@ describe("mobile feed", () => {
it("marks the first slide as the one on screen", () => {
const html = render({});
expect(html).toMatch(/class="slide slide-on-text is-cur"/);
expect(html).toMatch(/class="slide slide-on-text is-cur slide-own"/);
expect(html).toContain('data-current="0"');
});
@@ -101,7 +102,7 @@ describe("mobile stylesheet", () => {
});
it("uses a 31px serif quote with a crimson mark, and eases media from 104%", () => {
expect(css).toMatch(/\.slide-quote \{[^}]*font-size: 31px/);
expect(css).toMatch(/\.slide-quote::before \{[^}]*color: var\(--accent\)/);
expect(css).toMatch(/\.slide-quote-mark \{[^}]*color: var\(--accent\)/);
expect(css).toMatch(/transform: scale\(1\.04\)/);
expect(css).toMatch(/\.is-cur \.slide-art \{ transform: scale\(1\); \}/);
});
@@ -195,3 +196,64 @@ describe("top bar and progress rail", () => {
expect(css).toMatch(/\.ph-top \{[^}]*safe-area-inset-top/);
});
});
describe("videoFit", () => {
it("fills the slide for 4:5 and taller, shows whole for wider, covers while unknown", async () => {
const { videoFit } = await import("@/components/MobileFeed");
expect(videoFit(720, 1280)).toBe("cover");
expect(videoFit(1080, 1350)).toBe("cover");
expect(videoFit(1080, 1080)).toBe("contain");
expect(videoFit(1920, 1080)).toBe("contain");
expect(videoFit(0, 0)).toBe("cover");
});
});
describe("long post text", () => {
it("picks the largest quote size that fits, measured, not counted", () => {
const fitsBelow = (max: number) => (size: number) => size <= max;
expect(pickQuoteFit(fitsBelow(40), () => 0)).toEqual({ size: 31, lines: 0 });
expect(pickQuoteFit(fitsBelow(26), () => 0)).toEqual({ size: 26, lines: 0 });
expect(pickQuoteFit(fitsBelow(22), () => 0)).toEqual({ size: 22, lines: 0 });
expect(pickQuoteFit(fitsBelow(19), () => 0)).toEqual({ size: 19, lines: 0 });
expect([...QUOTE_SIZES]).toEqual([31, 26, 22, 19]);
});
it("clamps at 19px to the whole lines that fit when nothing fits", () => {
const tried: number[] = [];
const fit = pickQuoteFit((s) => (tried.push(s), false), () => 17.8);
expect(tried).toEqual([31, 26, 22, 19]);
expect(fit).toEqual({ size: 19, lines: 17 });
expect(pickQuoteFit(() => false, () => 0.2)).toEqual({ size: 19, lines: 1 });
});
it("shows no button on short text until a measurement says it is long", () => {
const html = render({});
expect(html).not.toContain("Read more");
expect(html).not.toMatch(/>more<\/button>/);
expect(html).not.toContain("slide-reader");
});
it("keeps taps on open text from counting toward a double-tap like", () => {
const el = { closest: (sel: string) => (sel.includes("[data-expanded-text]") ? {} : null) } as unknown as Element;
expect(isTapTarget(el)).toBe(false);
});
});
describe("long post text stylesheet", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
it("fits the quote between the top bar and the author block", () => {
expect(css).toMatch(/\.slide-quote-w \{[^}]*top: calc\(72px[^}]*bottom: 181px[^}]*overflow: hidden/);
});
it("makes the buttons at least 44px tall", () => {
expect(css).toMatch(/\.slide-more \{[^}]*min-height: 44px/);
});
it("holds the reel still while text is open, and the open text scrolls", () => {
expect(css).toMatch(/\.reel\[data-locked="true"\] \{ overflow-y: hidden; \}/);
expect(css).toMatch(/\.slide-reader-scroll \{[^}]*overflow-y: auto/);
expect(css).toMatch(/\.slide-text\.is-open \{[^}]*overflow-y: auto/);
expect(css).toMatch(/\.slide-text-open \.slide-scrim \{[^}]*var\(--media-scrim\)/);
});
it("drops the expand animation under reduced motion", () => {
expect(css).toMatch(/prefers-reduced-motion: reduce\) \{[^}]*\.slide-reader,\s*\.slide-text\.is-open,/);
});
});