Promote to main: Feed browser luxury redesign #7
@@ -62,6 +62,15 @@
|
|||||||
--accent-glow-0: rgba(255,110,110,0);
|
--accent-glow-0: rgba(255,110,110,0);
|
||||||
/* Text and marks on a crimson face: white in both themes. */
|
/* Text and marks on a crimson face: white in both themes. */
|
||||||
--on-accent: #FFFFFF;
|
--on-accent: #FFFFFF;
|
||||||
|
/* Over photos and video (mobile slides): white text, dark fades and dark glass, same in both themes. */
|
||||||
|
--on-media: #FFFFFF;
|
||||||
|
--on-media-line: rgba(255,255,255,.18);
|
||||||
|
--on-media-badge: rgba(255,255,255,.5);
|
||||||
|
--media-fade: rgba(0,0,0,.8);
|
||||||
|
--media-fade-top: rgba(0,0,0,.45);
|
||||||
|
--media-fade-0: rgba(0,0,0,0);
|
||||||
|
--media-glass: rgba(0,0,0,.28);
|
||||||
|
--media-shadow: rgba(0,0,0,.35);
|
||||||
|
|
||||||
/* HexAvatar initials: dark muted gradients, same in both themes. */
|
/* HexAvatar initials: dark muted gradients, same in both themes. */
|
||||||
--hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12);
|
--hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12);
|
||||||
|
|||||||
+6
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Cormorant_Garamond, Manrope } from "next/font/google";
|
import { Cormorant_Garamond, Manrope } from "next/font/google";
|
||||||
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
|
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
|
||||||
@@ -14,6 +14,8 @@ import "../styles/featured.css";
|
|||||||
import "../styles/feed.css";
|
import "../styles/feed.css";
|
||||||
import "../styles/trending.css";
|
import "../styles/trending.css";
|
||||||
import "../styles/search.css";
|
import "../styles/search.css";
|
||||||
|
import "../styles/mobile.css";
|
||||||
|
import "../styles/sheet.css";
|
||||||
import "../styles/legacy.css";
|
import "../styles/legacy.css";
|
||||||
|
|
||||||
const manrope = Manrope({
|
const manrope = Manrope({
|
||||||
@@ -31,6 +33,9 @@ const cormorant = Cormorant_Garamond({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// viewport-fit=cover makes env(safe-area-inset-*) report the iPhone home indicator.
|
||||||
|
export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" };
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Minnect feed browser",
|
title: "Minnect feed browser",
|
||||||
description: "Sign in with your Minnect account to read and write the home feed.",
|
description: "Sign in with your Minnect account to read and write the home feed.",
|
||||||
|
|||||||
+10
-4
@@ -5,7 +5,7 @@ import { loadSession, type Person } from "@/lib/auth-client";
|
|||||||
import { SignInForm } from "./SignInForm";
|
import { SignInForm } from "./SignInForm";
|
||||||
import { AppShell } from "./AppShell";
|
import { AppShell } from "./AppShell";
|
||||||
import { Feed } from "./Feed";
|
import { Feed } from "./Feed";
|
||||||
import { DiscoverRails } from "./DiscoverRails";
|
import { DiscoverRailsView, useDiscover } from "./DiscoverRails";
|
||||||
import { ExpertSearch } from "./ExpertSearch";
|
import { ExpertSearch } from "./ExpertSearch";
|
||||||
|
|
||||||
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
|
||||||
@@ -39,12 +39,18 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}} />;
|
}} />;
|
||||||
}
|
}
|
||||||
|
return <SignedInHome person={state.person} onSignedOut={() => setState({ kind: "out" })} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The signed-in shell. Discover loads once and feeds both the right rail and the phone feed. */
|
||||||
|
function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: () => void }) {
|
||||||
|
const discover = useDiscover();
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
person={state.person}
|
person={person}
|
||||||
search={<ExpertSearch />}
|
search={<ExpertSearch />}
|
||||||
feed={<Feed feedUserId={state.person.feedUserId} person={state.person} onSignedOut={() => setState({ kind: "out" })} />}
|
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} trendingMinnects={discover?.trendingMinnects} searchPanel={<ExpertSearch />} />}
|
||||||
discover={<DiscoverRails />}
|
discover={<DiscoverRailsView data={discover} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import type { Person } from "@/lib/auth-client";
|
import type { Person } from "@/lib/auth-client";
|
||||||
import { useTheme } from "@/lib/use-theme";
|
import { useTheme } from "@/lib/use-theme";
|
||||||
import { HexAvatar } from "./HexAvatar";
|
import { HexAvatar } from "./HexAvatar";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
import { CompactLayoutContext, isCompactWidth } from "./layout-context";
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
viewBox: "0 0 24 24",
|
viewBox: "0 0 24 24",
|
||||||
@@ -73,18 +74,36 @@ export interface AppShellProps {
|
|||||||
|
|
||||||
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
|
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
|
||||||
export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
export function AppShell({ person, search, feed, discover }: AppShellProps) {
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
const [compact, setCompact] = useState(false);
|
||||||
|
|
||||||
|
// The CSS switches layout with a container query on .app; the feed also
|
||||||
|
// needs to know, because the phone feed is different markup (full-screen slides).
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = root.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setCompact(isCompactWidth(el.getBoundingClientRect().width));
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<CompactLayoutContext.Provider value={compact}>
|
||||||
|
<div className="app" ref={root} data-layout={compact ? "compact" : "wide"}>
|
||||||
<main className="bento">
|
<main className="bento">
|
||||||
<div className="bento-col bento-left">
|
<div className="bento-col bento-left">
|
||||||
<AccountPanel person={person} />
|
<AccountPanel person={person} />
|
||||||
{search}
|
{compact ? null : search}
|
||||||
</div>
|
</div>
|
||||||
<div className="bento-col bento-center">{feed}</div>
|
<div className="bento-col bento-center">{feed}</div>
|
||||||
<aside className="bento-col bento-right" aria-label="Discover">
|
<aside className="bento-col bento-right" aria-label="Discover">
|
||||||
{discover}
|
{compact ? null : discover}
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</CompactLayoutContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Bottom sheet (spec "Sheets"): slides up over a dimmed, blurred backdrop with a
|
||||||
|
// grab handle, a serif title and a close button. Reused by the comments,
|
||||||
|
// composer and Discover sheets. Styles live in src/styles/sheet.css.
|
||||||
|
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||||
|
|
||||||
|
const FOCUSABLE = 'button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
export interface BottomSheetProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
/** Extra class on the sheet, for per-sheet sizing. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomSheet({ title, onClose, children, className }: BottomSheetProps) {
|
||||||
|
const sheet = useRef<HTMLElement | null>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sheet.current;
|
||||||
|
if (!el) return;
|
||||||
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
|
// Focus moves into the sheet: its first field, else the sheet itself.
|
||||||
|
const first = el.querySelector<HTMLElement>("input, textarea") ?? el.querySelector<HTMLElement>(FOCUSABLE);
|
||||||
|
(first ?? el).focus({ preventScroll: true });
|
||||||
|
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
// An open emoji popover closes first; it handles Escape itself.
|
||||||
|
if (el?.querySelector('[aria-haspopup="dialog"][aria-expanded="true"]')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
closeRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !el) return;
|
||||||
|
const items = [...el.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((n) => n.offsetParent !== null || n === document.activeElement);
|
||||||
|
if (!items.length) return;
|
||||||
|
const a = items[0];
|
||||||
|
const z = items[items.length - 1];
|
||||||
|
if (e.shiftKey && (document.activeElement === a || document.activeElement === el)) {
|
||||||
|
e.preventDefault();
|
||||||
|
z.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === z) {
|
||||||
|
e.preventDefault();
|
||||||
|
a.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
// Focus returns to the opener (when it is still on the page).
|
||||||
|
if (opener && opener.isConnected) opener.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className="sheet-bg" aria-label="Close" tabIndex={-1} onClick={onClose} data-testid="sheet-backdrop" />
|
||||||
|
<section
|
||||||
|
ref={sheet}
|
||||||
|
className={`sheet${className ? ` ${className}` : ""}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-testid="bottom-sheet"
|
||||||
|
>
|
||||||
|
<span className="sheet-grab" aria-hidden="true" />
|
||||||
|
<div className="sheet-h">
|
||||||
|
<h2 id={titleId} className="type-title type-title-strong">{title}</h2>
|
||||||
|
<button type="button" className="sheet-x" aria-label="Close" onClick={onClose}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6 6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="sheet-body">{children}</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -147,14 +147,17 @@ export interface DepthButtonProps extends NativeButton {
|
|||||||
label: string;
|
label: string;
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
/** Raw leading content in place of the icon tile, for example a stack of avatars. */
|
||||||
|
leading?: ReactNode;
|
||||||
/** Trailing content, for example a chevron or the current theme name. */
|
/** Trailing content, for example a chevron or the current theme name. */
|
||||||
trailing?: ReactNode;
|
trailing?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) {
|
export function DepthButton({ label, sublabel, icon, leading, trailing, className, type = "button", ...rest }: DepthButtonProps) {
|
||||||
return (
|
return (
|
||||||
<button {...rest} type={type} className={cx("btn-depth", className)}>
|
<button {...rest} type={type} className={cx("btn-depth", className)}>
|
||||||
{icon ? (
|
{leading ?? null}
|
||||||
|
{!leading && icon ? (
|
||||||
<span className="btn-depth-icon" aria-hidden="true">
|
<span className="btn-depth-icon" aria-hidden="true">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */
|
/** Loads /api/discover once. Failure leaves it null; the feed is unaffected. */
|
||||||
export function DiscoverRails() {
|
export function useDiscover(): DiscoverData | null {
|
||||||
const [data, setData] = useState<DiscoverData | null>(null);
|
const [data, setData] = useState<DiscoverData | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
|
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
|
||||||
return () => { live = false; };
|
return () => { live = false; };
|
||||||
}, []);
|
}, []);
|
||||||
return <DiscoverRailsView data={data} />;
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscoverRails() {
|
||||||
|
return <DiscoverRailsView data={useDiscover()} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Mobile Discover sheet (spec "Sheets"): three soft depth rows that open the
|
||||||
|
// Featured experts, Trending minnects and Find experts panels as 80% height
|
||||||
|
// sheets, then the Dark/Light appearance switch. Styles: src/styles/sheet.css.
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||||
|
import { useTheme } from "@/lib/use-theme";
|
||||||
|
import type { Theme } from "@/lib/theme";
|
||||||
|
import { BottomSheet } from "./BottomSheet";
|
||||||
|
import { DepthButton } from "./Buttons";
|
||||||
|
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||||
|
import { HexAvatar } from "./HexAvatar";
|
||||||
|
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
|
||||||
|
|
||||||
|
export type DiscoverPanel = "featured" | "trending" | "search";
|
||||||
|
|
||||||
|
/** Width of the center photo in the featured panel sheet. */
|
||||||
|
export const PANEL_CAROUSEL_SIZE = 150;
|
||||||
|
|
||||||
|
/** "Name1, Name2 and N more" for the Featured experts row; shorter lists read plainly. */
|
||||||
|
export function featuredSubtitle(experts: Pick<DiscoverExpert, "displayName">[]): string {
|
||||||
|
const names = experts.map((e) => e.displayName || "Expert");
|
||||||
|
if (names.length === 0) return "";
|
||||||
|
if (names.length === 1) return names[0];
|
||||||
|
if (names.length === 2) return `${names[0]} and ${names[1]}`;
|
||||||
|
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chevron() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="btn-depth-chev">
|
||||||
|
<path d="m9 6 6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" } as const;
|
||||||
|
|
||||||
|
function FlameIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon}>
|
||||||
|
<path d="M12 21c4 0 7-2.8 7-6.8 0-4.5-4-6.7-5-11.2-2.3 1.7-3.5 4-3.2 6.5C9.5 9 8.6 8 8.3 6.6 6.3 8.5 5 10.9 5 14.2 5 18.2 8 21 12 21z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon}>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m20 20-3.5-3.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon} aria-hidden="true">
|
||||||
|
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg {...icon} aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Dark/Light switch in the soft depth style; the choice persists through useTheme. */
|
||||||
|
export function AppearanceSwitch() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const options: { value: Theme; label: string; icon: ReactNode }[] = [
|
||||||
|
{ value: "dark", label: "Dark", icon: <MoonIcon /> },
|
||||||
|
{ value: "light", label: "Light", icon: <SunIcon /> },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="seg-depth" role="group" aria-label="Appearance">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.value} type="button" className={theme === o.value ? "on" : undefined} aria-pressed={theme === o.value} onClick={() => setTheme(o.value)}>
|
||||||
|
{o.icon}
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverSheetProps {
|
||||||
|
experts: DiscoverExpert[];
|
||||||
|
onOpen: (panel: DiscoverPanel) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) {
|
||||||
|
return (
|
||||||
|
<BottomSheet title="Discover" onClose={onClose} className="sheet-discover">
|
||||||
|
{experts.length > 0 ? (
|
||||||
|
<DepthButton
|
||||||
|
label="Featured experts"
|
||||||
|
sublabel={featuredSubtitle(experts)}
|
||||||
|
onClick={() => onOpen("featured")}
|
||||||
|
leading={
|
||||||
|
<span className="depth-stack" aria-hidden="true">
|
||||||
|
{experts.slice(0, 3).map((e) => (
|
||||||
|
<HexAvatar key={String(e.id)} name={e.displayName || "Expert"} pictureUrl={e.pictureUrl} size="sm" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
trailing={<Chevron />}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<DepthButton label="Trending minnects" sublabel="Answers people are watching" icon={<FlameIcon />} trailing={<Chevron />} onClick={() => onOpen("trending")} />
|
||||||
|
<DepthButton label="Find experts" sublabel="Search by name, topic or category" icon={<SearchIcon />} trailing={<Chevron />} onClick={() => onOpen("search")} />
|
||||||
|
<span className="type-eyebrow sheet-sub">Appearance</span>
|
||||||
|
<AppearanceSwitch />
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverPanelSheetProps {
|
||||||
|
panel: DiscoverPanel;
|
||||||
|
experts: DiscoverExpert[];
|
||||||
|
trending: DiscoverMinnect[];
|
||||||
|
search?: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLES: Record<DiscoverPanel, string> = {
|
||||||
|
featured: "Featured experts",
|
||||||
|
trending: "Trending minnects",
|
||||||
|
search: "Find experts",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One Discover panel as an 80% height sheet with a close button; reuses the desktop components. */
|
||||||
|
export function DiscoverPanelSheet({ panel, experts, trending, search, onClose }: DiscoverPanelSheetProps) {
|
||||||
|
return (
|
||||||
|
<BottomSheet title={TITLES[panel]} onClose={onClose} className={`sheet-panel sheet-panel-${panel}`}>
|
||||||
|
{panel === "featured" ? <FeaturedCarousel experts={experts} size={PANEL_CAROUSEL_SIZE} variant="slide" /> : null}
|
||||||
|
{panel === "trending" ? <TrendingMinnectsRail minnects={trending} /> : null}
|
||||||
|
{panel === "search" ? search : null}
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,9 +46,11 @@ export interface FeaturedCarouselProps {
|
|||||||
experts: DiscoverExpert[];
|
experts: DiscoverExpert[];
|
||||||
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
|
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
|
||||||
size?: number;
|
size?: number;
|
||||||
|
/** "slide": no tile or heading, for the phone featured slide that brings its own title. */
|
||||||
|
variant?: "panel" | "slide";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
|
export function FeaturedCarousel({ experts, size = 96, variant = "panel" }: FeaturedCarouselProps) {
|
||||||
const count = experts.length;
|
const count = experts.length;
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const reduced = useReducedMotion();
|
const reduced = useReducedMotion();
|
||||||
@@ -79,23 +81,27 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
|
|||||||
const current = index % count;
|
const current = index % count;
|
||||||
const cap = experts[current];
|
const cap = experts[current];
|
||||||
const style = { "--cfw": `${size}px` } as CSSProperties;
|
const style = { "--cfw": `${size}px` } as CSSProperties;
|
||||||
|
const bare = variant === "slide";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="tile panel rail rail-featured featured"
|
className={bare ? "featured featured-bare" : "tile panel rail rail-featured featured"}
|
||||||
aria-labelledby="featured-title"
|
aria-labelledby={bare ? undefined : "featured-title"}
|
||||||
|
aria-label={bare ? "Featured experts" : undefined}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
|
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
|
||||||
data-paused={paused ? "true" : undefined}
|
data-paused={paused ? "true" : undefined}
|
||||||
>
|
>
|
||||||
|
{bare ? null : (
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
||||||
<Star />Featured experts
|
<Star />Featured experts
|
||||||
</h2>
|
</h2>
|
||||||
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
|
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
|
||||||
{experts.map((e, i) => {
|
{experts.map((e, i) => {
|
||||||
const slot = featuredSlot(i, current, count);
|
const slot = featuredSlot(i, current, count);
|
||||||
|
|||||||
+19
-1
@@ -6,8 +6,11 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
|||||||
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||||
import type { ComposerProps } from "./Composer";
|
import type { ComposerProps } from "./Composer";
|
||||||
import { FeedView, type FeedStatus } from "./FeedView";
|
import { FeedView, type FeedStatus } from "./FeedView";
|
||||||
|
import { useCompactLayout } from "./layout-context";
|
||||||
|
|
||||||
const POLL_MS = 45_000;
|
const POLL_MS = 45_000;
|
||||||
|
|
||||||
@@ -15,11 +18,19 @@ export function Feed({
|
|||||||
onSignedOut,
|
onSignedOut,
|
||||||
feedUserId,
|
feedUserId,
|
||||||
person,
|
person,
|
||||||
|
featuredExperts,
|
||||||
|
trendingMinnects,
|
||||||
|
searchPanel,
|
||||||
}: {
|
}: {
|
||||||
onSignedOut: () => void;
|
onSignedOut: () => void;
|
||||||
feedUserId?: string | null;
|
feedUserId?: string | null;
|
||||||
/** The signed-in person, shown beside the composer. */
|
/** The signed-in person, shown beside the composer. */
|
||||||
person?: ComposerProps["person"];
|
person?: ComposerProps["person"];
|
||||||
|
/** Shown as a slide after the second post on phones. */
|
||||||
|
featuredExperts?: DiscoverExpert[];
|
||||||
|
/** Phone Discover sheet: trending minnects and the Find experts panel. */
|
||||||
|
trendingMinnects?: DiscoverMinnect[];
|
||||||
|
searchPanel?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||||
@@ -44,6 +55,9 @@ export function Feed({
|
|||||||
const list = useRef<HTMLDivElement | null>(null);
|
const list = useRef<HTMLDivElement | null>(null);
|
||||||
const busy = useRef(false);
|
const busy = useRef(false);
|
||||||
const shown = useRef<Set<string>>(new Set());
|
const shown = useRef<Set<string>>(new Set());
|
||||||
|
// The list element changes between the panel and the phone reel; the
|
||||||
|
// infinite-scroll observer re-attaches to the new one.
|
||||||
|
const compact = useCompactLayout();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shown.current = new Set(posts.map((p) => p.key));
|
shown.current = new Set(posts.map((p) => p.key));
|
||||||
@@ -117,7 +131,7 @@ export function Feed({
|
|||||||
);
|
);
|
||||||
io.observe(el);
|
io.observe(el);
|
||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
|
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]);
|
||||||
|
|
||||||
// Real-time: poll the first page while the tab is visible.
|
// Real-time: poll the first page while the tab is visible.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -276,6 +290,10 @@ export function Feed({
|
|||||||
deleteErrors={deleteErrors}
|
deleteErrors={deleteErrors}
|
||||||
onSignedOut={onSignedOut}
|
onSignedOut={onSignedOut}
|
||||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
||||||
|
featuredExperts={featuredExperts}
|
||||||
|
trendingMinnects={trendingMinnects}
|
||||||
|
searchPanel={searchPanel}
|
||||||
|
hasMore={!!nextCursor}
|
||||||
sentinelRef={sentinel}
|
sentinelRef={sentinel}
|
||||||
listRef={list}
|
listRef={list}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode, Ref } from "react";
|
import type { ReactNode, Ref } from "react";
|
||||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||||
|
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||||
import { Composer, type ComposerProps } from "./Composer";
|
import { Composer, type ComposerProps } from "./Composer";
|
||||||
import { GrowButton, IconButton } from "./Buttons";
|
import { GrowButton, IconButton } from "./Buttons";
|
||||||
import { PostCard } from "./PostCard";
|
import { PostCard } from "./PostCard";
|
||||||
|
import { useCompactLayout } from "./layout-context";
|
||||||
|
import { MobileFeed, type CurrentSlide } from "./MobileFeed";
|
||||||
|
|
||||||
export type FeedStatus = "loading" | "ready" | "error";
|
export type FeedStatus = "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -33,6 +38,22 @@ export interface FeedViewProps {
|
|||||||
/** The scrolling feed list; the page itself never scrolls. */
|
/** The scrolling feed list; the page itself never scrolls. */
|
||||||
listRef?: Ref<HTMLDivElement>;
|
listRef?: Ref<HTMLDivElement>;
|
||||||
now?: number;
|
now?: number;
|
||||||
|
/** Another page can load; the phone feed ends with a loading slide. */
|
||||||
|
hasMore?: boolean;
|
||||||
|
/** Phone layout only: experts for the featured slide after the second post. */
|
||||||
|
featuredExperts?: DiscoverExpert[];
|
||||||
|
/** Phone layout only: trending minnects for the Discover sheet. */
|
||||||
|
trendingMinnects?: DiscoverMinnect[];
|
||||||
|
/** Phone layout only: the Find experts panel shown in the Discover sheet. */
|
||||||
|
searchPanel?: ReactNode;
|
||||||
|
/** Phone layout only: the slide on screen changed (for the dock). */
|
||||||
|
onCurrentChange?: (slide: CurrentSlide) => void;
|
||||||
|
/** Phone layout only: the dock's comments button, for the post on screen. */
|
||||||
|
onOpenComments?: (postId: string) => void;
|
||||||
|
/** Phone layout only: the dock's new-post button. */
|
||||||
|
onOpenComposer?: () => void;
|
||||||
|
/** Phone layout only: the dock's More button (the Discover sheet). */
|
||||||
|
onOpenMore?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Spinner() {
|
function Spinner() {
|
||||||
@@ -104,6 +125,9 @@ function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { chi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedView(p: FeedViewProps) {
|
export function FeedView(p: FeedViewProps) {
|
||||||
|
// At 760px and below the feed is the full-screen snap reel instead of the panel.
|
||||||
|
const compact = useCompactLayout();
|
||||||
|
if (compact) return <MobileFeed {...p} />;
|
||||||
if (p.status === "loading") {
|
if (p.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<FeedPanel onRefresh={p.onRefresh} busy>
|
<FeedPanel onRefresh={p.onRefresh} busy>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { commentsLabel } from "@/lib/feed-model";
|
||||||
|
import { SparkleButton } from "./Buttons";
|
||||||
|
import type { CurrentSlide } from "./MobileFeed";
|
||||||
|
import type { CardPost } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
const icon = {
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 1.7,
|
||||||
|
strokeLinecap: "round" as const,
|
||||||
|
strokeLinejoin: "round" as const,
|
||||||
|
"aria-hidden": true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MobileDockProps {
|
||||||
|
/** The slide on screen; like and comments act on its post. */
|
||||||
|
slide: CurrentSlide;
|
||||||
|
onToggleLike?: (post: CardPost) => void;
|
||||||
|
likeBusy?: boolean;
|
||||||
|
onOpenComments?: (postId: string) => void;
|
||||||
|
onOpenComposer?: () => void;
|
||||||
|
onOpenMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the like and comment buttons show and whether they work, for the slide on screen. */
|
||||||
|
export function dockState(slide: CurrentSlide, opts: { canLike?: boolean; likeBusy?: boolean } = {}) {
|
||||||
|
const post = slide.kind === "post" ? slide.post : null;
|
||||||
|
if (!post) {
|
||||||
|
return { post: null, liked: false, likes: null, comments: null, likeLabel: "Like", commentsLabel: "Comments", likeDisabled: true, commentsDisabled: true };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
post,
|
||||||
|
liked: post.isLiked,
|
||||||
|
likes: post.likesCount,
|
||||||
|
comments: post.commentsCount,
|
||||||
|
likeLabel: post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`,
|
||||||
|
commentsLabel: `Open comments, ${commentsLabel(post.commentsCount).toLowerCase()}`,
|
||||||
|
likeDisabled: !post.id || !opts.canLike || !!opts.likeBusy,
|
||||||
|
commentsDisabled: !post.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone dock (spec "Mobile layout" > "Bottom dock"): a frosted bar 16px above the
|
||||||
|
* bottom. Like and comments act on the post on screen and are disabled on the
|
||||||
|
* featured and loading slides. Liked state and counts come from the post itself,
|
||||||
|
* the same source as the desktop like button and double-tap.
|
||||||
|
*/
|
||||||
|
export function MobileDock({ slide, onToggleLike, likeBusy, onOpenComments, onOpenComposer, onOpenMore }: MobileDockProps) {
|
||||||
|
const s = dockState(slide, { canLike: !!onToggleLike, likeBusy });
|
||||||
|
const [beat, setBeat] = useState(false);
|
||||||
|
return (
|
||||||
|
<nav className="dock" aria-label="Post actions" data-testid="feed-dock">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dk dk-like${s.liked ? " dk-like-on" : ""}${beat && s.liked ? " dk-beat" : ""}`}
|
||||||
|
aria-pressed={s.liked}
|
||||||
|
aria-label={s.likeLabel}
|
||||||
|
disabled={s.likeDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!s.post) return;
|
||||||
|
setBeat(!s.post.isLiked);
|
||||||
|
onToggleLike?.(s.post);
|
||||||
|
}}
|
||||||
|
onAnimationEnd={() => setBeat(false)}
|
||||||
|
>
|
||||||
|
<svg {...icon} fill={s.liked ? "currentColor" : "none"}>
|
||||||
|
<path d="M12 20.5s-7.5-4.4-9.4-9.1C1.3 8 3.1 4.5 6.6 4.5c2 0 3.5 1.1 4.3 2.4.5.7 1.7.7 2.2 0 .8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.3 3.5 4 6.9-1.9 4.7-9.4 9.1-9.4 9.1z" />
|
||||||
|
</svg>
|
||||||
|
{s.likes !== null ? <span className="dk-n" data-testid="dock-likes">{s.likes}</span> : null}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dk dk-comments"
|
||||||
|
aria-label={s.commentsLabel}
|
||||||
|
disabled={s.commentsDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (s.post?.id) onOpenComments?.(s.post.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg {...icon}>
|
||||||
|
<path d="M21 12a8.5 8.5 0 0 1-12.4 7.6L3.5 21l1.4-4.6A8.5 8.5 0 1 1 21 12z" />
|
||||||
|
</svg>
|
||||||
|
{s.comments !== null ? <span className="dk-n" data-testid="dock-comments">{s.comments}</span> : null}
|
||||||
|
</button>
|
||||||
|
<SparkleButton variant="fab" label="New post" className="dk-plus" onClick={() => onOpenComposer?.()} />
|
||||||
|
<button type="button" className="dk dk-more" aria-label="View more" onClick={() => onOpenMore?.()}>
|
||||||
|
<svg {...icon}>
|
||||||
|
<rect x="3.5" y="3.5" width="7" height="7" rx="2" />
|
||||||
|
<rect x="13.5" y="3.5" width="7" height="7" rx="2" />
|
||||||
|
<rect x="3.5" y="13.5" width="7" height="7" rx="2" />
|
||||||
|
<rect x="13.5" y="13.5" width="7" height="7" rx="2" />
|
||||||
|
</svg>
|
||||||
|
More
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type MouseEvent, type Ref } from "react";
|
||||||
|
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
||||||
|
import { GrowButton } from "./Buttons";
|
||||||
|
import { HexAvatar } from "./HexAvatar";
|
||||||
|
import { PlayIcon, PostMenu } from "./PostCard";
|
||||||
|
import { VideoPlayer } from "./VideoPlayer";
|
||||||
|
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import { useTheme } from "@/lib/use-theme";
|
||||||
|
import { MobileDock } from "./MobileDock";
|
||||||
|
import { BottomSheet } from "./BottomSheet";
|
||||||
|
import { CommentsSection } from "./CommentsSection";
|
||||||
|
import { Composer } from "./Composer";
|
||||||
|
import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet";
|
||||||
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
|
import type { FeedViewProps } from "./FeedView";
|
||||||
|
|
||||||
|
/** The slide on screen: its index in the reel and its post (null on the loading slide). */
|
||||||
|
export interface CurrentSlide {
|
||||||
|
index: number;
|
||||||
|
/** "post" is a real post; "featured" and "load" are not posts (the dock disables like and comment on them). */
|
||||||
|
kind: "post" | "featured" | "load";
|
||||||
|
post: CardPost | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The featured experts slide comes after this many posts. */
|
||||||
|
export const FEATURED_AFTER = 2;
|
||||||
|
/** Two taps this close together (ms) make a double-tap. */
|
||||||
|
export const DOUBLE_TAP_MS = 300;
|
||||||
|
/** How long the heart burst stays on screen (ms). */
|
||||||
|
export const BURST_MS = 950;
|
||||||
|
|
||||||
|
/** True when a tap at `now` follows a tap at `last` closely enough to be a double-tap. */
|
||||||
|
export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_TAP_MS): boolean {
|
||||||
|
return last !== null && now - last >= 0 && now - last <= windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taps on controls (buttons, links, video, menus, fields) never count toward a double-tap. */
|
||||||
|
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']");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */
|
||||||
|
export function featuredSlideIndex(postCount: number, expertCount: number): number {
|
||||||
|
return expertCount > 0 && postCount >= FEATURED_AFTER ? FEATURED_AFTER : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share of a slide that must be on screen before it counts as the current one. */
|
||||||
|
const CURRENT_RATIO = 0.6;
|
||||||
|
|
||||||
|
/** Smallest finger travel, in px, that counts as a swipe to the next or previous slide. */
|
||||||
|
const SWIPE_MIN = 40;
|
||||||
|
/** Wheel steps closer together than this belong to one gesture (trackpad inertia). */
|
||||||
|
const WHEEL_QUIET_MS = 220;
|
||||||
|
/** A wheel gesture moves at most one slide in this time. */
|
||||||
|
const WHEEL_STEP_MS = 520;
|
||||||
|
|
||||||
|
/** The slide one step away from `from` in the direction of `delta`, kept inside the reel. */
|
||||||
|
export function stepTarget(from: number, delta: number, count: number): number {
|
||||||
|
if (!delta || count <= 0) return Math.min(Math.max(from, 0), Math.max(count - 1, 0));
|
||||||
|
return Math.min(Math.max(from + Math.sign(delta), 0), count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the slide on screen, given which slides are now mostly visible. Keeps the old one when none is. */
|
||||||
|
export function pickCurrent(previous: number, visible: number[]): number {
|
||||||
|
if (!visible.length) return previous;
|
||||||
|
return visible.includes(previous) ? previous : Math.min(...visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
||||||
|
if (typeof ref === "function") ref(value);
|
||||||
|
else if (ref) (ref as { current: T | null }).current = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlideMedia({ media, author }: { media: CardMedia; author: string }) {
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
const [started, setStarted] = useState(false);
|
||||||
|
const playable = isPlayableVideo(media);
|
||||||
|
if (playable) {
|
||||||
|
const duration = formatDuration(media.durationSeconds);
|
||||||
|
const start = () => {
|
||||||
|
setStarted(true);
|
||||||
|
void box.current?.querySelector("video")?.play().catch(() => {});
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
{started ? null : (
|
||||||
|
<>
|
||||||
|
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={start}>
|
||||||
|
<PlayIcon />
|
||||||
|
</button>
|
||||||
|
{duration ? <span className="post-dur slide-dur">{duration}</span> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (media.src) {
|
||||||
|
return (
|
||||||
|
<div className="slide-media">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img className="slide-art" src={media.src} alt={media.kind === "video" ? "Video thumbnail" : ""} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="slide-media slide-pending" role="img" aria-label="Video is processing">
|
||||||
|
<span>Video is processing</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Several media items in one slide: a sideways scroll-snap track with a dot per item. */
|
||||||
|
function MediaTrack({ items, author }: { items: CardMedia[]; author: string }) {
|
||||||
|
const track = useRef<HTMLDivElement>(null);
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const onScroll = () => {
|
||||||
|
const el = track.current;
|
||||||
|
if (!el) return;
|
||||||
|
const next = Math.min(items.length - 1, Math.max(0, Math.round(el.scrollLeft / Math.max(el.clientWidth, 1))));
|
||||||
|
if (next === active) return;
|
||||||
|
setActive(next);
|
||||||
|
// A video stops when it is swiped away.
|
||||||
|
el.querySelectorAll<HTMLElement>(":scope > .slide-pane").forEach((pane, i) => {
|
||||||
|
if (i !== next) pane.querySelectorAll("video").forEach((v) => v.pause());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="slide-dots" data-testid="slide-dots" aria-hidden="true">
|
||||||
|
{items.map((_, i) => (
|
||||||
|
<span key={i} className={`slide-dot${i === active ? " slide-dot-on" : ""}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostSlide({ post, index, current, p }: { post: CardPost; index: number; current: boolean; p: FeedViewProps }) {
|
||||||
|
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" : ""}`;
|
||||||
|
const lastTap = useRef<number | null>(null);
|
||||||
|
const [burst, setBurst] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!burst) return;
|
||||||
|
const t = window.setTimeout(() => setBurst(0), BURST_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [burst]);
|
||||||
|
// Double-tap likes (never unlikes) and plays the burst. A tap that scrolls or swipes never reaches click.
|
||||||
|
const onTap = (e: MouseEvent<HTMLElement>) => {
|
||||||
|
if (!isTapTarget(e.target)) { lastTap.current = null; return; }
|
||||||
|
const now = performance.now();
|
||||||
|
if (!isDoubleTap(lastTap.current, now)) { lastTap.current = now; return; }
|
||||||
|
lastTap.current = null;
|
||||||
|
if (!post.isLiked && post.id && p.onToggleLike && !p.likeBusy?.has(post.key)) p.onToggleLike(post);
|
||||||
|
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) setBurst((n) => n + 1);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cls}
|
||||||
|
data-index={index}
|
||||||
|
data-testid="feed-slide"
|
||||||
|
data-liked={post.isLiked ? "true" : "false"}
|
||||||
|
onClick={onTap}
|
||||||
|
aria-label={`Post by ${post.authorName}`}
|
||||||
|
onPlayCapture={() => setPlaying(true)}
|
||||||
|
onPauseCapture={() => setPlaying(false)}
|
||||||
|
onEndedCapture={() => setPlaying(false)}
|
||||||
|
>
|
||||||
|
{media ? (
|
||||||
|
<>
|
||||||
|
{items.length > 1 ? <MediaTrack items={items} author={post.authorName} /> : <SlideMedia media={media} author={post.authorName} />}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{burst ? (
|
||||||
|
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 20.5s-7.5-4.4-9.4-9.1C1.3 8 3.1 4.5 6.6 4.5c2 0 3.5 1.1 4.3 2.4.5.7 1.7.7 2.2 0 .8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.3 3.5 4 6.9-1.9 4.7-9.4 9.1-9.4 9.1z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
{own ? (
|
||||||
|
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
|
||||||
|
) : null}
|
||||||
|
<div className="slide-meta">
|
||||||
|
{p.deleteErrors?.has(post.key) ? <p className="slide-error" role="alert">Could not delete the post.</p> : null}
|
||||||
|
<div className="slide-who">
|
||||||
|
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
{post.authorName}
|
||||||
|
{own ? <span className="post-you slide-you">You</span> : null}
|
||||||
|
</strong>
|
||||||
|
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{media && post.text ? <p className="slide-text">{post.text}</p> : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Height of the progress rail, as a percent: the slide on screen out of the loaded slides. */
|
||||||
|
export function railPercent(index: number, count: number): number {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
return Math.round((Math.min(Math.max(index, 0), count - 1) + 1) / count * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topIcon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as const;
|
||||||
|
|
||||||
|
/** Frosted pills over the feed (spec "Top bar"): logo and wordmark left, theme toggle right. */
|
||||||
|
function TopBar() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const next = theme === "dark" ? "light" : "dark";
|
||||||
|
return (
|
||||||
|
<div className="ph-top" data-testid="top-bar">
|
||||||
|
<div className="ph-brand ph-glass">
|
||||||
|
<Logo size={24} />
|
||||||
|
<span className="type-wordmark">MINNECT</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ph-theme ph-glass" data-testid="top-theme" aria-label={`Switch to ${next} theme`} onClick={toggleTheme}>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<svg {...topIcon}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg {...topIcon}><path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" /></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChevronDown() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Not a post: the dock disables like and comment here (data-slide-kind="featured"). */
|
||||||
|
function FeaturedSlide({ index, current, experts }: { index: number; current: boolean; experts: DiscoverExpert[] }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`slide slide-featured${current ? " is-cur" : ""}`}
|
||||||
|
data-index={index}
|
||||||
|
data-slide-kind="featured"
|
||||||
|
data-testid="feed-slide-featured"
|
||||||
|
aria-label="Featured experts"
|
||||||
|
>
|
||||||
|
<span className="type-eyebrow slide-feat-eyebrow">Featured experts</span>
|
||||||
|
<h2 className="type-title type-title-strong slide-feat-title">Ask the people everyone is talking about</h2>
|
||||||
|
<div className="slide-feat-cf">
|
||||||
|
<FeaturedCarousel experts={experts} size={156} variant="slide" />
|
||||||
|
</div>
|
||||||
|
<span className="slide-hint">
|
||||||
|
<ChevronDown />
|
||||||
|
Keep scrolling
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref<HTMLDivElement> }) {
|
||||||
|
return (
|
||||||
|
<section className={`slide slide-load${current ? " is-cur" : ""}`} data-index={index} data-testid="feed-slide-load" aria-label="Loading more posts">
|
||||||
|
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
|
||||||
|
<div className="slide-load-body" role="status" aria-label="Loading more posts">
|
||||||
|
<div className="slide-skel">
|
||||||
|
<div className="slide-skel-head">
|
||||||
|
<i className="slide-skel-hex" />
|
||||||
|
<div className="slide-skel-lines">
|
||||||
|
<i style={{ width: "40%" }} />
|
||||||
|
<i style={{ width: "24%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i style={{ width: "92%" }} />
|
||||||
|
<i style={{ width: "76%" }} />
|
||||||
|
<i style={{ width: "54%" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone feed (spec "Mobile layout" > "Full-screen feed"): one post per screen,
|
||||||
|
* snapping. Uses the same posts, delete and paging as the desktop list: the
|
||||||
|
* reel is the list ref (the infinite-scroll root) and the loading slide holds
|
||||||
|
* the sentinel. Reports the slide on screen through onCurrentChange.
|
||||||
|
*/
|
||||||
|
export function MobileFeed(p: FeedViewProps) {
|
||||||
|
const reel = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
// 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), []);
|
||||||
|
// A successful post closes the composer: publishing ends with no error and an emptied draft.
|
||||||
|
const wasBusy = useRef(false);
|
||||||
|
const composerBusy = !!p.composer?.busy;
|
||||||
|
const composerClear = !!p.composer && !p.composer.error && !p.composer.value && !p.composer.file;
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasBusy.current && !composerBusy && composerClear) setSheet((s) => (s?.kind === "composer" ? null : s));
|
||||||
|
wasBusy.current = composerBusy;
|
||||||
|
}, [composerBusy, composerClear]);
|
||||||
|
const onCurrentChange = p.onCurrentChange;
|
||||||
|
const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore));
|
||||||
|
const experts = p.featuredExperts ?? [];
|
||||||
|
const postCount = p.status === "ready" ? p.posts.length : 0;
|
||||||
|
const featuredAt = p.status === "ready" ? featuredSlideIndex(postCount, experts.length) : -1;
|
||||||
|
const slideCount = postCount + (featuredAt >= 0 ? 1 : 0) + (showLoad ? 1 : 0);
|
||||||
|
/** Slide index of post `i`: posts after the featured slide shift down by one. */
|
||||||
|
const slideOf = (i: number) => (featuredAt >= 0 && i >= featuredAt ? i + 1 : i);
|
||||||
|
const postAt = (slide: number) => (featuredAt >= 0 && slide === featuredAt ? -1 : featuredAt >= 0 && slide > featuredAt ? slide - 1 : slide);
|
||||||
|
|
||||||
|
// Track the slide on screen. A fresh observer per change picks up new slides.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = reel.current;
|
||||||
|
if (!root || typeof IntersectionObserver === "undefined") return;
|
||||||
|
const ratios = new Map<number, number>();
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) ratios.set(Number((e.target as HTMLElement).dataset.index), e.intersectionRatio);
|
||||||
|
const visible = [...ratios].filter(([, r]) => r >= CURRENT_RATIO).map(([i]) => i);
|
||||||
|
setCurrent((prev) => pickCurrent(prev, visible));
|
||||||
|
},
|
||||||
|
{ root, threshold: [0, CURRENT_RATIO, 1] },
|
||||||
|
);
|
||||||
|
root.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => io.observe(el));
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [slideCount]);
|
||||||
|
|
||||||
|
// One wheel notch or one swipe moves exactly one slide. Native snapping alone
|
||||||
|
// sends a short or slow gesture back to the slide it started on.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = reel.current;
|
||||||
|
if (!root) return;
|
||||||
|
const count = () => root.querySelectorAll(":scope > .slide").length;
|
||||||
|
const at = () => Math.round(root.scrollTop / Math.max(root.clientHeight, 1));
|
||||||
|
const go = (to: number) => {
|
||||||
|
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
root.scrollTo({ top: to * root.clientHeight, behavior: calm ? "auto" : "smooth" });
|
||||||
|
};
|
||||||
|
let lastWheel = 0;
|
||||||
|
let lastStep = 0;
|
||||||
|
let from = 0;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const now = performance.now();
|
||||||
|
const fresh = now - lastWheel > WHEEL_QUIET_MS && now - lastStep > WHEEL_STEP_MS;
|
||||||
|
lastWheel = now;
|
||||||
|
if (!fresh || !e.deltaY) return;
|
||||||
|
lastStep = now;
|
||||||
|
go(stepTarget(at(), e.deltaY, count()));
|
||||||
|
};
|
||||||
|
let startY: number | null = null;
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
startY = e.touches.length === 1 ? e.touches[0].clientY : null;
|
||||||
|
from = at();
|
||||||
|
};
|
||||||
|
const onTouchEnd = (e: TouchEvent) => {
|
||||||
|
if (startY === null || e.touches.length) return;
|
||||||
|
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
|
||||||
|
startY = null;
|
||||||
|
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
|
||||||
|
};
|
||||||
|
root.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
root.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||||
|
root.addEventListener("touchend", onTouchEnd, { passive: true });
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener("wheel", onWheel);
|
||||||
|
root.removeEventListener("touchstart", onTouchStart);
|
||||||
|
root.removeEventListener("touchend", onTouchEnd);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep the index inside the list when posts are removed.
|
||||||
|
const index = Math.min(current, Math.max(0, slideCount - 1));
|
||||||
|
const currentPostIndex = postAt(index);
|
||||||
|
const currentPost = p.status === "ready" && currentPostIndex >= 0 ? p.posts[currentPostIndex] ?? null : null;
|
||||||
|
const kind: CurrentSlide["kind"] = currentPost ? "post" : featuredAt >= 0 && index === featuredAt ? "featured" : "load";
|
||||||
|
|
||||||
|
const onMedia = !!currentPost && currentPost.media.some((m) => m.kind === "video" || m.src);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCurrentChange?.({ index, kind, post: currentPost });
|
||||||
|
}, [index, kind, currentPost, onCurrentChange]);
|
||||||
|
|
||||||
|
// A video keeps playing only while its slide is on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
reel.current?.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => {
|
||||||
|
if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause());
|
||||||
|
});
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="reel-feed" aria-label="Home feed" data-on-media={onMedia ? "true" : "false"}>
|
||||||
|
{p.newCount > 0 && p.status === "ready" ? (
|
||||||
|
<div className="reel-pill">
|
||||||
|
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<TopBar />
|
||||||
|
<div className="rail-v" data-testid="feed-rail" aria-hidden="true">
|
||||||
|
<i style={{ height: `${railPercent(index, slideCount)}%` }} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="reel"
|
||||||
|
data-testid="feed-reel"
|
||||||
|
data-current={index}
|
||||||
|
ref={(el) => {
|
||||||
|
reel.current = el;
|
||||||
|
setRef(p.listRef, el);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.status === "error" ? (
|
||||||
|
<section className="slide slide-message is-cur" data-index={0} role="alert">
|
||||||
|
<p>The feed did not load.</p>
|
||||||
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{p.status === "ready" && p.posts.length === 0 && !showLoad ? (
|
||||||
|
<section className="slide slide-message is-cur" data-index={0}>
|
||||||
|
<p>No posts yet.</p>
|
||||||
|
</section>
|
||||||
|
) : 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} />;
|
||||||
|
return i === featuredAt - 1
|
||||||
|
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
|
||||||
|
: [node];
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
{p.status === "ready" && p.loadMoreFailed ? (
|
||||||
|
<section className="slide slide-message" data-index={slideCount} role="alert">
|
||||||
|
<p>The feed did not load.</p>
|
||||||
|
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{showLoad ? (
|
||||||
|
<LoadSlide
|
||||||
|
index={slideCount - 1}
|
||||||
|
current={index === slideCount - 1}
|
||||||
|
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{p.status !== "error" ? (
|
||||||
|
<MobileDock
|
||||||
|
slide={{ index, kind, post: currentPost }}
|
||||||
|
onToggleLike={p.onToggleLike}
|
||||||
|
likeBusy={currentPost ? p.likeBusy?.has(currentPost.key) : false}
|
||||||
|
onOpenComments={(postId) => {
|
||||||
|
setSheet({ kind: "comments", postId });
|
||||||
|
p.onOpenComments?.(postId);
|
||||||
|
}}
|
||||||
|
onOpenComposer={() => {
|
||||||
|
setSheet({ kind: "composer" });
|
||||||
|
p.onOpenComposer?.();
|
||||||
|
}}
|
||||||
|
onOpenMore={() => {
|
||||||
|
setSheet({ kind: "more" });
|
||||||
|
p.onOpenMore?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{sheet?.kind === "comments" ? (
|
||||||
|
<BottomSheet title="Comments" onClose={closeSheet} className="sheet-comments">
|
||||||
|
<CommentsSection postId={sheet.postId} onSignedOut={p.onSignedOut} />
|
||||||
|
</BottomSheet>
|
||||||
|
) : null}
|
||||||
|
{sheet?.kind === "composer" && p.composer ? (
|
||||||
|
<BottomSheet title="New post" onClose={closeSheet} className="sheet-composer">
|
||||||
|
<Composer {...p.composer} />
|
||||||
|
</BottomSheet>
|
||||||
|
) : null}
|
||||||
|
{sheet?.kind === "more" ? (
|
||||||
|
<DiscoverSheet experts={experts} onOpen={(panel) => setSheet({ kind: "panel", panel })} onClose={closeSheet} />
|
||||||
|
) : null}
|
||||||
|
{sheet?.kind === "panel" ? (
|
||||||
|
<DiscoverPanelSheet panel={sheet.panel} experts={experts} trending={p.trendingMinnects ?? []} search={p.searchPanel} onClose={closeSheet} />
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+35
-19
@@ -59,7 +59,7 @@ function TrashIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayIcon() {
|
export function PlayIcon() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
|
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
|
||||||
@@ -91,13 +91,21 @@ function PostVideo({ media, author }: { media: CardMedia; author: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
export interface PostMenuProps {
|
||||||
const [open, setOpen] = useState(false);
|
post: CardPost;
|
||||||
|
onDelete?: (post: CardPost) => void;
|
||||||
|
deleteBusy?: boolean;
|
||||||
|
/** Extra class on the wrapper, e.g. the mobile slide's frosted position. */
|
||||||
|
className?: string;
|
||||||
|
/** Class for the "..." button; defaults to the card's button. */
|
||||||
|
buttonClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The own-post "..." menu with the delete confirm. Shared by the card and the mobile slide. */
|
||||||
|
export function PostMenu({ post, onDelete, deleteBusy, className, buttonClassName = "post-menu-btn" }: PostMenuProps) {
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [beat, setBeat] = useState(false);
|
|
||||||
const menuBox = useRef<HTMLDivElement>(null);
|
const menuBox = useRef<HTMLDivElement>(null);
|
||||||
const own = !!(canDelete && onDelete);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
@@ -113,21 +121,10 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
|
|||||||
}, [menu]);
|
}, [menu]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
|
<div className={className ? `post-menu ${className}` : "post-menu"} ref={menuBox}>
|
||||||
<header className="post-head">
|
|
||||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
|
||||||
<div className="post-who">
|
|
||||||
<strong>
|
|
||||||
{post.authorName}
|
|
||||||
{own ? <span className="post-you">You</span> : null}
|
|
||||||
</strong>
|
|
||||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
|
||||||
</div>
|
|
||||||
{own ? (
|
|
||||||
<div className="post-menu" ref={menuBox}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={menu ? "post-menu-btn post-menu-btn-open" : "post-menu-btn"}
|
className={menu ? `${buttonClassName} ${buttonClassName}-open` : buttonClassName}
|
||||||
aria-label="More actions"
|
aria-label="More actions"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={menu}
|
aria-expanded={menu}
|
||||||
@@ -169,7 +166,26 @@ export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeErr
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [beat, setBeat] = useState(false);
|
||||||
|
const own = !!(canDelete && onDelete);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
|
||||||
|
<header className="post-head">
|
||||||
|
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||||
|
<div className="post-who">
|
||||||
|
<strong>
|
||||||
|
{post.authorName}
|
||||||
|
{own ? <span className="post-you">You</span> : null}
|
||||||
|
</strong>
|
||||||
|
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||||
|
</div>
|
||||||
|
{own ? <PostMenu post={post} onDelete={onDelete} deleteBusy={deleteBusy} /> : null}
|
||||||
</header>
|
</header>
|
||||||
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
|
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
|
||||||
{post.text ? <p className="post-text">{post.text}</p> : null}
|
{post.text ? <p className="post-text">{post.text}</p> : null}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface VideoPlayerProps {
|
|||||||
hlsUrl?: string | null;
|
hlsUrl?: string | null;
|
||||||
poster: string | null;
|
poster: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Native controls; off until the caller's own play button starts the video. Default on. */
|
||||||
|
controls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNPLAYABLE = "Video can't be played here.";
|
const UNPLAYABLE = "Video can't be played here.";
|
||||||
@@ -18,7 +20,7 @@ const UNPLAYABLE = "Video can't be played here.";
|
|||||||
* unsupported), else thumbnail only. Never
|
* unsupported), else thumbnail only. Never
|
||||||
* autoplays. Any failure falls back to the thumbnail with a short note.
|
* autoplays. Any failure falls back to the thumbnail with a short note.
|
||||||
*/
|
*/
|
||||||
export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) {
|
export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true }: VideoPlayerProps) {
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps)
|
|||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={mp4Url ?? undefined}
|
src={mp4Url ?? undefined}
|
||||||
controls
|
controls={controls}
|
||||||
playsInline
|
playsInline
|
||||||
preload="none"
|
preload="none"
|
||||||
poster={poster ?? undefined}
|
poster={poster ?? undefined}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
/** Widest `.app` width that gets the phone layout (spec "Mobile layout", ADR 0001 section 3). */
|
||||||
|
export const COMPACT_MAX_WIDTH = 760;
|
||||||
|
|
||||||
|
/** True when `.app` is 760px wide or less. Matches `@container app (max-width: 760px)`. */
|
||||||
|
export function isCompactWidth(width: number): boolean {
|
||||||
|
return width > 0 && width <= COMPACT_MAX_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the signed-in shell is in the phone layout. AppShell measures `.app`
|
||||||
|
* and provides it; outside the shell (tests, other pages) it is false, so the
|
||||||
|
* desktop markup renders.
|
||||||
|
*/
|
||||||
|
export const CompactLayoutContext = createContext(false);
|
||||||
|
|
||||||
|
export function useCompactLayout(): boolean {
|
||||||
|
return useContext(CompactLayoutContext);
|
||||||
|
}
|
||||||
+50
-3
@@ -3,7 +3,7 @@
|
|||||||
// Dev-only: the real signed-in shell with fixture data and no network calls.
|
// Dev-only: the real signed-in shell with fixture data and no network calls.
|
||||||
// Likes, search and the composer work on local state so the panels can be
|
// Likes, search and the composer work on local state so the panels can be
|
||||||
// checked by hand. Used by src/app/dev/feed/page.tsx.
|
// checked by hand. Used by src/app/dev/feed/page.tsx.
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||||
import { ExpertSearchView } from "@/components/ExpertSearch";
|
import { ExpertSearchView } from "@/components/ExpertSearch";
|
||||||
@@ -31,6 +31,38 @@ export function FeedPreview() {
|
|||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const list = useRef<HTMLDivElement | null>(null);
|
const list = useRef<HTMLDivElement | null>(null);
|
||||||
|
// The list element swaps between the panel and the phone reel; paging re-attaches to it.
|
||||||
|
const [listEl, setListEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
const listRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
|
list.current = el;
|
||||||
|
setListEl(el);
|
||||||
|
}, []);
|
||||||
|
// Paging: two more fixture pages load when the sentinel scrolls into view,
|
||||||
|
// the same way Feed pages (the observer root is the list or the phone reel).
|
||||||
|
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [paging, setPaging] = useState(false);
|
||||||
|
const hasMore = page < 2;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinel.current;
|
||||||
|
if (!el || !hasMore || paging) return;
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
if (!entries.some((e) => e.isIntersecting)) return;
|
||||||
|
setPaging(true);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const next = page + 1;
|
||||||
|
const items = FIXTURE_FEED_ITEMS.map((raw) => {
|
||||||
|
const it = raw as { post: { id: string } };
|
||||||
|
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
|
||||||
|
});
|
||||||
|
setPosts((cur) => [...cur, ...toCardPosts(items)]);
|
||||||
|
setPage(next);
|
||||||
|
setPaging(false);
|
||||||
|
}, 900);
|
||||||
|
}, { root: listEl, rootMargin: "400px" });
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [hasMore, paging, page, posts.length, listEl]);
|
||||||
|
|
||||||
// The comments calls answer from the fixtures; the preview never reaches the network.
|
// The comments calls answer from the fixtures; the preview never reaches the network.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,8 +123,23 @@ export function FeedPreview() {
|
|||||||
<FeedView
|
<FeedView
|
||||||
posts={posts}
|
posts={posts}
|
||||||
status="ready"
|
status="ready"
|
||||||
loadingMore={loadingMore}
|
loadingMore={loadingMore || paging}
|
||||||
listRef={list}
|
hasMore={hasMore}
|
||||||
|
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
|
||||||
|
trendingMinnects={FIXTURE_DISCOVER.trendingMinnects}
|
||||||
|
searchPanel={
|
||||||
|
<ExpertSearchView
|
||||||
|
query={query}
|
||||||
|
onQuery={setQuery}
|
||||||
|
categories={FIXTURE_CATEGORIES}
|
||||||
|
activeCategory={category}
|
||||||
|
onCategory={setCategory}
|
||||||
|
status="ready"
|
||||||
|
results={results}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sentinelRef={sentinel}
|
||||||
|
listRef={listRef}
|
||||||
loadMoreFailed={false}
|
loadMoreFailed={false}
|
||||||
newCount={newCount}
|
newCount={newCount}
|
||||||
onRetry={() => {}}
|
onRetry={() => {}}
|
||||||
|
|||||||
@@ -89,6 +89,22 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
|
|||||||
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
|
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "post",
|
||||||
|
publishedAt: "2026-10-18T21:20:00Z",
|
||||||
|
likesCount: 22,
|
||||||
|
commentsCount: 2,
|
||||||
|
post: {
|
||||||
|
id: "p7",
|
||||||
|
content: "Workshop recap: two slides and the demo recording. Swipe sideways for all three.",
|
||||||
|
author: author("a7", "Leila Haddad"),
|
||||||
|
mediaItems: [
|
||||||
|
{ mediaType: "image", imageUrl: placeholderImage(140, "Slide one"), order: 0 },
|
||||||
|
{ mediaType: "image", imageUrl: placeholderImage(260, "Slide two"), order: 1 },
|
||||||
|
{ mediaType: "video", thumbnailUrl: placeholderImage(20, "Demo recording"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 73, order: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "post",
|
type: "post",
|
||||||
publishedAt: "2026-10-18T19:05:00Z",
|
publishedAt: "2026-10-18T19:05:00Z",
|
||||||
|
|||||||
@@ -120,10 +120,14 @@
|
|||||||
.feed .composer { margin: 0 14px 4px; }
|
.feed .composer { margin: 0 14px 4px; }
|
||||||
.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
|
.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
|
||||||
|
|
||||||
/* Single-column fallback (760px and below, until the Mobile milestone): lists get a usable height. */
|
/* 760px and below: the full-screen feed (spec "Mobile layout"). The feed
|
||||||
|
column fills the app frame and the reel inside it is the only scroller.
|
||||||
|
The side panels are hidden; the phone dock and sheets reach them later. */
|
||||||
@container app (max-width: 760px) {
|
@container app (max-width: 760px) {
|
||||||
.bento-col .panel { flex: 0 0 auto; }
|
.bento { display: block; padding: 0; overflow: hidden; }
|
||||||
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
|
.bento-left,
|
||||||
|
.bento-right { display: none; }
|
||||||
|
.bento-center { height: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Account panel ---------------------------------------------------------- */
|
/* Account panel ---------------------------------------------------------- */
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
/*
|
||||||
|
* Phone feed: full-screen snapping slides (spec "Mobile layout" > "Full-screen
|
||||||
|
* feed", ADR 0001). Ported from the mockup's .reel / .slide / .sl-* rules with
|
||||||
|
* the `slide-` and `reel-` prefixes, tokens only. White, the dark fades and the
|
||||||
|
* dark glass over photos and video come from the --on-media / --media-* tokens.
|
||||||
|
* Only rendered at 760px and below (AppShell measures .app).
|
||||||
|
*/
|
||||||
|
@keyframes slide-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes slide-shimmer { from { background-position: 120% 0; } to { background-position: -100% 0; } }
|
||||||
|
|
||||||
|
.reel-feed {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reel {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.reel::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
--slide-text-bg: radial-gradient(120% 70% at 0% 0%, var(--accent-soft), transparent 62%), var(--bg);
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--slide-text-bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media: edge to edge. Scaled to 104% until the slide is on screen, then eases to 100%. */
|
||||||
|
.slide-media { position: absolute; inset: 0; background: var(--tile-3); }
|
||||||
|
.slide-art {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transform: scale(1.04);
|
||||||
|
transition: transform 6s ease-out;
|
||||||
|
}
|
||||||
|
.is-cur .slide-art { transform: scale(1); }
|
||||||
|
.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-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; }
|
||||||
|
|
||||||
|
/* Several media items: a sideways snap track; each pane is one full-slide item. */
|
||||||
|
.slide-track {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.slide-track::-webkit-scrollbar { display: none; }
|
||||||
|
.slide-pane { position: relative; flex: 0 0 100%; height: 100%; scroll-snap-align: start; scroll-snap-stop: always; }
|
||||||
|
.slide-dots {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 90px;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.slide-dot {
|
||||||
|
display: block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--on-media-line);
|
||||||
|
transition: width .5s var(--ease), background-color .5s;
|
||||||
|
}
|
||||||
|
.slide-dot-on { width: 18px; background: var(--accent); }
|
||||||
|
|
||||||
|
.slide-scrim,
|
||||||
|
.slide-scrim-top { position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; }
|
||||||
|
.slide-scrim { bottom: 0; height: 58%; background: linear-gradient(180deg, var(--media-fade-0), var(--media-fade)); }
|
||||||
|
.slide-scrim-top { top: 0; height: 150px; background: linear-gradient(180deg, var(--media-fade-top), var(--media-fade-0)); }
|
||||||
|
|
||||||
|
/* A playing video: the author, text and fades step aside so the native controls are clear. */
|
||||||
|
.slide-meta,
|
||||||
|
.slide-scrim,
|
||||||
|
.slide-scrim-top { transition: opacity .3s var(--ease); }
|
||||||
|
.slide-playing .slide-meta,
|
||||||
|
.slide-playing .slide-scrim,
|
||||||
|
.slide-playing .slide-scrim-top { opacity: 0; pointer-events: none; }
|
||||||
|
.slide-playing .slide-meta > * { pointer-events: none; }
|
||||||
|
|
||||||
|
/* Author and text over the bottom fade. */
|
||||||
|
.slide-meta {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 108px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.slide-meta > * { pointer-events: auto; }
|
||||||
|
.slide-on-media .slide-meta { color: var(--on-media); }
|
||||||
|
.slide-who { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.slide-who > div { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.slide-who strong { display: flex; align-items: center; font-size: 15px; font-weight: 700; }
|
||||||
|
.slide-who time { font-size: 12.5px; opacity: .72; }
|
||||||
|
.slide-on-media .slide-you { color: var(--on-media); box-shadow: inset 0 0 0 1px var(--on-media-badge); }
|
||||||
|
.slide-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-shadow: 0 1px 12px var(--media-shadow);
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
right: 28px;
|
||||||
|
top: 46%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-serif), Georgia, serif;
|
||||||
|
font-size: 31px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The slide on screen: its content rises in when it snaps into place. */
|
||||||
|
.is-cur .slide-meta,
|
||||||
|
.is-cur .slide-quote-w { animation: slide-rise .7s var(--ease) both; }
|
||||||
|
/* The rise animation's fill would keep the meta visible; a playing video takes it off. */
|
||||||
|
.is-cur.slide-playing .slide-meta { animation: none; }
|
||||||
|
|
||||||
|
/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */
|
||||||
|
.slide-menu { position: absolute; top: calc(70px + env(safe-area-inset-top, 0px)); right: 12px; z-index: 6; }
|
||||||
|
.slide-menu-btn {
|
||||||
|
appearance: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
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;
|
||||||
|
transition: transform .15s, background-color .2s;
|
||||||
|
}
|
||||||
|
.slide-menu-btn svg { width: 20px; height: 20px; }
|
||||||
|
.slide-menu-btn:active { transform: scale(.92); }
|
||||||
|
.slide-menu-btn:disabled { cursor: default; }
|
||||||
|
.slide-on-media .slide-menu-btn {
|
||||||
|
color: var(--on-media);
|
||||||
|
background: var(--media-glass);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||||
|
}
|
||||||
|
.slide-menu .post-pop { min-width: 230px; }
|
||||||
|
|
||||||
|
/* Double-tap heart burst over a post slide (mockup .burst), crimson accent. */
|
||||||
|
@keyframes slide-burst {
|
||||||
|
0% { opacity: 0; transform: scale(.2); }
|
||||||
|
25% { opacity: 1; transform: scale(1.15); }
|
||||||
|
60% { opacity: 1; transform: scale(1); }
|
||||||
|
100% { opacity: 0; transform: scale(1.25) translateY(-20px); }
|
||||||
|
}
|
||||||
|
.slide-burst {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 44%;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: -60px 0 0 -60px;
|
||||||
|
color: var(--accent);
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: slide-burst .95s cubic-bezier(.2, .9, .3, 1.3) both;
|
||||||
|
filter: drop-shadow(0 12px 30px color-mix(in oklab, var(--accent), transparent 45%));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Featured experts slide: not a post. Eyebrow, title, carousel at 156px, hint. */
|
||||||
|
@keyframes slide-nudge { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(4px); } }
|
||||||
|
.slide-featured {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 70px 0 110px;
|
||||||
|
background: radial-gradient(90% 55% at 50% 46%, color-mix(in oklab, var(--accent), transparent 84%), transparent 70%), var(--bg);
|
||||||
|
}
|
||||||
|
.slide-feat-title {
|
||||||
|
margin: 10px 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.slide-feat-cf { width: 100%; margin-top: 26px; }
|
||||||
|
.featured-bare { background: none; }
|
||||||
|
.featured-bare .featured-cap { padding-top: 16px; }
|
||||||
|
.slide-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.slide-hint svg { width: 16px; height: 16px; animation: slide-nudge 1.6s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* Loading slide: a skeleton card in the middle while the next page loads. */
|
||||||
|
.slide-load { background: var(--bg); }
|
||||||
|
.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; }
|
||||||
|
.slide-load-body {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 22px 110px;
|
||||||
|
}
|
||||||
|
.slide-skel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
}
|
||||||
|
.slide-skel-head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.slide-skel-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.slide-skel i {
|
||||||
|
display: block;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(90deg, var(--tile-2) 0%, var(--tile-3) 40%, var(--tile-2) 80%);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: slide-shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
.slide-skel .slide-skel-hex {
|
||||||
|
flex: none;
|
||||||
|
width: 46px;
|
||||||
|
height: 53px;
|
||||||
|
border-radius: 0;
|
||||||
|
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty feed or a failed page, as a slide. */
|
||||||
|
.slide-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 24px 110px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New-posts pill floats over the top of the reel. */
|
||||||
|
.reel-pill {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(76px + env(safe-area-inset-top, 0px));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 8;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: feed-pill-in .45s var(--ease) backwards;
|
||||||
|
}
|
||||||
|
.reel-pill > * { pointer-events: auto; }
|
||||||
|
|
||||||
|
/* Bottom dock (spec "Bottom dock"): a frosted bar 16px above the bottom. Like and
|
||||||
|
comments act on the post on screen. Disabled buttons stay fully opaque and use
|
||||||
|
a solid muted ink instead of fading. */
|
||||||
|
.dock {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--glass);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.8);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.8);
|
||||||
|
box-shadow: var(--shadow-pop), inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
.dk {
|
||||||
|
appearance: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .25s, color .25s;
|
||||||
|
}
|
||||||
|
.dk svg { width: 22px; height: 22px; flex: none; }
|
||||||
|
@media (hover: hover) { .dk:hover:not(:disabled) { background: var(--tile-3); } }
|
||||||
|
.dk:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
.dk.dk-like-on { color: var(--accent); }
|
||||||
|
.dk-beat svg { animation: post-beat .5s cubic-bezier(.2,.9,.3,1.4); }
|
||||||
|
.dk:disabled { opacity: 1; color: var(--ink-3); cursor: default; }
|
||||||
|
.btn-spark.btn-spark-fab.dk-plus { flex: 0 0 64px; width: 64px; height: 52px; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.is-cur .slide-meta,
|
||||||
|
.is-cur .slide-quote-w,
|
||||||
|
.slide-skel i,
|
||||||
|
.slide-burst,
|
||||||
|
.slide-hint svg,
|
||||||
|
.reel-pill,
|
||||||
|
.dk-beat svg { animation: none !important; }
|
||||||
|
.slide-art { transition: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top bar (spec "Top bar"): frosted pills over the feed. Over a media slide they use a
|
||||||
|
dark translucent glass with white text; over a text slide the theme glass. The bar
|
||||||
|
ends 56px (plus the top inset) down, above the own-post menu at 70px. */
|
||||||
|
.ph-top {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: calc(14px + env(safe-area-inset-top, 0px)) 14px 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ph-top > * { pointer-events: auto; }
|
||||||
|
.ph-glass {
|
||||||
|
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);
|
||||||
|
color: var(--ink);
|
||||||
|
transition: background .3s var(--ease), color .3s var(--ease);
|
||||||
|
}
|
||||||
|
.reel-feed[data-on-media="true"] .ph-glass {
|
||||||
|
background: var(--media-glass);
|
||||||
|
color: var(--on-media);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||||
|
}
|
||||||
|
.ph-brand { display: flex; align-items: center; gap: 8px; height: 42px; padding: 0 14px 0 10px; border-radius: 14px; }
|
||||||
|
.ph-brand .type-wordmark { font-size: 13px; }
|
||||||
|
.ph-theme { appearance: none; border: 0; margin: 0; display: grid; place-items: center; width: 42px; height: 42px; padding: 0; border-radius: 14px; cursor: pointer; }
|
||||||
|
.ph-theme svg { width: 20px; height: 20px; }
|
||||||
|
.ph-theme:active { transform: scale(.94); }
|
||||||
|
|
||||||
|
/* Progress rail: position in the loaded feed, on the right edge. */
|
||||||
|
.rail-v {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
height: 110px;
|
||||||
|
margin-top: -55px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--line-2);
|
||||||
|
z-index: 15;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.rail-v i { display: block; width: 100%; background: var(--accent); border-radius: 2px; transition: height .5s var(--ease); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .rail-v i { transition: none; } }
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* Bottom sheets (spec "Sheets"). Ported from the mockup's .psheet, .grab,
|
||||||
|
* .psheet-h and .ph-bg rules, plus the `.mx.sheet .cmp` composer rules. Tokens
|
||||||
|
* only. The sheet sits inside the phone feed (.reel-feed), over the dock.
|
||||||
|
*/
|
||||||
|
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }
|
||||||
|
@keyframes sheet-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.sheet-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 55;
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: color-mix(in srgb, var(--bg), transparent 50%);
|
||||||
|
-webkit-backdrop-filter: blur(3px);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
animation: sheet-fade .3s both;
|
||||||
|
}
|
||||||
|
.sheet {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 78%;
|
||||||
|
padding: 10px 14px max(20px, calc(env(safe-area-inset-bottom, 0px) + 12px));
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 26px 26px 0 0;
|
||||||
|
box-shadow: 0 -20px 50px -10px color-mix(in srgb, var(--neu-d), transparent 45%), inset 0 1px 0 var(--line);
|
||||||
|
animation: sheet-up .45s cubic-bezier(.2,.9,.25,1) both;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.sheet-grab {
|
||||||
|
flex: none;
|
||||||
|
width: 40px;
|
||||||
|
height: 5px;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--line-2);
|
||||||
|
}
|
||||||
|
.sheet-h { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.sheet-h h2 { margin: 0; font-size: 25px; line-height: 1.2; }
|
||||||
|
.sheet-x {
|
||||||
|
appearance: none;
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--r-ctl);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .2s, color .2s;
|
||||||
|
}
|
||||||
|
.sheet-x svg { width: 20px; height: 20px; }
|
||||||
|
@media (hover: hover) { .sheet-x:hover { background: var(--tile-2); color: var(--ink); } }
|
||||||
|
.sheet-x:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
.sheet-body { min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* Comments sheet: the shared comments panel without its inline-card chrome. */
|
||||||
|
.sheet-comments .post-cm { margin: 0; padding: 0; border-top: 0; animation: none; flex: 1; min-height: 0; }
|
||||||
|
.sheet-comments .post-cm-list { overflow-y: auto; min-height: 60px; }
|
||||||
|
.sheet-comments .post-cm-form { flex: none; }
|
||||||
|
.sheet-comments .post-cm-form input { font-size: 16px; }
|
||||||
|
|
||||||
|
/* Composer sheet: the desktop composer, borderless, with a 150px text area. */
|
||||||
|
.sheet-composer { max-height: 92%; }
|
||||||
|
.sheet-composer .sheet-body { overflow: visible; }
|
||||||
|
.sheet-composer .composer,
|
||||||
|
.sheet-composer .composer.composer-open { margin: 0; padding: 0; background: transparent; box-shadow: none; }
|
||||||
|
.sheet-composer .composer textarea,
|
||||||
|
.sheet-composer .composer.composer-open textarea { height: 150px; font-size: 16px; }
|
||||||
|
.sheet-composer .composer-bar { flex-wrap: wrap; }
|
||||||
|
.sheet-composer .composer-kbd { display: none; }
|
||||||
|
/* The popover anchors to the whole composer (not the emoji button) so it fits a phone: full width, just above the toolbar. */
|
||||||
|
.sheet-composer .composer-emoji-wrap { position: static; }
|
||||||
|
.sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; }
|
||||||
|
.sheet-composer .composer-att.composer-att-indent { margin-left: 0; }
|
||||||
|
|
||||||
|
/* Discover sheet (More): soft depth rows, then the Appearance switch. */
|
||||||
|
.sheet-discover { gap: 14px; }
|
||||||
|
.sheet-discover .sheet-body { gap: 16px; padding: 4px 4px 4px; overflow-y: auto; }
|
||||||
|
.sheet-discover .btn-depth { position: relative; overflow: hidden; isolation: isolate; min-height: 72px; }
|
||||||
|
.sheet-discover .btn-depth-chev { width: 18px; height: 18px; flex: none; color: var(--ink-3); transition: color .3s; }
|
||||||
|
.sheet-discover .btn-depth:hover .btn-depth-chev,
|
||||||
|
.sheet-discover .btn-depth:focus-visible .btn-depth-chev { color: var(--on-accent); }
|
||||||
|
.depth-stack { display: flex; flex: none; }
|
||||||
|
.depth-stack .hex-avatar { margin-left: -10px; }
|
||||||
|
.depth-stack .hex-avatar:first-child { margin-left: 0; }
|
||||||
|
.sheet-sub { margin: 4px 0 -6px 4px; color: var(--ink-3); }
|
||||||
|
.seg-depth {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: inset 4px 4px 10px var(--neu-d), inset -4px -4px 10px var(--neu-l);
|
||||||
|
}
|
||||||
|
.seg-depth button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--r-ctl);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background-color .25s, color .25s, box-shadow .25s;
|
||||||
|
}
|
||||||
|
.seg-depth button svg { width: 18px; height: 18px; }
|
||||||
|
.seg-depth button.on {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: 4px 4px 10px var(--neu-d), -4px -4px 10px var(--neu-l);
|
||||||
|
}
|
||||||
|
.seg-depth button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Panel sheets: 80% height, the desktop panels without their tile chrome or repeated heading. */
|
||||||
|
.sheet-panel { height: 80%; max-height: 80%; }
|
||||||
|
.sheet-panel .sheet-body { flex: 1; overflow: hidden; }
|
||||||
|
.sheet-panel .sheet-body > .tile {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.sheet-panel .sheet-body > .tile > .panel-head { display: none; }
|
||||||
|
.sheet-panel .search-results,
|
||||||
|
.sheet-panel .trend-list { flex: 1 1 0; min-height: 0; overflow-y: auto; }
|
||||||
|
.sheet-panel .search-field input { font-size: 16px; }
|
||||||
|
.sheet-panel-featured .sheet-body { overflow-y: auto; align-items: center; justify-content: center; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sheet, .sheet-bg { animation: none !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElement, type FC } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { BottomSheet } from "@/components/BottomSheet";
|
||||||
|
|
||||||
|
const css = readFileSync("src/styles/sheet.css", "utf8");
|
||||||
|
const feed = readFileSync("src/components/MobileFeed.tsx", "utf8");
|
||||||
|
|
||||||
|
describe("BottomSheet", () => {
|
||||||
|
const html = renderToStaticMarkup(createElement(BottomSheet as FC<{ title: string; onClose: () => void }>, { title: "New post", onClose: () => {} }, createElement("p", null, "body")));
|
||||||
|
it("is a modal dialog named by its serif title, with a grab handle, close buttons and a backdrop", () => {
|
||||||
|
expect(html).toContain('role="dialog"');
|
||||||
|
expect(html).toContain('aria-modal="true"');
|
||||||
|
expect(html).toMatch(/aria-labelledby="([^"]+)"[\s\S]*<h2 id="\1"[^>]*type-title[^>]*>New post<\/h2>/);
|
||||||
|
expect(html).toContain("sheet-grab");
|
||||||
|
expect(html).toContain('class="sheet-bg"');
|
||||||
|
expect(html.match(/aria-label="Close"/g)?.length).toBe(2);
|
||||||
|
expect(html).toContain("<p>body</p>");
|
||||||
|
});
|
||||||
|
it("slides up over 450ms and turns the motion off under reduced motion", () => {
|
||||||
|
expect(css).toMatch(/animation: sheet-up \.45s/);
|
||||||
|
expect(css).toMatch(/prefers-reduced-motion: reduce[\s\S]*animation: none/);
|
||||||
|
expect(css).toMatch(/backdrop-filter: blur\(3px\)/);
|
||||||
|
});
|
||||||
|
it("gives the composer sheet a 150px text area", () => {
|
||||||
|
expect(css).toMatch(/\.sheet-composer \.composer textarea[\s\S]*height: 150px/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phone feed sheets", () => {
|
||||||
|
it("opens the shared comments section and composer from the dock", () => {
|
||||||
|
expect(feed).toContain('<BottomSheet title="Comments"');
|
||||||
|
expect(feed).toContain("<CommentsSection postId={sheet.postId}");
|
||||||
|
expect(feed).toContain('<BottomSheet title="New post"');
|
||||||
|
expect(feed).toContain("<Composer {...p.composer} />");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { DiscoverPanelSheet, DiscoverSheet, featuredSubtitle, PANEL_CAROUSEL_SIZE } from "@/components/DiscoverSheet";
|
||||||
|
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
|
||||||
|
|
||||||
|
const experts = FIXTURE_DISCOVER.featuredExperts;
|
||||||
|
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
||||||
|
|
||||||
|
describe("featuredSubtitle", () => {
|
||||||
|
it("names two experts and counts the rest", () => {
|
||||||
|
const list = [{ displayName: "A" }, { displayName: "B" }, { displayName: "C" }, { displayName: "D" }];
|
||||||
|
expect(featuredSubtitle(list)).toBe("A, B and 2 more");
|
||||||
|
expect(featuredSubtitle(list.slice(0, 3))).toBe("A, B and 1 more");
|
||||||
|
});
|
||||||
|
it("reads plainly for short lists", () => {
|
||||||
|
expect(featuredSubtitle([])).toBe("");
|
||||||
|
expect(featuredSubtitle([{ displayName: "A" }])).toBe("A");
|
||||||
|
expect(featuredSubtitle([{ displayName: "A" }, { displayName: "B" }])).toBe("A and B");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Discover sheet", () => {
|
||||||
|
const out = html(createElement(DiscoverSheet, { experts, onOpen: () => {}, onClose: () => {} }));
|
||||||
|
it("has three depth rows and the appearance switch", () => {
|
||||||
|
expect(out).toContain("Discover");
|
||||||
|
expect((out.match(/btn-depth"/g) ?? []).length).toBe(3);
|
||||||
|
expect(out).toContain("Featured experts");
|
||||||
|
expect(out).toContain("Trending minnects");
|
||||||
|
expect(out).toContain("Find experts");
|
||||||
|
expect(out).toContain('aria-label="Appearance"');
|
||||||
|
expect(out).toContain("Dark");
|
||||||
|
expect(out).toContain("Light");
|
||||||
|
expect(out).toContain(featuredSubtitle(experts));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Discover panel sheets", () => {
|
||||||
|
it("shows the featured carousel at 150px", () => {
|
||||||
|
expect(PANEL_CAROUSEL_SIZE).toBe(150);
|
||||||
|
const out = html(createElement(DiscoverPanelSheet, { panel: "featured", experts, trending: [], onClose: () => {} }));
|
||||||
|
expect(out).toContain("--cfw:150px");
|
||||||
|
expect(out).toContain('aria-label="Close"');
|
||||||
|
});
|
||||||
|
it("is 80% high", () => {
|
||||||
|
const css = readFileSync("src/styles/sheet.css", "utf8");
|
||||||
|
expect(css).toMatch(/\.sheet-panel\s*\{[^}]*height:\s*80%/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||||
|
import { MobileDock, dockState } from "@/components/MobileDock";
|
||||||
|
import { CompactLayoutContext } from "@/components/layout-context";
|
||||||
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
const item = (id: string, likes: number, comments: number, liked = false) => ({
|
||||||
|
type: "post",
|
||||||
|
publishedAt: "2026-10-19T11:00:00Z",
|
||||||
|
likesCount: likes,
|
||||||
|
commentsCount: comments,
|
||||||
|
isLiked: liked,
|
||||||
|
post: { id, content: `text ${id}`, author: { id: "a", displayName: `Author ${id}` }, mediaItems: [] },
|
||||||
|
});
|
||||||
|
const POSTS = toCardPosts([item("p1", 12, 3), item("p2", 40, 1, true)]);
|
||||||
|
|
||||||
|
function feed(over: Partial<FeedViewProps>, compact = true) {
|
||||||
|
const props: FeedViewProps = {
|
||||||
|
posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false,
|
||||||
|
newCount: 0, onRetry: () => {}, onShowNew: () => {}, onToggleLike: () => {}, ...over,
|
||||||
|
};
|
||||||
|
return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dock state", () => {
|
||||||
|
it("shows the counts and liked state of the post on screen", () => {
|
||||||
|
const s = dockState({ index: 1, kind: "post", post: POSTS[1] }, { canLike: true });
|
||||||
|
expect(s.likes).toBe(POSTS[1].likesCount);
|
||||||
|
expect(s.comments).toBe(POSTS[1].commentsCount);
|
||||||
|
expect(s.liked).toBe(POSTS[1].isLiked);
|
||||||
|
expect(s.likeDisabled).toBe(false);
|
||||||
|
expect(s.commentsDisabled).toBe(false);
|
||||||
|
});
|
||||||
|
it("disables like and comments on the featured and loading slides", () => {
|
||||||
|
for (const kind of ["featured", "load"] as const) {
|
||||||
|
const s = dockState({ index: 2, kind, post: null });
|
||||||
|
expect(s.likeDisabled).toBe(true);
|
||||||
|
expect(s.commentsDisabled).toBe(true);
|
||||||
|
expect(s.likes).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("disables like while the like call is in flight", () => {
|
||||||
|
expect(dockState({ index: 0, kind: "post", post: POSTS[0] }, { canLike: true, likeBusy: true }).likeDisabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dock markup", () => {
|
||||||
|
it("renders like with aria-pressed, comments, the sparkle new-post button and More", () => {
|
||||||
|
const out = renderToStaticMarkup(createElement(MobileDock, { slide: { index: 1, kind: "post", post: POSTS[1] }, onToggleLike: () => {} }));
|
||||||
|
expect(out).toContain('aria-label="Post actions"');
|
||||||
|
expect(out).toMatch(/class="dk dk-like dk-like-on"[^>]*aria-pressed="true"/);
|
||||||
|
expect(out).toContain(`>${POSTS[1].likesCount}</span>`);
|
||||||
|
expect(out).toContain(`Open comments, ${POSTS[1].commentsCount} comment`);
|
||||||
|
expect(out).toContain("btn-spark btn-spark-fab dk-plus");
|
||||||
|
expect(out).not.toContain("btn-spark-fold");
|
||||||
|
expect(out).toContain('aria-label="View more"');
|
||||||
|
});
|
||||||
|
it("is part of the phone feed and follows the first slide", () => {
|
||||||
|
const out = feed({});
|
||||||
|
expect(out).toContain('data-testid="feed-dock"');
|
||||||
|
expect(out).toContain(`data-testid="dock-likes">${POSTS[0].likesCount}<`);
|
||||||
|
});
|
||||||
|
it("is absent on desktop", () => {
|
||||||
|
expect(feed({}, false)).not.toContain("feed-dock");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dock stylesheet", () => {
|
||||||
|
const css = readFileSync("src/styles/mobile.css", "utf8");
|
||||||
|
it("floats 16px above the bottom on frosted glass", () => {
|
||||||
|
expect(css).toMatch(/\.dock \{[^}]*bottom: calc\(16px[^}]*backdrop-filter: blur/);
|
||||||
|
});
|
||||||
|
it("keeps touch targets at least 44px and disabled buttons fully opaque", () => {
|
||||||
|
expect(css).toMatch(/\.dk \{[^}]*min-width: 44px;[^}]*height: 52px/);
|
||||||
|
expect(css).toMatch(/\.dk:disabled \{ opacity: 1;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||||
|
import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex, railPercent } from "@/components/MobileFeed";
|
||||||
|
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
|
||||||
|
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
|
||||||
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2026-10-19T12:00:00Z");
|
||||||
|
const item = (id: string, authorId: string, mediaItems: unknown[] = []) => ({
|
||||||
|
type: "post",
|
||||||
|
publishedAt: "2026-10-19T11:00:00Z",
|
||||||
|
likesCount: 1,
|
||||||
|
commentsCount: 0,
|
||||||
|
post: { id, content: `text ${id}`, author: { id: authorId, displayName: `Author ${id}` }, mediaItems },
|
||||||
|
});
|
||||||
|
const POSTS = toCardPosts([
|
||||||
|
item("t1", "me"),
|
||||||
|
item("i1", "a2", [{ mediaType: "image", imageUrl: "https://img/x.jpg", order: 0 }]),
|
||||||
|
item("v1", "a3", [{ mediaType: "video", thumbnailUrl: "https://img/v.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4", videoDuration: 61, order: 0 }]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function render(over: Partial<FeedViewProps>, compact = true) {
|
||||||
|
const props: FeedViewProps = {
|
||||||
|
posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false,
|
||||||
|
newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, feedUserId: "me", onDelete: () => {}, ...over,
|
||||||
|
};
|
||||||
|
return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("compact layout", () => {
|
||||||
|
it("switches at 760px and below", () => {
|
||||||
|
expect(isCompactWidth(390)).toBe(true);
|
||||||
|
expect(isCompactWidth(760)).toBe(true);
|
||||||
|
expect(isCompactWidth(761)).toBe(false);
|
||||||
|
expect(isCompactWidth(1440)).toBe(false);
|
||||||
|
expect(isCompactWidth(0)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the desktop panel outside the phone layout", () => {
|
||||||
|
const html = render({}, false);
|
||||||
|
expect(html).toContain('data-testid="post-card"');
|
||||||
|
expect(html).not.toContain("feed-reel");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(/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).toContain('aria-label="Play video"');
|
||||||
|
// The quote slide shows its text once, as the quote.
|
||||||
|
expect(html.match(/text t1/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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).toContain('data-current="0"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the frosted menu only on the person's own posts", () => {
|
||||||
|
const html = render({});
|
||||||
|
expect(html.match(/slide-menu-btn/g)).toHaveLength(1);
|
||||||
|
expect(html).toContain('aria-label="More actions"');
|
||||||
|
expect(render({ feedUserId: "someone-else" })).not.toContain("slide-menu-btn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with a loading slide while another page can load", () => {
|
||||||
|
expect(render({ hasMore: true })).toContain('data-testid="feed-slide-load"');
|
||||||
|
expect(render({ loadingMore: true })).toContain('data-testid="feed-slide-load"');
|
||||||
|
expect(render({})).not.toContain("feed-slide-load");
|
||||||
|
const failed = render({ hasMore: true, loadMoreFailed: true });
|
||||||
|
expect(failed).not.toContain("feed-slide-load");
|
||||||
|
expect(failed).toContain("Try again");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the loading slide on first load", () => {
|
||||||
|
expect(render({ status: "loading", posts: [] })).toContain('data-testid="feed-slide-load"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks the slide that is mostly on screen", () => {
|
||||||
|
expect(pickCurrent(0, [])).toBe(0);
|
||||||
|
expect(pickCurrent(0, [1])).toBe(1);
|
||||||
|
expect(pickCurrent(2, [1, 2])).toBe(2);
|
||||||
|
expect(pickCurrent(4, [1, 2])).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mobile stylesheet", () => {
|
||||||
|
const css = readFileSync("src/styles/mobile.css", "utf8");
|
||||||
|
it("snaps one slide per screen", () => {
|
||||||
|
expect(css).toMatch(/\.reel \{[^}]*scroll-snap-type: y mandatory/);
|
||||||
|
expect(css).toMatch(/\.slide \{[^}]*height: 100%;[^}]*scroll-snap-stop: always/);
|
||||||
|
});
|
||||||
|
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(/transform: scale\(1\.04\)/);
|
||||||
|
expect(css).toMatch(/\.is-cur \.slide-art \{ transform: scale\(1\); \}/);
|
||||||
|
});
|
||||||
|
it("uses tokens only", () => {
|
||||||
|
expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba?\(/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("one step per gesture", () => {
|
||||||
|
it("moves one slide in the gesture's direction and stays inside the reel", async () => {
|
||||||
|
const { stepTarget } = await import("@/components/MobileFeed");
|
||||||
|
expect(stepTarget(0, 120, 5)).toBe(1);
|
||||||
|
expect(stepTarget(3, -4, 5)).toBe(2);
|
||||||
|
expect(stepTarget(4, 300, 5)).toBe(4);
|
||||||
|
expect(stepTarget(0, -50, 5)).toBe(0);
|
||||||
|
expect(stepTarget(2, 0, 5)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("video slide before play", () => {
|
||||||
|
it("shows only the frosted play button, no native controls", () => {
|
||||||
|
const html = render({});
|
||||||
|
const video = /<video[^>]*>/.exec(html)?.[0] ?? "";
|
||||||
|
expect(video).not.toBe("");
|
||||||
|
expect(video).not.toContain("controls");
|
||||||
|
expect(html).toContain('class="post-play slide-play"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("double-tap detection", () => {
|
||||||
|
it("needs two taps within 300ms", () => {
|
||||||
|
expect(isDoubleTap(null, 100)).toBe(false);
|
||||||
|
expect(isDoubleTap(100, 250)).toBe(true);
|
||||||
|
expect(isDoubleTap(100, 400)).toBe(true);
|
||||||
|
expect(isDoubleTap(100, 401)).toBe(false);
|
||||||
|
});
|
||||||
|
it("ignores taps on controls", () => {
|
||||||
|
const el = (html: string) => {
|
||||||
|
const fake = (match: boolean) => ({ closest: () => (match ? {} : null) });
|
||||||
|
return html === "slide" ? fake(false) : fake(true);
|
||||||
|
};
|
||||||
|
expect(isTapTarget(el("slide") as unknown as EventTarget)).toBe(true);
|
||||||
|
expect(isTapTarget(el("button") as unknown as EventTarget)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("featured experts slide", () => {
|
||||||
|
it("sits after the second post and only with experts", () => {
|
||||||
|
expect(featuredSlideIndex(3, 5)).toBe(2);
|
||||||
|
expect(featuredSlideIndex(2, 5)).toBe(2);
|
||||||
|
expect(featuredSlideIndex(1, 5)).toBe(-1);
|
||||||
|
expect(featuredSlideIndex(3, 0)).toBe(-1);
|
||||||
|
});
|
||||||
|
it("renders the title, eyebrow, hint and a 156px carousel as a non-post slide", () => {
|
||||||
|
const out = render({ featuredExperts: FIXTURE_DISCOVER.featuredExperts });
|
||||||
|
expect(out).toContain('data-slide-kind="featured"');
|
||||||
|
expect(out).toContain("Featured experts");
|
||||||
|
expect(out).toContain("Ask the people everyone is talking about");
|
||||||
|
expect(out).toContain("--cfw:156px");
|
||||||
|
expect(out).toContain("Keep scrolling");
|
||||||
|
expect(out.match(/data-testid="feed-slide"/g)).toHaveLength(3);
|
||||||
|
// After the second post, before the third.
|
||||||
|
expect(out.indexOf("Author i1")).toBeLessThan(out.indexOf("feed-slide-featured"));
|
||||||
|
expect(out.indexOf("feed-slide-featured")).toBeLessThan(out.indexOf("Author v1"));
|
||||||
|
});
|
||||||
|
it("is absent without experts", () => {
|
||||||
|
expect(render({})).not.toContain("feed-slide-featured");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("top bar and progress rail", () => {
|
||||||
|
it("renders the brand pill, the theme toggle and the rail", () => {
|
||||||
|
const html = render({});
|
||||||
|
expect(html).toContain('data-testid="top-bar"');
|
||||||
|
expect(html).toContain("MINNECT");
|
||||||
|
expect(html).toMatch(/data-testid="top-theme"[^>]*aria-label="Switch to (light|dark) theme"/);
|
||||||
|
expect(html).toContain('data-testid="feed-rail"');
|
||||||
|
expect(html).toContain('data-on-media="false"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sizes the rail by the slide on screen out of the loaded slides", () => {
|
||||||
|
expect(railPercent(0, 4)).toBe(25);
|
||||||
|
expect(railPercent(2, 4)).toBe(75);
|
||||||
|
expect(railPercent(9, 4)).toBe(100);
|
||||||
|
expect(railPercent(0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the own-post menu below the bar and the safe area", () => {
|
||||||
|
const css = readFileSync("src/styles/mobile.css", "utf8");
|
||||||
|
expect(css).toMatch(/\.slide-menu \{[^}]*top: calc\(70px \+ env\(safe-area-inset-top/);
|
||||||
|
expect(css).toMatch(/\.ph-top \{[^}]*safe-area-inset-top/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user