Feed browser: Mobile video and caption fixes #6

Merged
calcazar merged 3 commits from feature/feed-browser-mobile-video-fixes into develop 2026-10-20 10:23:33 -06:00
6 changed files with 477 additions and 28 deletions
Showing only changes of commit 6af225afa5 - Show all commits
+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. */
+42 -10
View File
@@ -6,6 +6,7 @@ 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";
@@ -41,7 +42,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. */
@@ -185,13 +186,36 @@ 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,
p,
onExpandedChange,
}: {
post: CardPost;
index: number;
current: 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.
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(() => {
@@ -227,9 +251,7 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
<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">
@@ -251,7 +273,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>
);
@@ -348,6 +370,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), []);
@@ -401,6 +430,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();
@@ -412,11 +443,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()));
@@ -465,6 +496,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);
@@ -483,7 +515,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} 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}
</>
);
}
+52
View File
@@ -81,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",
+135 -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);
@@ -154,14 +156,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;
@@ -169,19 +192,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,
@@ -391,10 +509,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
+55 -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\); \}/);
});
@@ -206,3 +207,53 @@ describe("videoFit", () => {
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,/);
});
});