- {preview && isVideo ? (
-
- ) : preview ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : null}
-
{file.name}
-
- Remove
-
+
);
}
diff --git a/src/components/DiscoverRails.tsx b/src/components/DiscoverRails.tsx
index b33989b..c45b8e3 100644
--- a/src/components/DiscoverRails.tsx
+++ b/src/components/DiscoverRails.tsx
@@ -3,26 +3,30 @@
import { useEffect, useState } from "react";
import type { DiscoverData } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
-import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
+import { FeaturedCarousel } from "./FeaturedCarousel";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
if (!data) return null;
return (
<>
-
+
>
);
}
-/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */
-export function DiscoverRails() {
+/** Loads /api/discover once. Failure leaves it null; the feed is unaffected. */
+export function useDiscover(): DiscoverData | null {
const [data, setData] = useState
(null);
useEffect(() => {
let live = true;
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
return () => { live = false; };
}, []);
- return ;
+ return data;
+}
+
+export function DiscoverRails() {
+ return ;
}
diff --git a/src/components/DiscoverSheet.tsx b/src/components/DiscoverSheet.tsx
new file mode 100644
index 0000000..e128e89
--- /dev/null
+++ b/src/components/DiscoverSheet.tsx
@@ -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[]): 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 (
+
+
+
+ );
+}
+
+const icon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" } as const;
+
+function FlameIcon() {
+ return (
+
+
+
+ );
+}
+
+function SearchIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function MoonIcon() {
+ return (
+
+
+
+ );
+}
+
+function SunIcon() {
+ return (
+
+
+
+
+ );
+}
+
+/** 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: },
+ { value: "light", label: "Light", icon: },
+ ];
+ return (
+
+ {options.map((o) => (
+ setTheme(o.value)}>
+ {o.icon}
+ {o.label}
+
+ ))}
+
+ );
+}
+
+export interface DiscoverSheetProps {
+ experts: DiscoverExpert[];
+ onOpen: (panel: DiscoverPanel) => void;
+ onClose: () => void;
+}
+
+export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) {
+ return (
+
+ {experts.length > 0 ? (
+ onOpen("featured")}
+ leading={
+
+ {experts.slice(0, 3).map((e) => (
+
+ ))}
+
+ }
+ trailing={ }
+ />
+ ) : null}
+ } trailing={ } onClick={() => onOpen("trending")} />
+ } trailing={ } onClick={() => onOpen("search")} />
+ Appearance
+
+
+ );
+}
+
+export interface DiscoverPanelSheetProps {
+ panel: DiscoverPanel;
+ experts: DiscoverExpert[];
+ trending: DiscoverMinnect[];
+ search?: ReactNode;
+ onClose: () => void;
+}
+
+const TITLES: Record = {
+ 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 (
+
+ {panel === "featured" ? : null}
+ {panel === "trending" ? : null}
+ {panel === "search" ? search : null}
+
+ );
+}
diff --git a/src/components/ExpertSearch.tsx b/src/components/ExpertSearch.tsx
index eb7e30d..cce7eb7 100644
--- a/src/components/ExpertSearch.tsx
+++ b/src/components/ExpertSearch.tsx
@@ -1,11 +1,12 @@
"use client";
+import { SweepButton } from "@/components/Buttons";
+import { HexAvatar } from "@/components/HexAvatar";
import { useEffect, useRef, useState } from "react";
import type { DiscoverCategory } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import type { ExpertResult } from "@/lib/experts-search";
import { fetchExperts } from "@/lib/experts-search-client";
-import { initials } from "@/lib/feed-model";
export type SearchStatus = "idle" | "loading" | "ready" | "error";
@@ -17,67 +18,70 @@ export interface ExpertSearchViewProps {
onCategory: (name: string | null) => void;
status: SearchStatus;
results: ExpertResult[];
- defaultOpen?: boolean;
}
export function ExpertSearchView(p: ExpertSearchViewProps) {
return (
-
- Find experts
- e.preventDefault()}>
+
+
+
Find experts
+
+ e.preventDefault()}>
Search experts
- p.onQuery(e.target.value)}
- />
+
+
+
+
+
p.onQuery(e.target.value)}
+ />
+
{p.categories.length > 0 ? (
{p.categories.map((c) => (
- p.onCategory(p.activeCategory === c.name ? null : c.name)}
- >
- {c.name}
-
+ />
))}
) : null}
-
+
{p.status === "loading" ?
Searching...
: null}
{p.status === "error" ?
Search is unavailable. Try again.
: null}
{p.status === "ready" && p.results.length === 0 ? (
No experts found.
) : null}
{p.status === "ready" && p.results.length > 0 ? (
-
+
{p.results.map((e) => (
-
- {e.pictureUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
- {initials(e.displayName)}
- )}
-
-
{e.displayName || "Expert"}
- {e.byline ?
{e.byline} : null}
- {e.rating !== null ?
★ {e.rating.toFixed(1)} : null}
+
+
+
+ {e.displayName || "Expert"}
+ {e.byline ? {e.byline} : null}
+ {e.rating !== null ? (
+
+
+ {e.rating.toFixed(1)}
+
+ ) : null}
))}
) : null}
-
+
);
}
@@ -89,11 +93,9 @@ export function ExpertSearch() {
const [categories, setCategories] = useState([]);
const [status, setStatus] = useState("idle");
const [results, setResults] = useState([]);
- const [open, setOpen] = useState(true);
const seq = useRef(0);
useEffect(() => {
- setOpen(window.matchMedia("(min-width: 960px)").matches);
let live = true;
fetchDiscover().then(
(d) => { if (live) setCategories(d.categories); },
@@ -122,8 +124,6 @@ export function ExpertSearch() {
return (
{
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const mq = window.matchMedia(REDUCED_MOTION);
+ setReduced(mq.matches);
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+ }, []);
+ return reduced;
+}
+
+function Star() {
+ return (
+
+
+
+ );
+}
+
+function Photo({ expert, center }: { expert: DiscoverExpert; center: boolean }) {
+ const name = expert.displayName || "Expert";
+ if (expert.pictureUrl) {
+ // eslint-disable-next-line @next/next/no-img-element
+ return ;
+ }
+ return (
+
+ {initials(name)}
+
+ );
+}
+
+export interface FeaturedCarouselProps {
+ experts: DiscoverExpert[];
+ /** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
+ 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, variant = "panel" }: FeaturedCarouselProps) {
+ const count = experts.length;
+ const [index, setIndex] = useState(0);
+ const reduced = useReducedMotion();
+ const autoplay = useRef(null);
+ const [hovered, setHovered] = useState(false);
+ const [focused, setFocused] = useState(false);
+ const paused = hovered || focused;
+ const pausedRef = useRef(false);
+ pausedRef.current = paused;
+ const rotating = count > 1 && !reduced;
+
+ useEffect(() => {
+ if (paused) autoplay.current?.pause();
+ else autoplay.current?.resume();
+ }, [paused]);
+
+ useEffect(() => {
+ if (count < 2 || reduced) return;
+ const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
+ if (pausedRef.current) ctl.pause();
+ autoplay.current = ctl;
+ return () => { ctl.stop(); autoplay.current = null; };
+ // The index only seeds a restart; ticking must not recreate the timer.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [count, reduced]);
+
+ if (count === 0) return null;
+ const current = index % count;
+ const cap = experts[current];
+ const style = { "--cfw": `${size}px` } as CSSProperties;
+ const bare = variant === "slide";
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onFocus={() => setFocused(true)}
+ onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
+ data-paused={paused ? "true" : undefined}
+ >
+ {bare ? null : (
+
+
+ Featured experts
+
+ {`${current + 1} / ${count}`}
+
+ )}
+
+ {experts.map((e, i) => {
+ const slot = featuredSlot(i, current, count);
+ return (
+
+ );
+ })}
+
+
+ {cap.displayName || "Expert"}
+ {cap.byline ? {cap.byline} : null}
+ {cap.rating !== null || cap.reviewsCount !== null ? (
+
+ {cap.rating !== null ? (
+ {cap.rating.toFixed(1)}
+ ) : null}
+ {cap.reviewsCount !== null ? (
+ ({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})
+ ) : null}
+
+ ) : null}
+
+ {count > 1 ? (
+
+ {experts.map((e, i) => (
+
+ ))}
+
+ ) :
}
+
+ );
+}
diff --git a/src/components/FeaturedExpertsRail.tsx b/src/components/FeaturedExpertsRail.tsx
deleted file mode 100644
index a057e91..0000000
--- a/src/components/FeaturedExpertsRail.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { DiscoverExpert } from "@/lib/discover";
-import { initials } from "@/lib/feed-model";
-
-function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
- if (rating === null && reviews === null) return null;
- return (
-
- {rating !== null ? ★ {rating.toFixed(1)} : null}
- {reviews !== null ? ({reviews} {reviews === 1 ? "review" : "reviews"}) : null}
-
- );
-}
-
-export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
- if (experts.length === 0) return null;
- return (
-
- );
-}
diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx
index d6c4aa1..60f6211 100644
--- a/src/components/Feed.tsx
+++ b/src/components/Feed.tsx
@@ -3,18 +3,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
-import { 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 { DeleteError, sendDelete } from "@/lib/delete-client";
+import type { ReactNode } from "react";
+import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
+import type { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView";
+import { useCompactLayout } from "./layout-context";
const POLL_MS = 45_000;
-export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
+export function Feed({
+ onSignedOut,
+ feedUserId,
+ person,
+ featuredExperts,
+ trendingMinnects,
+ searchPanel,
+}: {
+ onSignedOut: () => void;
+ feedUserId?: string | null;
+ /** The signed-in person, shown beside the composer. */
+ 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([]);
const [status, setStatus] = useState("loading");
const [nextCursor, setNextCursor] = useState(null);
const [loadingMore, setLoadingMore] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const [refreshFailed, setRefreshFailed] = useState(false);
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
const [pending, setPending] = useState([]);
const [likeBusy, setLikeBusy] = useState>(new Set());
@@ -29,8 +52,12 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
// re-checks the feed instead of creating a duplicate.
const unconfirmed = useRef<{ id: string | null }>({ id: null });
const sentinel = useRef(null);
+ const list = useRef(null);
const busy = useRef(false);
const shown = useRef>(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(() => {
shown.current = new Set(posts.map((p) => p.key));
@@ -43,10 +70,13 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
[onSignedOut],
);
- const loadFirst = useCallback(async () => {
+ // quiet: a refresh keeps the list on screen instead of swapping in the spinner.
+ const loadFirst = useCallback(async (quiet = false) => {
if (busy.current) return;
busy.current = true;
- setStatus("loading");
+ setRefreshFailed(false);
+ if (quiet) setRefreshing(true);
+ else setStatus("loading");
setLoadMoreFailed(false);
try {
const page = await fetchFeedPage(null);
@@ -56,9 +86,12 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
setStatus("ready");
} catch (e) {
fail(e);
- setStatus("error");
+ const out = firstLoadFailure(quiet);
+ if (out.fullError) setStatus("error");
+ setRefreshFailed(out.refreshFailed);
} finally {
busy.current = false;
+ setRefreshing(false);
}
}, [fail]);
@@ -93,11 +126,12 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
- { rootMargin: "400px" },
+ // The list scrolls inside its panel, so it is the observer root.
+ { root: list.current, rootMargin: "400px" },
);
io.observe(el);
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.
useEffect(() => {
@@ -224,7 +258,15 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
function showNew() {
setPosts((cur) => mergePosts(cur, pending));
setPending([]);
- window.scrollTo({ top: 0, behavior: "smooth" });
+ const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
+ }
+
+ function refresh() {
+ if (busy.current) return;
+ const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
+ void loadFirst(true);
}
return (
@@ -236,6 +278,9 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
newCount={pending.length}
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
onShowNew={showNew}
+ onRefresh={refresh}
+ refreshing={refreshing}
+ refreshFailed={refreshFailed}
onToggleLike={(p) => void toggleLike(p)}
likeBusy={likeBusy}
likeErrors={likeErrors}
@@ -244,8 +289,13 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
deleteBusy={deleteBusy}
deleteErrors={deleteErrors}
onSignedOut={onSignedOut}
- composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
+ 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}
+ listRef={list}
/>
);
}
diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx
index fbe02cd..494f4b4 100644
--- a/src/components/FeedView.tsx
+++ b/src/components/FeedView.tsx
@@ -1,7 +1,13 @@
-import type { Ref } from "react";
+"use client";
+
+import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model";
+import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import { Composer, type ComposerProps } from "./Composer";
+import { GrowButton, IconButton, SweepButton } from "./Buttons";
import { PostCard } from "./PostCard";
+import { useCompactLayout } from "./layout-context";
+import { MobileFeed, type CurrentSlide } from "./MobileFeed";
export type FeedStatus = "loading" | "ready" | "error";
@@ -13,6 +19,11 @@ export interface FeedViewProps {
newCount: number;
onRetry: () => void;
onShowNew: () => void;
+ /** Reloads the first page; the header shows the button when it is given. */
+ onRefresh?: () => void;
+ refreshing?: boolean;
+ /** The last refresh failed; the posts stay and a small message shows in the header. */
+ refreshFailed?: boolean;
onToggleLike?: (post: CardPost) => void;
likeBusy?: ReadonlySet;
likeErrors?: ReadonlySet;
@@ -24,70 +35,153 @@ export interface FeedViewProps {
deleteErrors?: ReadonlySet;
composer?: ComposerProps;
sentinelRef?: Ref;
+ /** The scrolling feed list; the page itself never scrolls. */
+ listRef?: Ref;
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() {
return ;
}
+/** Shimmer placeholder card shown while the next page loads. */
+function SkeletonCard() {
+ return (
+
+ );
+}
+
+function RefreshIcon({ spinning }: { spinning: boolean }) {
+ return (
+
+
+
+
+ );
+}
+
+export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy?: boolean }) {
+ return (
+ }
+ disabled={busy}
+ aria-busy={busy || undefined}
+ onClick={onRefresh}
+ />
+ );
+}
+
+function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean; refreshFailed?: boolean }) {
+ return (
+
+
+
Home feed
+ {refreshFailed ? Could not refresh. Try again. : null}
+ {onRefresh ? : null}
+
+ {composer}
+ {children}
+
+ );
+}
+
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 ;
if (p.status === "loading") {
return (
-
+
);
}
if (p.status === "error") {
return (
-
+
The feed did not load.
-
Try again
+
-
+
);
}
return (
-
- {p.composer ? : null}
- {p.newCount > 0 ? (
-
- {p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
-
- ) : null}
- {p.posts.length === 0 ? (
- No posts yet.
- ) : (
- p.posts.map((post) => )
- )}
- {p.loadMoreFailed ? (
-
-
The feed did not load.
-
Try again
-
- ) : null}
- {p.loadingMore ? (
-
-
-
- ) : null}
-
-
+ : null} onRefresh={p.onRefresh} busy={p.refreshing} refreshFailed={p.refreshFailed}>
+
+ {p.newCount > 0 ? (
+
+
+
+ ) : null}
+ {p.posts.length === 0 ? (
+
No posts yet.
+ ) : (
+ p.posts.map((post, i) =>
)
+ )}
+ {p.loadMoreFailed ? (
+
+
The feed did not load.
+
+
+ ) : null}
+ {p.loadingMore ?
: null}
+
+
+
);
}
diff --git a/src/components/HexAvatar.tsx b/src/components/HexAvatar.tsx
new file mode 100644
index 0000000..7106516
--- /dev/null
+++ b/src/components/HexAvatar.tsx
@@ -0,0 +1,41 @@
+import { initials } from "@/lib/feed-model";
+
+export type HexAvatarSize = "lg" | "md" | "sm";
+
+/** Width by height in px for each spec size (ratio 0.866). */
+export const HEX_SIZES: Record = {
+ lg: { width: 46, height: 53 },
+ md: { width: 38, height: 44 },
+ sm: { width: 30, height: 35 },
+};
+
+export const HEX_TONES = ["oxblood", "navy", "forest", "slate", "plum", "graphite"] as const;
+export type HexTone = (typeof HEX_TONES)[number];
+
+/** Same name always gives the same tone. */
+export function toneFor(name: string): HexTone {
+ let sum = 0;
+ for (const ch of name.trim().toLowerCase()) sum = (sum * 31 + ch.codePointAt(0)!) >>> 0;
+ return HEX_TONES[sum % HEX_TONES.length];
+}
+
+export interface HexAvatarProps {
+ name: string;
+ pictureUrl?: string | null;
+ size?: HexAvatarSize;
+}
+
+export function HexAvatar({ name, pictureUrl, size = "md" }: HexAvatarProps) {
+ const { width, height } = HEX_SIZES[size];
+ if (pictureUrl) {
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+ }
+ return (
+
+ {initials(name)}
+
+ );
+}
diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx
index 79ba50c..80ae0f0 100644
--- a/src/components/Logo.tsx
+++ b/src/components/Logo.tsx
@@ -1,8 +1,8 @@
export function Logo({ size = 44 }: { size?: number }) {
return (
-
-
+
+
);
}
diff --git a/src/components/MinnectMedia.tsx b/src/components/MinnectMedia.tsx
index c315e25..1acb8b3 100644
--- a/src/components/MinnectMedia.tsx
+++ b/src/components/MinnectMedia.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useState } from "react";
+import { useRef, useState } from "react";
+import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverMedia } from "@/lib/discover";
interface Props {
@@ -8,36 +9,81 @@ interface Props {
thumbnailUrl: string | null;
question: string | null;
who: string;
+ pictureUrl?: string | null;
+ views?: number | null;
}
-/** Video answers play inline; audio keeps the audio control; no url shows the thumbnail only. */
-export function MinnectMedia({ media, thumbnailUrl, question, who }: Props) {
+/**
+ * The trending card's 16:9 frame. Video answers play inline as a with a
+ * poster; audio keeps its control under the frame; no url shows the
+ * thumbnail only. The glass chip, play button, views and kind label sit over
+ * the thumbnail and step aside once a video has started, and only then does the
+ * video show its native controls.
+ */
+export function MinnectMedia({ media, thumbnailUrl, question, who, pictureUrl, views = null }: Props) {
const [videoFailed, setVideoFailed] = useState(false);
+ // Native controls only after the first play, so the frame never shows our
+ // play button and the browser's controls at the same time.
+ const [started, setStarted] = useState(false);
+ const video = useRef(null);
+ const audio = useRef(null);
- const thumb = thumbnailUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : null;
+ const isVideo = media?.kind === "video" && !videoFailed;
+ const isAudio = media?.kind === "audio";
+ const playable = isVideo || isAudio;
+
+ const play = () => {
+ const el = isVideo ? video.current : audio.current;
+ void el?.play().catch(() => {});
+ };
- if (media?.kind === "video" && !videoFailed) {
- return (
- setVideoFailed(true)}
- />
- );
- }
return (
<>
- {thumb}
- {media?.kind === "audio" ? (
-
+
+ {isVideo ? (
+
setStarted(true)}
+ onError={() => setVideoFailed(true)}
+ />
+ ) : thumbnailUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+
+
+ {who}
+
+ {playable ? (
+
+
+
+ ) : null}
+
+ {views !== null || playable ? (
+
+ {views !== null ? (
+
+
+
+
+ {views.toLocaleString("en-US")} {views === 1 ? "view" : "views"}
+
+ ) :
}
+ {playable ?
{isVideo ? "Video" : "Audio"} : null}
+
+ ) : null}
+ {isAudio ? (
+
) : null}
>
);
diff --git a/src/components/MobileDock.tsx b/src/components/MobileDock.tsx
new file mode 100644
index 0000000..481cec1
--- /dev/null
+++ b/src/components/MobileDock.tsx
@@ -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 (
+
+ {
+ if (!s.post) return;
+ setBeat(!s.post.isLiked);
+ onToggleLike?.(s.post);
+ }}
+ onAnimationEnd={() => setBeat(false)}
+ >
+
+
+
+ {s.likes !== null ? {s.likes} : null}
+
+ {
+ if (s.post?.id) onOpenComments?.(s.post.id);
+ }}
+ >
+
+
+
+ {s.comments !== null ? {s.comments} : null}
+
+ onOpenComposer?.()} />
+ onOpenMore?.()}>
+
+
+
+
+
+
+ More
+
+
+ );
+}
diff --git a/src/components/MobileFeed.tsx b/src/components/MobileFeed.tsx
new file mode 100644
index 0000000..c9cf5d6
--- /dev/null
+++ b/src/components/MobileFeed.tsx
@@ -0,0 +1,708 @@
+"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, SweepButton } from "./Buttons";
+import { HexAvatar } from "./HexAvatar";
+import { PlayIcon, PostMenu } from "./PostCard";
+import { VideoPlayer } from "./VideoPlayer";
+import { CaptionText, QuoteText } from "./SlideText";
+import { FeaturedCarousel } from "./FeaturedCarousel";
+import { Logo } from "./Logo";
+import { useTheme } from "@/lib/use-theme";
+import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
+import { MobileDock } from "./MobileDock";
+import { BottomSheet } from "./BottomSheet";
+import { CommentsSection } from "./CommentsSection";
+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'], [data-expanded-text]");
+}
+
+/** 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(ref: Ref | undefined, value: T | null) {
+ if (typeof ref === "function") ref(value);
+ else if (ref) (ref as { current: T | null }).current = value;
+}
+
+/** Aspect ratio (width / height) at or below which a video fills the slide (4:5 or taller). */
+export const COVER_MAX_RATIO = 0.8;
+
+/** "cover" for portrait and near-portrait video, "contain" for wider, "cover" while the size is unknown. */
+export function videoFit(width: number, height: number): "cover" | "contain" {
+ if (!(width > 0) || !(height > 0)) return "cover";
+ return width / height <= COVER_MAX_RATIO ? "cover" : "contain";
+}
+
+/** True when the person asked for less motion. Updates when the setting changes; false on the server. */
+function useReducedMotion(): boolean | null {
+ const [reduced, setReduced] = useState(null);
+ useEffect(() => {
+ if (typeof window.matchMedia !== "function") return setReduced(false);
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const read = () => setReduced(mq.matches);
+ read();
+ mq.addEventListener?.("change", read);
+ return () => mq.removeEventListener?.("change", read);
+ }, []);
+ return reduced;
+}
+
+function SoundIcon({ muted }: { muted: boolean }) {
+ return (
+
+
+ {muted ? : }
+
+ );
+}
+
+/**
+ * One media item of a slide. A video plays by itself, muted and inline, while
+ * `active` (its slide and pane are on screen) unless the person asked for less
+ * motion or the browser refuses: then the play button starts it as before.
+ * `hold` (a sheet is open) pauses a playing video and resumes it after.
+ */
+function SlideMedia({ media, author, active = false, hold = false }: { media: CardMedia; author: string; active?: boolean; hold?: boolean }) {
+ const box = useRef(null);
+ const [started, setStarted] = useState(false);
+ // Real video size once metadata loads; until then the thumbnail's size decides.
+ const [videoSize, setVideoSize] = useState<[number, number] | null>(null);
+ const [thumbSize, setThumbSize] = useState<[number, number] | null>(null);
+ const playable = isPlayableVideo(media);
+ const reduced = useReducedMotion();
+ // The browser refused autoplay: fall back to the play button.
+ const [blocked, setBlocked] = useState(false);
+ const auto = reduced === false && !blocked;
+ const [muted, setMuted] = useState(getSessionMuted);
+ // Paused by a tap: it stays paused until tapped again or the slide leaves.
+ const [userPaused, setUserPaused] = useState(false);
+ const userPausedRef = useRef(false);
+ const resumeAfterHold = useRef(false);
+ const video = () => box.current?.querySelector("video") ?? null;
+ useEffect(() => {
+ const v = video();
+ if (!v) return;
+ const read = () => {
+ if (v.videoWidth > 0 && v.videoHeight > 0) setVideoSize([v.videoWidth, v.videoHeight]);
+ };
+ read();
+ v.addEventListener("loadedmetadata", read);
+ return () => v.removeEventListener("loadedmetadata", read);
+ }, [playable, media.mp4Url, media.hlsUrl]);
+
+ const autoplay = useCallback((v: HTMLVideoElement) => {
+ v.muted = getSessionMuted();
+ // Like a reel, an autoplayed video loops while it is on screen.
+ v.loop = true;
+ setMuted(v.muted);
+ v.play().catch((err: unknown) => {
+ if (!isAutoplayBlocked(err)) return;
+ // Sound not allowed yet: play muted this time, keeping the session's choice.
+ if (!v.muted) {
+ v.muted = true;
+ setMuted(true);
+ v.play().catch((again: unknown) => {
+ if (isAutoplayBlocked(again)) setBlocked(true);
+ });
+ } else setBlocked(true);
+ });
+ }, []);
+
+ useEffect(() => {
+ const v = video();
+ if (!v || !playable) return;
+ if (!active) {
+ userPausedRef.current = false;
+ setUserPaused(false);
+ resumeAfterHold.current = false;
+ v.pause();
+ return;
+ }
+ if (hold) {
+ if (!v.paused) {
+ resumeAfterHold.current = true;
+ v.pause();
+ }
+ return;
+ }
+ if (resumeAfterHold.current) {
+ resumeAfterHold.current = false;
+ void v.play().catch(() => {});
+ return;
+ }
+ if (auto && !userPausedRef.current) autoplay(v);
+ }, [active, hold, auto, playable, autoplay]);
+
+ if (playable) {
+ const size = videoSize ?? thumbSize;
+ const fit = size ? videoFit(size[0], size[1]) : "cover";
+ const duration = formatDuration(media.durationSeconds);
+ const start = () => {
+ setStarted(true);
+ const v = video();
+ if (!v) return;
+ v.muted = false;
+ void v.play().catch(() => {});
+ };
+ // Autoplay: a tap on the video pauses or resumes it (no native controls).
+ const togglePause = () => {
+ const v = video();
+ if (!v) return;
+ const pause = !v.paused;
+ userPausedRef.current = pause;
+ setUserPaused(pause);
+ if (pause) v.pause();
+ else void v.play().catch(() => {});
+ };
+ const toggleMute = () => {
+ const v = video();
+ const next = !muted;
+ if (v) v.muted = next;
+ setSessionMuted(next);
+ setMuted(next);
+ };
+ const showPlay = auto ? userPaused : !started;
+ return (
+
+ {media.src ? (
+ // The thumbnail doubles as the blurred backdrop behind a contained video, and as the size fallback.
+ // eslint-disable-next-line @next/next/no-img-element
+
setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])}
+ />
+ ) : null}
+
+
+
+ {auto ? (
+
+
+
+ ) : null}
+ {showPlay ? (
+ <>
+
+
+
+ {duration && !auto ?
{duration} : null}
+ >
+ ) : null}
+
+ );
+ }
+ if (media.src) {
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ );
+ }
+ return (
+
+ Video is processing
+
+ );
+}
+
+/** Several media items in one slide: a sideways scroll-snap track with a dot per item. */
+function MediaTrack({ items, author, current, hold }: { items: CardMedia[]; author: string; current: boolean; hold: boolean }) {
+ const track = useRef(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(":scope > .slide-pane").forEach((pane, i) => {
+ if (i !== next) pane.querySelectorAll("video").forEach((v) => v.pause());
+ });
+ };
+ return (
+ <>
+
+ {items.map((m, i) => (
+
+
+
+ ))}
+
+
+ {items.map((_, i) => (
+
+ ))}
+
+ >
+ );
+}
+
+function PostSlide({
+ post,
+ index,
+ current,
+ hold,
+ p,
+ onExpandedChange,
+}: {
+ post: CardPost;
+ index: number;
+ current: boolean;
+ /** A sheet is open over the feed: a playing video pauses until it closes. */
+ hold: boolean;
+ p: FeedViewProps;
+ onExpandedChange?: (index: number, open: boolean) => void;
+}) {
+ const items = post.media.filter((m) => m.kind === "video" || m.src);
+ const media = items[0] ?? null;
+ const own = canDelete(post, p.feedUserId) && !!p.onDelete;
+ // While a video plays with native controls (play button, not autoplay), the author and text step aside.
+ const [playing, setPlaying] = useState(false);
+ // Long text opened with "Read more" or "more". It closes when the slide leaves the screen.
+ const [textOpen, setTextOpen] = useState(false);
+ useEffect(() => {
+ if (!current) setTextOpen(false);
+ }, [current]);
+ useEffect(() => {
+ onExpandedChange?.(index, textOpen);
+ }, [index, textOpen, onExpandedChange]);
+ useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]);
+ const cls =
+ `slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` +
+ `${own ? " slide-own" : ""}${textOpen ? " slide-text-open" : ""}`;
+ const lastTap = useRef(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) => {
+ 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 (
+ {
+ if ((e.target as HTMLVideoElement).controls) setPlaying(true);
+ }}
+ onPauseCapture={() => setPlaying(false)}
+ onEndedCapture={() => setPlaying(false)}
+ >
+ {media ? (
+ <>
+ {items.length > 1 ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ ) : (
+
+ )}
+ {burst ? (
+
+
+
+ ) : null}
+ {own ? (
+
+ ) : null}
+
+ {p.deleteErrors?.has(post.key) ?
Could not delete the post.
: null}
+
+
+
+
+ {post.authorName}
+ {own ? You : null}
+
+ {relativeTime(post.publishedAt, p.now)}
+
+
+ {media && post.text ?
: null}
+
+
+ );
+}
+
+/** 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 (
+
+
+
+ MINNECT
+
+
+ {theme === "dark" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ChevronDown() {
+ return (
+
+
+
+ );
+}
+
+/** 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 (
+
+ Featured experts
+ Ask the people everyone is talking about
+
+
+
+
+
+ Keep scrolling
+
+
+ );
+}
+
+function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref }) {
+ return (
+
+ );
+}
+
+/**
+ * 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(null);
+ const [current, setCurrent] = useState(0);
+ // The slide whose long text is open: the feed holds still so swipes scroll the text.
+ const [expandedAt, setExpandedAt] = useState(null);
+ const locked = useRef(false);
+ locked.current = expandedAt !== null;
+ const onExpandedChange = useCallback((at: number, open: boolean) => {
+ setExpandedAt((cur) => (open ? at : cur === at ? null : cur));
+ }, []);
+ // The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
+ const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
+ const closeSheet = useCallback(() => setSheet(null), []);
+ // 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();
+ 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(":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) => {
+ // Open text scrolls natively; the reel is overflow-hidden meanwhile.
+ if (locked.current) return;
+ if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
+ e.preventDefault();
+ const now = performance.now();
+ 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 && !locked.current ? e.touches[0].clientY : null;
+ from = at();
+ };
+ const onTouchEnd = (e: TouchEvent) => {
+ if (startY === null || e.touches.length || locked.current) return;
+ const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
+ startY = null;
+ if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
+ };
+ 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(":scope > .slide").forEach((el) => {
+ if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause());
+ });
+ }, [index]);
+
+ return (
+
+ {p.newCount > 0 && p.status === "ready" ? (
+
+
+
+ ) : null}
+
+
+
+
+ {
+ reel.current = el;
+ setRef(p.listRef, el);
+ }}
+ >
+ {p.status === "error" ? (
+
+ The feed did not load.
+
+
+ ) : null}
+ {p.status === "ready" && p.posts.length === 0 && !showLoad ? (
+
+ ) : null}
+ {p.status === "ready"
+ ? p.posts.flatMap((post, i) => {
+ const node =
;
+ return i === featuredAt - 1
+ ? [node,
]
+ : [node];
+ })
+ : null}
+ {p.status === "ready" && p.loadMoreFailed ? (
+
+ The feed did not load.
+
+
+ ) : null}
+ {showLoad ? (
+
+ ) : null}
+
+ {p.status !== "error" ? (
+ {
+ setSheet({ kind: "comments", postId });
+ p.onOpenComments?.(postId);
+ }}
+ onOpenComposer={() => {
+ setSheet({ kind: "composer" });
+ p.onOpenComposer?.();
+ }}
+ onOpenMore={() => {
+ setSheet({ kind: "more" });
+ p.onOpenMore?.();
+ }}
+ />
+ ) : null}
+ {sheet?.kind === "comments" ? (
+
+
+
+ ) : null}
+ {sheet?.kind === "composer" && p.composer ? (
+
+
+
+ ) : null}
+ {sheet?.kind === "more" ? (
+ setSheet({ kind: "panel", panel })} onClose={closeSheet} />
+ ) : null}
+ {sheet?.kind === "panel" ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx
index fb87ac7..21e4d9a 100644
--- a/src/components/PostCard.tsx
+++ b/src/components/PostCard.tsx
@@ -1,13 +1,17 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState, type CSSProperties } from "react";
+import { HexAvatar } from "@/components/HexAvatar";
import { CommentsSection } from "@/components/CommentsSection";
import { VideoPlayer } from "@/components/VideoPlayer";
-import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
+import { IconButton, SweepButton } from "@/components/Buttons";
+import { commentsLabel, formatDuration, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
export interface PostCardProps {
post: CardPost;
now?: number;
+ /** Position in the list; the first cards rise in one after another. */
+ index?: number;
onToggleLike?: (post: CardPost) => void;
likeBusy?: boolean;
likeError?: boolean;
@@ -19,9 +23,19 @@ export interface PostCardProps {
deleteError?: boolean;
}
+const icon = {
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 2,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+ "aria-hidden": true,
+};
+
function Heart({ filled }: { filled: boolean }) {
return (
-
@@ -29,68 +43,155 @@ function Heart({ filled }: { filled: boolean }) {
);
}
-export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
- const [open, setOpen] = useState(false);
+function CommentIcon() {
+ return (
+
+
+
+ );
+}
+
+function TrashIcon() {
+ return (
+
+
+
+ );
+}
+
+export function PlayIcon() {
+ return (
+
+
+
+ );
+}
+
+/** A playable video: the real player, with a frosted play button and the length over it until it starts. */
+function PostVideo({ media, author }: { media: CardMedia; author: string }) {
+ const box = useRef(null);
+ const [started, setStarted] = useState(false);
+ const duration = formatDuration(media.durationSeconds);
+ const start = () => {
+ setStarted(true);
+ void box.current?.querySelector("video")?.play().catch(() => {});
+ };
+ return (
+
+
+ {started ? null : (
+ <>
+
+
+
+ {duration ?
{duration} : null}
+ >
+ )}
+
+ );
+}
+
+export interface PostMenuProps {
+ 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 [confirming, setConfirming] = useState(false);
+ const menuBox = useRef(null);
+
+ useEffect(() => {
+ if (!menu) return;
+ const close = () => { setMenu(false); setConfirming(false); };
+ const onDown = (e: MouseEvent) => { if (!menuBox.current?.contains(e.target as Node)) close(); };
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
+ document.addEventListener("mousedown", onDown);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onDown);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [menu]);
+
return (
-
+
+
{ setMenu((m) => !m); setConfirming(false); }}
+ >
+
+
+
+
+ {menu ? (
+
+ {confirming ? (
+
+
Delete this post?
+
This cannot be undone.
+
+ { setConfirming(false); setMenu(false); }}
+ />
+ }
+ size="sm"
+ disabled={deleteBusy}
+ onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }}
+ />
+
+
+ ) : (
+
setConfirming(true)}>
+
+ Delete post
+
+ )}
+
+ ) : 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 (
+
- {post.authorPictureUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- {initials(post.authorName)}
-
- )}
+
- {post.authorName}
+
+ {post.authorName}
+ {own ? You : null}
+
{relativeTime(post.publishedAt, now)}
- {canDelete && onDelete ? (
-
-
{ setMenu((m) => !m); setConfirming(false); }}
- >
- ...
-
- {menu ? (
-
- {confirming ? (
- <>
- Delete this post?
- { setMenu(false); setConfirming(false); onDelete(post); }}>
- Confirm delete
-
- { setConfirming(false); setMenu(false); }}>
- Cancel
-
- >
- ) : (
- setConfirming(true)}>
- Delete post
-
- )}
-
- ) : null}
-
- ) : null}
+ {own ? : null}
- {deleteError ? Could not delete the post.
: null}
+ {deleteError ? Could not delete the post.
: null}
{post.text ? {post.text}
: null}
{post.media.map((m, i) =>
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
-
-
-
+
) : m.src ? (
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -102,28 +203,29 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
) : null,
)}
-
+
onToggleLike?.(post)}
+ onClick={() => { setBeat(!post.isLiked); onToggleLike?.(post); }}
>
- {post.likesCount}
+ {post.likesCount}
setOpen((o) => !o)}
>
- {post.commentsCount} comments
+
+ {commentsLabel(post.commentsCount)}
{likeError ? (
- Could not update like.
+ Could not update like.
) : null}
{open && post.id ? : null}
diff --git a/src/components/SignInForm.tsx b/src/components/SignInForm.tsx
index bd5db95..fdb040c 100644
--- a/src/components/SignInForm.tsx
+++ b/src/components/SignInForm.tsx
@@ -3,6 +3,7 @@
import { useState, type FormEvent } from "react";
import { signIn, type Person } from "@/lib/auth-client";
import { Logo } from "./Logo";
+import { SparkleButton } from "./Buttons";
export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void }) {
const [email, setEmail] = useState("");
@@ -27,39 +28,72 @@ export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void })
}
return (
-
-
-
- Sign in to Minnect
- {error && (
-
- {error}
-
- )}
- Email
- setEmail(e.target.value)}
- />
- Password
- setPassword(e.target.value)}
- />
-
- {busy ? "Signing in..." : "Sign in"}
-
-
+
+
+
+
+
+ MINNECT
+
+
+
Members
+
+ Human answers for human decisions
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ Email
+
+
+
+
+
+
+
setEmail(e.target.value)}
+ />
+
+
+ Password
+
+
+
+
+
+
+
setPassword(e.target.value)}
+ />
+
+
+
+
);
}
diff --git a/src/components/SlideText.tsx b/src/components/SlideText.tsx
new file mode 100644
index 0000000..95a9c4a
--- /dev/null
+++ b/src/components/SlideText.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+
+/** Quote sizes for a text-only post, largest first. The smallest one clamps and offers "Read more". */
+export const QUOTE_SIZES = [31, 26, 22, 19] as const;
+/** Space kept under a clamped quote for the "Read more" button (44px tall plus its gap). */
+export const QUOTE_MORE_RESERVE = 52;
+
+export interface QuoteFit {
+ size: number;
+ /** Lines shown before "Read more"; 0 when the whole text fits. */
+ lines: number;
+}
+
+/**
+ * The first quote size whose rendered text fits, or the smallest size with as
+ * many whole lines as fit above the "Read more" button. `fits` measures the
+ * rendered text at a size; `linesAt` counts the lines that fit at the smallest one.
+ */
+export function pickQuoteFit(fits: (size: number) => boolean, linesAt: (size: number) => number): QuoteFit {
+ for (const size of QUOTE_SIZES) if (fits(size)) return { size, lines: 0 };
+ const size = QUOTE_SIZES[QUOTE_SIZES.length - 1];
+ return { size, lines: Math.max(1, Math.floor(linesAt(size))) };
+}
+
+/** Re-runs `measure` when the box resizes, web fonts finish loading, or the theme changes. */
+function useRemeasure(box: React.RefObject, measure: () => void, deps: unknown[]) {
+ useLayoutEffect(() => {
+ measure();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+ useEffect(() => {
+ const el = box.current;
+ if (!el) return;
+ let frame = 0;
+ const later = () => {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(measure);
+ };
+ const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(later) : null;
+ ro?.observe(el);
+ const fonts = typeof document !== "undefined" ? document.fonts : undefined;
+ void fonts?.ready.then(later);
+ fonts?.addEventListener?.("loadingdone", later);
+ const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(later) : null;
+ mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
+ return () => {
+ cancelAnimationFrame(frame);
+ ro?.disconnect();
+ mo?.disconnect();
+ fonts?.removeEventListener?.("loadingdone", later);
+ };
+ }, [box, measure]);
+}
+
+/**
+ * Text-only post: the serif quote steps down 31 → 26 → 22 → 19px until it fits
+ * between the top bar and the author block. When it still does not fit, it
+ * clamps and ends with "Read more", which opens the full text over the slide.
+ */
+export function QuoteText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
+ const box = useRef(null);
+ const inner = useRef(null);
+ const quote = useRef(null);
+ const moreBtn = useRef(null);
+ const closeBtn = useRef(null);
+ const [fit, setFit] = useState({ size: QUOTE_SIZES[0], lines: 0 });
+
+ const measure = useCallback(() => {
+ const b = box.current;
+ const q = quote.current;
+ const w = inner.current;
+ if (!b || !q || !w || !b.clientHeight) return;
+ const apply = (f: QuoteFit) => {
+ q.style.fontSize = `${f.size}px`;
+ q.style.webkitLineClamp = f.lines ? String(f.lines) : "";
+ q.classList.toggle("is-clamped", f.lines > 0);
+ b.classList.toggle("is-clamped", f.lines > 0);
+ };
+ const result = pickQuoteFit(
+ (size) => {
+ apply({ size, lines: 0 });
+ return w.offsetHeight <= b.clientHeight + 0.5;
+ },
+ () => {
+ // Height left for the text: the box minus the quote mark, its gap and the button.
+ const other = w.offsetHeight - q.offsetHeight;
+ const lineHeight = parseFloat(getComputedStyle(q).lineHeight) || 19 * 1.2;
+ return (b.clientHeight - other - QUOTE_MORE_RESERVE) / lineHeight;
+ },
+ );
+ apply(result);
+ setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result));
+ }, []);
+ useRemeasure(box, measure, [text]);
+
+ // Focus follows the reader: into it on open, back to "Read more" on close.
+ const wasOpen = useRef(false);
+ useEffect(() => {
+ if (open) closeBtn.current?.focus({ preventScroll: true });
+ else if (wasOpen.current) moreBtn.current?.focus({ preventScroll: true });
+ wasOpen.current = open;
+ }, [open]);
+
+ return (
+ <>
+
+
+ {fit.lines ? (
+
onOpenChange(true)}
+ >
+ Read more
+
+ ) : null}
+
+ {open ? (
+ {
+ if (e.key === "Escape") onOpenChange(false);
+ }}
+ >
+
+
onOpenChange(false)}>
+ Close
+
+
+ ) : null}
+ >
+ );
+}
+
+/**
+ * Caption over a photo or video: three lines, then "more" when it is longer.
+ * Open, the whole caption shows over the media on a darker scrim, scrolling
+ * inside the slide when it is very long, with "less" to collapse it.
+ */
+export function CaptionText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
+ const caption = useRef(null);
+ const [long, setLong] = useState(false);
+ const measure = useCallback(() => {
+ const c = caption.current;
+ // Measured only while clamped: an open caption is long by definition.
+ if (!c || c.classList.contains("is-open") || !c.clientHeight) return;
+ setLong(c.scrollHeight > c.clientHeight + 1);
+ }, []);
+ useRemeasure(caption, measure, [text]);
+ useEffect(() => {
+ if (!open && caption.current) caption.current.scrollTop = 0;
+ }, [open]);
+ return (
+ <>
+
+ {text}
+
+ {long || open ? (
+ onOpenChange(!open)}>
+ {open ? "less" : "more"}
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/components/TrendingMinnectsRail.tsx b/src/components/TrendingMinnectsRail.tsx
index e90b4f5..7d7c594 100644
--- a/src/components/TrendingMinnectsRail.tsx
+++ b/src/components/TrendingMinnectsRail.tsx
@@ -1,31 +1,27 @@
import type { DiscoverMinnect } from "@/lib/discover";
import { MinnectMedia } from "./MinnectMedia";
-import { initials } from "@/lib/feed-model";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
return (
-
- Trending minnects
-
+
+
+
Trending minnects
+
+
{minnects.map((m) => {
const who = m.expert.displayName || "Expert";
return (
-
-
- {m.expert.pictureUrl ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
{initials(who)}
- )}
-
{who}
-
-
- {m.question ? {m.question}
: null}
- {m.views !== null ? (
- {m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}
- ) : null}
+
+
+ {m.question ? {m.question}
: null}
);
})}
diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx
index 8c2c73e..9d53f8a 100644
--- a/src/components/VideoPlayer.tsx
+++ b/src/components/VideoPlayer.tsx
@@ -8,6 +8,10 @@ export interface VideoPlayerProps {
hlsUrl?: string | null;
poster: string | null;
label: string;
+ /** Native controls; off until the caller's own play button starts the video. Default on. */
+ controls?: boolean;
+ /** Start muted (the mobile feed's autoplay). Desktop leaves it unset. */
+ muted?: boolean;
}
const UNPLAYABLE = "Video can't be played here.";
@@ -16,9 +20,9 @@ const UNPLAYABLE = "Video can't be played here.";
* Real . Order: direct mp4Url (plain progressive source), else
* hls.js (lazy-loaded, only for the HLS path; native HLS only where hls.js is
* unsupported), else thumbnail only. Never
- * autoplays. Any failure falls back to the thumbnail with a short note.
+ * autoplays by itself (the mobile feed starts it). Any failure falls back to the thumbnail with a short note.
*/
-export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) {
+export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true, muted }: VideoPlayerProps) {
const ref = useRef(null);
const [failed, setFailed] = useState(false);
@@ -77,7 +81,8 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps)
start && text[end - 1] === "\n") end -= 1;
+
+ // Offsets of every line start from the caret's line to the selection's last line.
+ const firstLine = text.lastIndexOf("\n", start - 1) + 1;
+ const lineStarts: number[] = [];
+ for (let i = firstLine; i <= end; ) {
+ lineStarts.push(i);
+ const next = text.indexOf("\n", i);
+ if (next === -1 || next >= end) break;
+ i = next + 1;
+ }
+
+ let out = "";
+ let last = 0;
+ let newStart = selectionStart;
+ let newEnd = selectionEnd;
+ for (const at of lineStarts) {
+ out += text.slice(last, at);
+ last = at;
+ if (text.startsWith(BULLET, at)) continue;
+ out += BULLET;
+ if (at <= selectionStart) newStart += BULLET.length;
+ if (at <= selectionEnd) newEnd += BULLET.length;
+ }
+ out += text.slice(last);
+ return { text: out, selectionStart: newStart, selectionEnd: newEnd };
+}
+
+/** "2.4 MB", "820 KB" or "512 B". */
+export function formatBytes(bytes: number): string {
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${bytes} B`;
+}
+
+/** The attachment row's meta line: type and size, for example "Image, 2.4 MB". */
+export function attachmentMeta(file: { type: string; size: number }): string {
+ const kind = file.type.startsWith("video/") ? "Video" : "Image";
+ return `${kind}, ${formatBytes(file.size)}`;
+}
+
+/** The 32 frequent emoji in the composer's emoji popover (mockup order). */
+export const COMPOSER_EMOJI = [
+ "😀", "😂", "😍", "🥳", "😎", "🤔", "🙌", "👏",
+ "🔥", "💡", "🚀", "🎯", "✅", "❤️", "💯", "✨",
+ "🙏", "👀", "📈", "🧠", "💬", "📣", "🎉", "☕",
+ "🌍", "📚", "🤝", "💪", "🌱", "⚡", "🏆", "👋",
+] as const;
diff --git a/src/components/featured-carousel-model.ts b/src/components/featured-carousel-model.ts
new file mode 100644
index 0000000..fb5e12e
--- /dev/null
+++ b/src/components/featured-carousel-model.ts
@@ -0,0 +1,68 @@
+/** Rotation timing from the spec ("Featured experts carousel"). */
+export const FEATURED_INTERVAL_MS = 2800;
+
+export type FeaturedSlot = "c0" | "l1" | "r1" | "l2" | "r2" | "l3" | "r3";
+
+/**
+ * Where item `i` sits when `current` is in the center: by its signed offset
+ * from the current index, wrapped so the shorter way round wins. With two
+ * items the other one is a single right neighbor, never an empty pair.
+ */
+export function featuredSlot(i: number, current: number, count: number): FeaturedSlot {
+ let off = (((i - current) % count) + count) % count;
+ if (off > count / 2) off -= count;
+ if (off === 0) return "c0";
+ if (off === 1) return "r1";
+ if (off === -1) return "l1";
+ if (off === 2) return "r2";
+ if (off === -2) return "l2";
+ return off > 0 ? "r3" : "l3";
+}
+
+export interface FeaturedAutoplay {
+ readonly index: number;
+ readonly paused: boolean;
+ pause(): void;
+ resume(): void;
+ stop(): void;
+}
+
+/**
+ * Advances the index every `intervalMs` while not paused. A single item never
+ * rotates. The caller stops it on unmount.
+ */
+export function startFeaturedAutoplay(opts: {
+ count: number;
+ onChange: (index: number) => void;
+ intervalMs?: number;
+ start?: number;
+}): FeaturedAutoplay {
+ const { count, onChange, intervalMs = FEATURED_INTERVAL_MS } = opts;
+ let index = opts.start ?? 0;
+ let paused = false;
+ const timer =
+ count > 1
+ ? setInterval(() => {
+ if (paused) return;
+ index = (index + 1) % count;
+ onChange(index);
+ }, intervalMs)
+ : null;
+ return {
+ get index() { return index; },
+ get paused() { return paused; },
+ pause() { paused = true; },
+ resume() { paused = false; },
+ stop() { if (timer) clearInterval(timer); },
+ };
+}
+
+/**
+ * WAI-ARIA carousel pattern: a rotating carousel must not announce every
+ * slide, so the region is `off` while autoplay runs and `polite` when the
+ * person has paused it (hover, focus) or it never rotates (reduced motion,
+ * a single expert).
+ */
+export function featuredLiveMode(opts: { rotating: boolean; paused: boolean }): "off" | "polite" {
+ return opts.rotating && !opts.paused ? "off" : "polite";
+}
diff --git a/src/components/layout-context.ts b/src/components/layout-context.ts
new file mode 100644
index 0000000..1aa9449
--- /dev/null
+++ b/src/components/layout-context.ts
@@ -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);
+}
diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx
new file mode 100644
index 0000000..a642f3b
--- /dev/null
+++ b/src/dev/FeedPreview.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+// 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
+// checked by hand. Used by src/app/dev/feed/page.tsx.
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { AppShell } from "@/components/AppShell";
+import { DiscoverRailsView } from "@/components/DiscoverRails";
+import { ExpertSearchView } from "@/components/ExpertSearch";
+import { FeedView } from "@/components/FeedView";
+import { toComments } from "@/lib/comment-model";
+import { type CardPost } from "@/lib/feed-model";
+import {
+ FIXTURE_CATEGORIES,
+ FIXTURE_COMMENTS,
+ FIXTURE_DISCOVER,
+ FIXTURE_FEED_ITEMS,
+ fixtureCards,
+ FIXTURE_NOW,
+ FIXTURE_PERSON,
+ FIXTURE_SEARCH_RESULTS,
+} from "./fixtures";
+
+export function FeedPreview() {
+ const [posts, setPosts] = useState(() => fixtureCards(FIXTURE_FEED_ITEMS));
+ const [draft, setDraft] = useState("");
+ const [file, setFile] = useState(null);
+ const [query, setQuery] = useState("");
+ const [category, setCategory] = useState(null);
+ // Fixture switches for the new-posts pill and the loading-more skeleton.
+ const [newCount, setNewCount] = useState(0);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
+ const list = useRef(null);
+ // The list element swaps between the panel and the phone reel; paging re-attaches to it.
+ const [listEl, setListEl] = useState(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(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, ...fixtureCards(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.
+ useEffect(() => {
+ const real = window.fetch;
+ const lists: Record = { ...FIXTURE_COMMENTS };
+ window.fetch = async (input, init) => {
+ const m = /\/api\/posts\/([^/]+)\/comments/.exec(String(input));
+ if (!m) return real(input, init);
+ const id = decodeURIComponent(m[1]);
+ if (init?.method === "POST") {
+ const { content } = JSON.parse(String(init.body)) as { content: string };
+ const made = { id: `local-${Date.now()}`, content, createdAt: new Date(FIXTURE_NOW).toISOString(), author: { displayName: FIXTURE_PERSON.name } };
+ lists[id] = [...(lists[id] ?? []), made];
+ return Response.json({ comment: toComments([made])[0] });
+ }
+ return Response.json({ comments: toComments(lists[id] ?? []) });
+ };
+ return () => { window.fetch = real; };
+ }, []);
+
+ // With no query or category, show every fixture result so the list scrolls.
+ const results = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ return q ? FIXTURE_SEARCH_RESULTS.filter((e) => e.displayName.toLowerCase().includes(q)) : FIXTURE_SEARCH_RESULTS;
+ }, [query]);
+
+ const toggleLike = (post: CardPost) =>
+ setPosts((cur) =>
+ cur.map((p) =>
+ p.key === post.key ? { ...p, isLiked: !p.isLiked, likesCount: p.likesCount + (p.isLiked ? -1 : 1) } : p,
+ ),
+ );
+
+ return (
+ <>
+
+
+ 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
+
+
+ setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
+
+
+
+ }
+ feed={
+
+ }
+ sentinelRef={sentinel}
+ listRef={listRef}
+ loadMoreFailed={false}
+ newCount={newCount}
+ onRetry={() => {}}
+ onShowNew={() => {
+ setNewCount(0);
+ list.current?.scrollTo({ top: 0, behavior: "smooth" });
+ }}
+ refreshing={refreshing}
+ onRefresh={() => {
+ list.current?.scrollTo({ top: 0, behavior: "smooth" });
+ setRefreshing(true);
+ window.setTimeout(() => {
+ setPosts(fixtureCards(FIXTURE_FEED_ITEMS));
+ setRefreshing(false);
+ }, 600);
+ }}
+ onToggleLike={toggleLike}
+ feedUserId={FIXTURE_PERSON.feedUserId}
+ onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
+ composer={{
+ value: draft,
+ busy: false,
+ error: false,
+ file,
+ onChange: setDraft,
+ onFile: setFile,
+ // No network here: "publishing" just clears the composer.
+ onSubmit: () => {
+ setDraft("");
+ setFile(null);
+ },
+ person: FIXTURE_PERSON,
+ }}
+ now={FIXTURE_NOW}
+ />
+ }
+ discover={ }
+ />
+ >
+ );
+}
diff --git a/src/dev/fixtures.ts b/src/dev/fixtures.ts
new file mode 100644
index 0000000..8d90146
--- /dev/null
+++ b/src/dev/fixtures.ts
@@ -0,0 +1,316 @@
+// Fixture data for the dev-only preview routes (/dev/feed). Nothing here is
+// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
+// so the preview never calls the network. Later tasks extend this file.
+import type { Person } from "@/lib/auth-client";
+import { toCardPosts, type CardPost } from "@/lib/feed-model";
+import type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
+import type { ExpertResult } from "@/lib/experts-search";
+
+/** Fixed "now" so relative times in the preview are stable. */
+export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
+
+/** A gradient placeholder image (16:9 unless a size is given), drawn with hsl() so no fetch happens. */
+export function placeholderImage(hue: number, label = "", width = 1280, height = 720): string {
+ const svg =
+ `` +
+ `` +
+ ` ` +
+ ` ` +
+ (label
+ ? `${label} `
+ : "") +
+ ` `;
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+/** Not a real file: preload is off, so the preview video shows only its poster, play button and length. */
+const FIXTURE_VIDEO_URL = "https://cdn.feed.sharedservices.vt.com/dev-fixture.mp4";
+
+/** Fixture videos with real (tiny) files in public/dev: the feed only accepts CDN-host mp4 URLs, so these are swapped to local paths after parsing. */
+const FIXTURE_LANDSCAPE_URL = "https://cdn.feed.sharedservices.vt.com/dev-landscape.mp4";
+const FIXTURE_PORTRAIT_URL = "https://cdn.feed.sharedservices.vt.com/dev-portrait.mp4";
+const LOCAL_FIXTURE_VIDEOS: Record = {
+ [FIXTURE_LANDSCAPE_URL]: "/dev/landscape.mp4",
+ [FIXTURE_PORTRAIT_URL]: "/dev/portrait.mp4",
+};
+
+/** toCardPosts for the preview, with the fixture video URLs pointed at the local files. */
+export function fixtureCards(items: unknown[]): CardPost[] {
+ return toCardPosts(items).map((p) => ({
+ ...p,
+ media: p.media.map((m) => (m.mp4Url && LOCAL_FIXTURE_VIDEOS[m.mp4Url] ? { ...m, mp4Url: LOCAL_FIXTURE_VIDEOS[m.mp4Url] } : m)),
+ }));
+}
+
+/** Comments for the preview, in the shape /api/posts/[id]/comments returns. p2 holds a removed one; p4 has none. */
+export const FIXTURE_COMMENTS: Record = {
+ p1: [{ id: "c1", content: "Congratulations, the flow reads well.", createdAt: "2026-10-19T11:50:00Z", author: { displayName: "Priya Natarajan" } }],
+ p2: [
+ { id: "c2", content: "Number two changed how I price renewals.", createdAt: "2026-10-19T10:40:00Z", author: { displayName: "Marcus Bell" } },
+ { id: "c3", content: "removed text", deletedAt: "2026-10-19T11:00:00Z", createdAt: "2026-10-19T10:55:00Z", author: { displayName: "Elena Kovac" } },
+ { id: "c4", content: "Saving this for the next pricing review.", createdAt: "2026-10-19T11:30:00Z", author: { displayName: "Samuel Okafor" } },
+ ],
+};
+
+export const FIXTURE_FEED_USER_ID = "dev-user-1";
+
+export const FIXTURE_PERSON: Person = {
+ name: "Jordan Rivera",
+ email: "jordan.rivera@example.com",
+ pictureUrl: null,
+ feedUserId: FIXTURE_FEED_USER_ID,
+};
+
+const author = (id: string, displayName: string, picture: string | null = null) => ({
+ id,
+ displayName,
+ profilePictureUrl: picture,
+});
+
+/** Raw feed items in the shape /api/feed returns; run them through toCardPosts. */
+export const FIXTURE_FEED_ITEMS: unknown[] = [
+ {
+ type: "post",
+ publishedAt: "2026-10-19T11:42:00Z",
+ likesCount: 4,
+ commentsCount: 1,
+ post: {
+ id: "p1",
+ content: "Shipped the new onboarding flow this morning. The best feedback so far came from a five minute call, not a survey.",
+ author: author(FIXTURE_FEED_USER_ID, "Jordan Rivera"),
+ mediaItems: [],
+ },
+ },
+ {
+ // Medium text-only post: too long for 31px, fits at 26px or 22px with no "Read more".
+ type: "post",
+ publishedAt: "2026-10-19T11:30:00Z",
+ likesCount: 12,
+ commentsCount: 0,
+ post: {
+ id: "p10",
+ content: "The best advice I got as a new manager: your job is not to have the answers. It is to make sure the right question gets asked in the right room, by the person closest to the customer, early enough that the answer still changes what we build. Everything else, the rituals and the dashboards and the planning decks, exists to protect that one habit. When a meeting ends without a better question than it started with, it was a status update, and those can be an email.",
+ author: author("a10", "Grace Whitfield"),
+ mediaItems: [],
+ },
+ },
+ {
+ // Very long text-only post: does not fit at 19px, so it clamps and ends with "Read more".
+ type: "post",
+ publishedAt: "2026-10-19T11:10:00Z",
+ likesCount: 87,
+ commentsCount: 0,
+ post: {
+ id: "p11",
+ content: "I spent ten years building products before I learned the most useful habit of all: write down what you expect to happen before you ship, then check it a month later.\n\nMost teams skip the first half. They launch, watch a dashboard for a week and call whatever they see a win. Without the written guess there is nothing to be wrong about, so nobody learns.\n\nHere is the routine we use now. Every change gets one sentence: who it is for, what they will do differently, and the number that will move if we are right. The sentence goes in the ticket, not in a slide.\n\nThirty days later the person who wrote it reads it out loud in the weekly review and says what actually happened. No blame, no speeches. Just the guess, the result and one line on why they differ.\n\nAfter a quarter you get something no analytics tool gives you: a record of how good your team is at predicting its own customers. Ours was humbling. We were right about a third of the time.\n\nThat number changed how we plan. We now run smaller bets, we talk to five customers before any big build, and we kill features faster when the guess was wrong.\n\nIf you try one thing this month, try the sentence. It takes two minutes and it will teach you more than the next roadmap offsite.",
+ author: author("a11", "Daniel Moreau"),
+ mediaItems: [],
+ },
+ },
+ {
+ // Photo post with a caption longer than three lines: "more" / "less".
+ type: "post",
+ publishedAt: "2026-10-19T10:50:00Z",
+ likesCount: 64,
+ commentsCount: 0,
+ post: {
+ id: "p12",
+ content: "Notes from the panel on hiring a first sales lead, since a few people asked. Hire for the next eighteen months, not the next five years: the person who closes your first fifty customers is rarely the one who builds a team of twenty. Ask every candidate to walk you through a deal they lost and what they changed afterwards. Give them real numbers in the interview, including the bad ones, and watch how they react. Pay them on outcomes you can measure from day one, and write down what success looks like at thirty, sixty and ninety days. Last point: the founder keeps selling until the new lead has closed at least three deals alone. Handing over too early was the most common regret in the room.",
+ author: author("a12", "Aiko Tanaka"),
+ mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(48, "Sales panel", 1080, 1350), order: 0 }],
+ },
+ },
+ {
+ // Video post with a long caption.
+ type: "post",
+ publishedAt: "2026-10-19T10:30:00Z",
+ likesCount: 39,
+ commentsCount: 0,
+ post: {
+ id: "p13",
+ content: "Full recording of the Thursday session on pricing experiments. Chapters: why we test price at all, how to pick the first segment, the three-week test plan, what to tell existing customers, reading the results without fooling yourself, and the questions from the audience at the end. Slides and the spreadsheet template are linked in the comments. If you only have five minutes, skip to the part on reading results: that is where most teams get it wrong, and where we lost two months last year before we noticed.",
+ author: author("a13", "Rafael Duarte"),
+ mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(230, "Pricing session"), mp4Url: FIXTURE_LANDSCAPE_URL, videoDuration: 2, order: 0 }],
+ },
+ },
+ {
+ type: "post",
+ publishedAt: "2026-10-19T10:15:00Z",
+ likesCount: 128,
+ commentsCount: 23,
+ isLiked: true,
+ post: {
+ id: "p2",
+ content: "Three questions I ask before every pricing change:\n• Who pays more?\n• Who leaves?\n• What do we learn either way?",
+ author: author("a2", "Priya Natarajan"),
+ mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(352, "Pricing review"), order: 0 }],
+ },
+ },
+ {
+ type: "post",
+ publishedAt: "2026-10-19T08:30:00Z",
+ likesCount: 56,
+ commentsCount: 7,
+ post: {
+ id: "p3",
+ content: "A short walkthrough of how we run weekly reviews with a remote team.",
+ author: author("a3", "Marcus Bell"),
+ mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
+ },
+ },
+ {
+ type: "post",
+ publishedAt: "2026-10-19T07:50:00Z",
+ likesCount: 31,
+ commentsCount: 4,
+ post: {
+ id: "p8",
+ content: "Landscape recording of the quarterly walkthrough. It should show whole, centered, over a blurred copy of its own thumbnail.",
+ author: author("a8", "Tomas Reyes"),
+ mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(180, "Landscape 16:9"), mp4Url: FIXTURE_LANDSCAPE_URL, videoDuration: 2, order: 0 }],
+ },
+ },
+ {
+ type: "post",
+ publishedAt: "2026-10-19T07:20:00Z",
+ likesCount: 44,
+ commentsCount: 5,
+ post: {
+ id: "p9",
+ content: "Portrait clip shot on a phone. It should fill the whole slide.",
+ author: author("a9", "Ines Moreau"),
+ mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(300, "Portrait 9:16", 720, 1280), mp4Url: FIXTURE_PORTRAIT_URL, videoDuration: 2, order: 0 }],
+ },
+ },
+ {
+ type: "post",
+ publishedAt: "2026-10-18T21:20:00Z",
+ 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",
+ publishedAt: "2026-10-18T19:05:00Z",
+ likesCount: 9,
+ commentsCount: 0,
+ post: {
+ id: "p4",
+ content: "Reading list for anyone moving from engineering into product: start with the customer interviews, then the numbers.",
+ author: author("a4", "Elena Kovac"),
+ mediaItems: [],
+ },
+ },
+ {
+ type: "post",
+ publishedAt: "2026-10-18T14:40:00Z",
+ likesCount: 312,
+ commentsCount: 41,
+ post: {
+ id: "p5",
+ content: "Fundraising tip: send the update even when the month was bad. Especially when the month was bad.",
+ author: author("a5", "Samuel Okafor"),
+ mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(30, "Monthly update"), order: 0 }],
+ },
+ },
+ {
+ type: "post",
+ publishedAt: "2026-10-17T09:00:00Z",
+ likesCount: 17,
+ commentsCount: 3,
+ post: {
+ id: "p6",
+ content: "Your first ten customers will tell you what the product is. Listen before you build the next feature.",
+ author: author("a6", "Hannah Lindqvist"),
+ mediaItems: [],
+ },
+ },
+];
+
+const expert = (
+ id: string,
+ displayName: string,
+ byline: string,
+ rating: number,
+ reviewsCount: number,
+ pictureUrl: string | null = null,
+): DiscoverExpert => ({
+ id,
+ slug: displayName.replace(/\s+/g, ""),
+ displayName,
+ byline,
+ pictureUrl,
+ rating,
+ reviewsCount,
+ available: true,
+ location: { city: null, country: null },
+});
+
+export const FIXTURE_FEATURED: DiscoverExpert[] = [
+ expert("e1", "Priya Natarajan", "Pricing and growth advisor", 4.9, 212),
+ expert("e2", "Marcus Bell", "Remote team coach", 4.8, 96),
+ expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
+ expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
+ expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
+ // Photo placeholders exercise the image path of the featured carousel; the
+ // rest show initials.
+ expert("e9", "Leila Haddad", "Marketplace operations", 4.9, 181, placeholderImage(10)),
+ expert("e10", "Tomas Ferreira", "Hiring and team design", 4.7, 64, placeholderImage(190)),
+ expert("e11", "Grace Whitfield", "Brand and storytelling", 4.8, 102, placeholderImage(300)),
+];
+
+const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
+ id,
+ question,
+ views,
+ thumbnailUrl: placeholderImage(hue),
+ media: id === "m2" ? { kind: "video", url: "/fixtures/answer.mp4", duration: 42, waveform: null } : id === "m1" || id === "m4" ? { kind: "audio", url: "/fixtures/answer.mp3", duration: 31, waveform: null } : null,
+ expert: { slug: who.slug, displayName: who.displayName, pictureUrl: null, byline: who.byline, rating: who.rating },
+});
+
+export const FIXTURE_TRENDING: DiscoverMinnect[] = [
+ minnect("m1", FIXTURE_FEATURED[0], "How do I raise prices without losing my best customers?", 12840, 352),
+ minnect("m2", FIXTURE_FEATURED[3], "What should a pre-seed deck show in the first three slides?", 9310, 30),
+ minnect("m3", FIXTURE_FEATURED[1], "How often should a remote team meet in person?", 6120, 210),
+ minnect("m4", FIXTURE_FEATURED[2], "When is the right time to hire a first product manager?", 4470, 270),
+ minnect("m5", FIXTURE_FEATURED[4], "How many customer interviews are enough before building?", 2015, 150),
+];
+
+export const FIXTURE_CATEGORIES: DiscoverCategory[] = [
+ { id: 1, name: "Business", icon: null, sortOrder: 1 },
+ { id: 2, name: "Coaching", icon: null, sortOrder: 2 },
+ { id: 3, name: "Finance", icon: null, sortOrder: 3 },
+ { id: 4, name: "Product", icon: null, sortOrder: 4 },
+ { id: 5, name: "Media", icon: null, sortOrder: 5 },
+];
+
+export const FIXTURE_DISCOVER: DiscoverData = {
+ featuredExperts: FIXTURE_FEATURED,
+ trendingMinnects: FIXTURE_TRENDING,
+ categories: FIXTURE_CATEGORIES,
+};
+
+/** Find experts results: every featured expert plus a few more, filterable by name. */
+export const FIXTURE_SEARCH_RESULTS: ExpertResult[] = [
+ ...FIXTURE_FEATURED,
+ expert("e6", "Daniel Moreau", "Sales operations", 4.5, 21),
+ expert("e7", "Aiko Tanaka", "Brand strategy", 4.8, 77),
+ expert("e8", "Rafael Duarte", "Engineering management", 4.4, 18),
+].map((e) => ({
+ id: e.id,
+ slug: e.slug,
+ displayName: e.displayName,
+ byline: e.byline,
+ pictureUrl: e.pictureUrl,
+ rating: e.rating,
+ categories: [],
+}));
diff --git a/src/lib/autoplay.ts b/src/lib/autoplay.ts
new file mode 100644
index 0000000..e3478db
--- /dev/null
+++ b/src/lib/autoplay.ts
@@ -0,0 +1,24 @@
+/**
+ * Mobile feed autoplay: the session's mute choice and how a failed play() is read.
+ * The choice lives in memory only, for this page session: videos start muted
+ * (browsers only autoplay muted video) until the person unmutes one.
+ */
+let sessionMuted = true;
+
+/** True until the person unmutes a video in this session. */
+export function getSessionMuted(): boolean {
+ return sessionMuted;
+}
+
+/** Remember the person's mute choice for the videos that play after this one. */
+export function setSessionMuted(muted: boolean): void {
+ sessionMuted = muted;
+}
+
+/**
+ * True when play() was refused by the browser's autoplay policy. A play() that
+ * was cut short by pause() (AbortError) is not a refusal.
+ */
+export function isAutoplayBlocked(error: unknown): boolean {
+ return !!error && typeof error === "object" && (error as { name?: unknown }).name === "NotAllowedError";
+}
diff --git a/src/lib/feed-model.ts b/src/lib/feed-model.ts
index a19feb1..eaf0d82 100644
--- a/src/lib/feed-model.ts
+++ b/src/lib/feed-model.ts
@@ -9,6 +9,18 @@ export interface CardMedia {
hlsUrl?: string | null;
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
mp4Url?: string | null;
+ /** Video length in whole seconds, when the feed reports it. */
+ durationSeconds?: number | null;
+}
+
+/** "m:ss" for a duration in seconds, or null when it is missing or not positive. */
+export function formatDuration(seconds: number | null | undefined): string | null {
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return null;
+ const total = Math.round(seconds);
+ const h = Math.floor(total / 3600);
+ const m = Math.floor((total % 3600) / 60);
+ const s = String(total % 60).padStart(2, "0");
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
export interface CardPost {
@@ -92,7 +104,8 @@ function toMedia(raw: unknown): CardMedia[] {
const hls = muxHlsUrl(m.hlsUrl);
const mp4 = feedMp4Url(m.mp4Url);
// Keep a video even with no thumbnail or playable URL yet (just published).
- out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4 });
+ const dur = [m.videoDuration, m.durationSeconds, m.duration].find((v): v is number => typeof v === "number" && v > 0) ?? null;
+ out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4, ...(dur ? { durationSeconds: dur } : {}) });
continue;
}
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
@@ -181,6 +194,16 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
return changed ? next : current;
}
+/** A failed first-page load: a refresh keeps the posts and flags an inline error; an initial load shows the full error state. */
+export function firstLoadFailure(refresh: boolean): { fullError: boolean; refreshFailed: boolean } {
+ return { fullError: !refresh, refreshFailed: refresh };
+}
+
+/** "1 comment", "0 comments", "2 comments". */
+export function commentsLabel(count: number): string {
+ return count === 1 ? "1 comment" : `${count} comments`;
+}
+
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
const t = publishedAt ? Date.parse(publishedAt) : NaN;
if (!Number.isFinite(t)) return "";
diff --git a/src/lib/theme.ts b/src/lib/theme.ts
new file mode 100644
index 0000000..90258ce
--- /dev/null
+++ b/src/lib/theme.ts
@@ -0,0 +1,51 @@
+// Theme helper (ADR 0001, section 5). The theme is `data-theme` on ,
+// stored in localStorage under THEME_KEY. Dark is the default.
+
+export type Theme = "dark" | "light";
+
+export const THEME_KEY = "theme";
+export const DEFAULT_THEME: Theme = "dark";
+
+type ReadableStorage = Pick;
+type WritableStorage = Pick;
+type ThemeRoot = { dataset: DOMStringMap; style: { colorScheme: string } };
+
+export function isTheme(value: unknown): value is Theme {
+ return value === "dark" || value === "light";
+}
+
+/** The stored theme, or dark when nothing valid is stored or storage throws. */
+export function readStoredTheme(storage: ReadableStorage | null | undefined): Theme {
+ try {
+ const value = storage?.getItem(THEME_KEY);
+ return isTheme(value) ? value : DEFAULT_THEME;
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
+
+/** The theme currently on the root element, or dark when it is missing. */
+export function currentTheme(root: ThemeRoot | null | undefined): Theme {
+ const value = root?.dataset.theme;
+ return isTheme(value) ? value : DEFAULT_THEME;
+}
+
+/**
+ * Set the theme on the root element and store it. A failed write still
+ * changes the theme for this session.
+ */
+export function applyTheme(theme: Theme, root: ThemeRoot, storage: WritableStorage | null | undefined): void {
+ root.dataset.theme = theme;
+ root.style.colorScheme = theme;
+ try {
+ storage?.setItem(THEME_KEY, theme);
+ } catch {
+ // Storage blocked or full: keep the theme for this session only.
+ }
+}
+
+/**
+ * Inline script for the first child of . It runs before first paint, so
+ * a stored light theme never flashes dark. Fixed string, no user input.
+ */
+export const THEME_SCRIPT = `(function(){var t="${DEFAULT_THEME}";try{var s=localStorage.getItem("${THEME_KEY}");if(s==="light"||s==="dark")t=s;}catch(e){}var r=document.documentElement;r.dataset.theme=t;r.style.colorScheme=t;})();`;
diff --git a/src/lib/use-theme.ts b/src/lib/use-theme.ts
new file mode 100644
index 0000000..11ac027
--- /dev/null
+++ b/src/lib/use-theme.ts
@@ -0,0 +1,44 @@
+"use client";
+
+import { useCallback, useSyncExternalStore } from "react";
+import { applyTheme, currentTheme, DEFAULT_THEME, THEME_KEY, type Theme } from "./theme";
+
+const listeners = new Set<() => void>();
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ const onStorage = (event: StorageEvent) => {
+ // Another tab changed the theme: follow it.
+ if (event.key !== THEME_KEY) return;
+ const next = event.newValue === "light" || event.newValue === "dark" ? event.newValue : DEFAULT_THEME;
+ applyTheme(next, document.documentElement, null);
+ listeners.forEach((l) => l());
+ };
+ window.addEventListener("storage", onStorage);
+ return () => {
+ listeners.delete(listener);
+ window.removeEventListener("storage", onStorage);
+ };
+}
+
+const getSnapshot = (): Theme => currentTheme(document.documentElement);
+const getServerSnapshot = (): Theme => DEFAULT_THEME;
+
+function safeStorage(): Storage | null {
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+/** Shared theme state for every toggle: the current theme and a setter. */
+export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void } {
+ const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+ const setTheme = useCallback((next: Theme) => {
+ applyTheme(next, document.documentElement, safeStorage());
+ listeners.forEach((l) => l());
+ }, []);
+ const toggleTheme = useCallback(() => setTheme(getSnapshot() === "dark" ? "light" : "dark"), [setTheme]);
+ return { theme, setTheme, toggleTheme };
+}
diff --git a/src/styles/avatar.css b/src/styles/avatar.css
new file mode 100644
index 0000000..c050b97
--- /dev/null
+++ b/src/styles/avatar.css
@@ -0,0 +1,28 @@
+/* HexAvatar (ADR 0001, section 6). Pointy-top hexagon, width to height 0.866. */
+.hex-avatar {
+ display: block;
+ flex: none;
+ object-fit: cover;
+ background: var(--tile-3);
+ clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
+}
+.hex-avatar-lg { width: 46px; height: 53px; }
+.hex-avatar-md { width: 38px; height: 44px; }
+.hex-avatar-sm { width: 30px; height: 35px; }
+
+.hex-avatar-initials {
+ display: grid;
+ place-items: center;
+ color: var(--avatar-ink);
+ font-weight: 600;
+ letter-spacing: .02em;
+}
+.hex-avatar-lg.hex-avatar-initials { font-size: 16px; }
+.hex-avatar-md.hex-avatar-initials { font-size: 14px; }
+.hex-avatar-sm.hex-avatar-initials { font-size: 11px; }
+.hex-avatar-oxblood { background: var(--hex-oxblood); }
+.hex-avatar-navy { background: var(--hex-navy); }
+.hex-avatar-forest { background: var(--hex-forest); }
+.hex-avatar-slate { background: var(--hex-slate); }
+.hex-avatar-plum { background: var(--hex-plum); }
+.hex-avatar-graphite { background: var(--hex-graphite); }
diff --git a/src/styles/buttons.css b/src/styles/buttons.css
new file mode 100644
index 0000000..d62253f
--- /dev/null
+++ b/src/styles/buttons.css
@@ -0,0 +1,509 @@
+/*
+ * Button system (spec "Buttons", ADR 0001 sections 6 to 8).
+ * Five styles plus the glossy crimson icon button, ported from the mockup's
+ * recolored rules. Tokens only; white and black appear only on crimson faces.
+ * Every class sets margin, padding, font and color itself so no global
+ * `button` rule, such as `button { color: inherit }`, can leak in.
+ */
+
+/* Shared base for every style. */
+.btn-flip,
+.btn-grow,
+.btn-sweep,
+.btn-depth,
+.btn-spark,
+.btn-gloss {
+ appearance: none;
+ margin: 0;
+ border: 0;
+ font-family: inherit;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+ isolation: isolate;
+ opacity: 1;
+ -webkit-tap-highlight-color: transparent;
+}
+.btn-flip svg,
+.btn-grow svg,
+.btn-depth svg,
+.btn-spark svg,
+.btn-gloss svg { flex: none; }
+
+/* 1. Flip reveal: Post. Ready, disabled ("write first") and busy ("posting"). */
+.btn-flip {
+ height: 42px;
+ min-width: 118px;
+ padding: 0 22px;
+ border-radius: var(--r-ctl);
+ background: var(--ink);
+ color: var(--bg);
+ font-size: 13px;
+ font-weight: 800;
+ letter-spacing: .14em;
+ text-transform: uppercase;
+ box-shadow: var(--shadow-btn);
+ transition: transform .3s cubic-bezier(.23,1,.32,1);
+}
+.btn-flip .btn-flip-front {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ height: 100%;
+ color: var(--bg);
+ transition: transform .6s cubic-bezier(.23,1,.32,1);
+}
+.btn-flip .btn-flip-back {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--on-accent);
+ background: linear-gradient(135deg, var(--accent-deep), var(--accent));
+ transform: translateY(100%);
+ transition: transform .6s cubic-bezier(.23,1,.32,1);
+}
+.btn-flip svg { width: 16px; height: 16px; }
+.btn-flip:hover .btn-flip-front,
+.btn-flip:focus-visible .btn-flip-front { transform: translateY(-200%); }
+.btn-flip:hover .btn-flip-back,
+.btn-flip:focus-visible .btn-flip-back { transform: none; }
+.btn-flip:active:not(:disabled) { transform: scale(.95); }
+
+/* Disabled: solid graphite face, readable text, never faded. */
+.btn-flip:disabled {
+ opacity: 1;
+ cursor: not-allowed;
+ background: var(--tile-3);
+ color: var(--ink-2);
+ box-shadow: inset 0 0 0 1px var(--line-2);
+}
+.btn-flip:disabled .btn-flip-front { color: var(--ink-2); }
+.btn-flip:disabled .btn-flip-back {
+ background: var(--tile-3);
+ color: var(--ink);
+ font-size: 12px;
+}
+
+/* Busy (posting): keeps the ink face, shows "Posting", never flips. */
+.btn-flip.btn-flip-busy:disabled {
+ cursor: progress;
+ background: var(--ink);
+ color: var(--bg);
+ box-shadow: var(--shadow-btn);
+}
+.btn-flip.btn-flip-busy:disabled .btn-flip-front { color: var(--bg); transform: none; }
+.btn-flip.btn-flip-busy .btn-flip-back { transform: translateY(100%); }
+
+/* 2. Circle expand: the new-posts pill. */
+.btn-grow {
+ display: block;
+ width: 178px;
+ height: 42px;
+ padding: 0;
+ border-radius: 999px;
+ background: var(--glass);
+ color: var(--ink);
+ -webkit-backdrop-filter: blur(16px);
+ backdrop-filter: blur(16px);
+ box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
+}
+.btn-grow .btn-grow-circle {
+ position: absolute;
+ left: 4px;
+ top: 4px;
+ bottom: 4px;
+ width: 34px;
+ border-radius: 999px;
+ background: linear-gradient(180deg, var(--accent-hi), var(--accent) 55%, var(--accent-lo));
+ transition: width .45s cubic-bezier(.65,0,.076,1);
+}
+.btn-grow .btn-grow-circle svg {
+ position: absolute;
+ left: 9px;
+ top: 9px;
+ width: 16px;
+ height: 16px;
+ color: var(--on-accent);
+ transition: transform .45s cubic-bezier(.65,0,.076,1);
+}
+.btn-grow .btn-grow-text {
+ position: relative;
+ z-index: 1;
+ display: block;
+ padding-left: 40px;
+ line-height: 42px;
+ font-size: 11.5px;
+ font-weight: 800;
+ letter-spacing: .16em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ color: var(--ink);
+ transition: color .45s cubic-bezier(.65,0,.076,1);
+}
+.btn-grow:hover .btn-grow-circle,
+.btn-grow:focus-visible .btn-grow-circle { width: calc(100% - 8px); }
+.btn-grow:hover .btn-grow-circle svg,
+.btn-grow:focus-visible .btn-grow-circle svg { transform: translateY(-3px); }
+.btn-grow:hover .btn-grow-text,
+.btn-grow:focus-visible .btn-grow-text { color: var(--on-accent); }
+.btn-grow:active { transform: scale(.97); }
+
+/* 3. Outline fill sweep: category chips and Cancel. Selected stays filled. */
+.btn-sweep {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 34px;
+ padding: 0 15px;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--ink);
+ font-size: 12.5px;
+ font-weight: 600;
+ white-space: nowrap;
+ box-shadow: inset 0 0 0 1.5px var(--line-2);
+ transition: color .3s, box-shadow .3s, transform .15s;
+}
+.btn-sweep::before {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ width: 170px;
+ height: 170px;
+ border-radius: 50%;
+ background: var(--ink);
+ top: 100%;
+ left: 100%;
+ transition: top .35s var(--ease), left .35s var(--ease);
+}
+.btn-sweep:hover,
+.btn-sweep:focus-visible,
+.btn-sweep.btn-sweep-on {
+ color: var(--bg);
+ box-shadow: inset 0 0 0 1.5px var(--ink);
+}
+.btn-sweep:hover::before,
+.btn-sweep:focus-visible::before,
+.btn-sweep.btn-sweep-on::before { top: -70px; left: -40px; }
+.btn-sweep:active { transform: scale(.95); }
+
+/* 4. Soft depth, rising liquid: mobile More sheet rows and the theme switch. */
+.btn-depth {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ width: 100%;
+ padding: 14px;
+ border-radius: var(--r-card);
+ background: var(--bg);
+ color: var(--ink);
+ text-align: left;
+ font-size: 14px;
+ box-shadow: 6px 6px 14px var(--neu-d), -6px -6px 14px var(--neu-l);
+ transition: color .3s, box-shadow .25s;
+}
+.btn-depth::before,
+.btn-depth::after {
+ content: "";
+ position: absolute;
+ width: 140%;
+ height: 240%;
+ border-radius: 50%;
+ z-index: -1;
+ transition: all .5s .05s cubic-bezier(.55,0,.1,1);
+}
+.btn-depth::before {
+ left: 50%;
+ top: 100%;
+ transform: translateX(-50%) scaleX(1.25);
+ background: color-mix(in srgb, var(--accent), transparent 82%);
+}
+.btn-depth::after {
+ left: 55%;
+ top: 180%;
+ transform: translateX(-50%) scaleX(1.45);
+ background: var(--accent);
+}
+.btn-depth .btn-depth-icon {
+ width: 44px;
+ height: 44px;
+ flex: none;
+ display: grid;
+ place-items: center;
+ border-radius: var(--r-ctl);
+ background: var(--tile-2);
+ color: var(--ink);
+ transition: background-color .3s, color .3s;
+}
+.btn-depth .btn-depth-icon svg { width: 20px; height: 20px; }
+.btn-depth .btn-depth-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.btn-depth .btn-depth-label { font-size: 15px; font-weight: 700; color: inherit; }
+.btn-depth .btn-depth-sub { font-size: 12.5px; font-weight: 500; color: var(--ink-3); transition: color .3s; }
+.btn-depth:hover,
+.btn-depth:focus-visible { color: var(--on-accent); }
+.btn-depth:hover::before,
+.btn-depth:focus-visible::before {
+ top: -35%;
+ background: var(--accent);
+ transform: translateX(-50%) scaleY(1.3) scaleX(.8);
+}
+.btn-depth:hover::after,
+.btn-depth:focus-visible::after { top: -45%; transform: translateX(-50%) scaleY(1.3) scaleX(.8); }
+.btn-depth:hover .btn-depth-sub,
+.btn-depth:focus-visible .btn-depth-sub { color: color-mix(in srgb, white 82%, transparent); }
+.btn-depth:hover .btn-depth-icon,
+.btn-depth:focus-visible .btn-depth-icon { background: color-mix(in srgb, white 16%, transparent); color: var(--on-accent); }
+.btn-depth:active { box-shadow: inset 4px 4px 12px var(--neu-d), inset -4px -4px 12px var(--neu-l); }
+
+/* 5. Sparkle glow: Sign in (fold + hexagon) and the mobile new-post button (plus). */
+.btn-spark {
+ --spark-round: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 180px;
+ height: 50px;
+ padding: 0 22px;
+ border-radius: var(--spark-round);
+ color: var(--on-accent);
+ background:
+ radial-gradient(65% 65% at 50% 100%, var(--accent-glow) 0%, var(--accent-glow-0) 100%),
+ linear-gradient(0deg, var(--accent), var(--accent));
+ box-shadow: 0 18px 36px -14px color-mix(in srgb, var(--accent), transparent 15%);
+ transition: transform .25s;
+}
+.btn-spark::before,
+.btn-spark::after {
+ content: "";
+ position: absolute;
+ inset: var(--spark-inset);
+ border-radius: calc(var(--spark-round) - var(--spark-inset));
+ z-index: 0;
+ transition: all .5s ease-in-out;
+}
+.btn-spark::before {
+ --spark-inset: 1px;
+ background: linear-gradient(178deg, color-mix(in srgb, white 24%, transparent) 0%, transparent 100%);
+}
+.btn-spark::after {
+ --spark-inset: 2px;
+ background:
+ radial-gradient(65% 65% at 50% 100%, var(--accent-glow) 0%, var(--accent-glow-0) 100%),
+ linear-gradient(0deg, var(--accent), var(--accent));
+}
+.btn-spark:active { transform: scale(.96); }
+
+.btn-spark .btn-spark-fold {
+ z-index: 1;
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 1rem;
+ height: 1rem;
+ transition: all .5s ease-in-out;
+ background: radial-gradient(100% 75% at 55%, var(--accent-glow) 0%, var(--accent-glow-0) 100%);
+ box-shadow: 0 0 3px black;
+ border-bottom-left-radius: .5rem;
+ border-top-right-radius: var(--spark-round);
+}
+.btn-spark .btn-spark-fold::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 150%;
+ height: 150%;
+ transform: rotate(45deg) translateY(-18px);
+ background: var(--bg);
+ pointer-events: none;
+}
+.btn-spark:hover .btn-spark-fold,
+.btn-spark:focus-visible .btn-spark-fold { margin-top: -1rem; margin-right: -1rem; }
+
+.btn-spark .btn-spark-pts { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 1; }
+.btn-spark .btn-spark-pts i {
+ position: absolute;
+ bottom: -10px;
+ width: 2px;
+ height: 2px;
+ border-radius: 50%;
+ background: var(--on-accent);
+ animation: btn-spark-rise infinite ease-in-out;
+}
+@keyframes btn-spark-rise {
+ 0% { transform: translateY(0); }
+ 85% { opacity: 0; }
+ 100% { transform: translateY(-60px); opacity: 0; }
+}
+.btn-spark .btn-spark-pts i:nth-child(1) { left: 10%; animation-duration: 2.35s; animation-delay: .2s; }
+.btn-spark .btn-spark-pts i:nth-child(2) { left: 30%; opacity: .7; animation-duration: 2.5s; animation-delay: .5s; }
+.btn-spark .btn-spark-pts i:nth-child(3) { left: 25%; opacity: .8; animation-duration: 2.2s; animation-delay: .1s; }
+.btn-spark .btn-spark-pts i:nth-child(4) { left: 44%; opacity: .6; animation-duration: 2.05s; }
+.btn-spark .btn-spark-pts i:nth-child(5) { left: 50%; animation-duration: 1.9s; }
+.btn-spark .btn-spark-pts i:nth-child(6) { left: 75%; opacity: .5; animation-duration: 1.5s; animation-delay: 1.5s; }
+.btn-spark .btn-spark-pts i:nth-child(7) { left: 88%; opacity: .9; animation-duration: 2.2s; animation-delay: .2s; }
+.btn-spark .btn-spark-pts i:nth-child(8) { left: 58%; opacity: .8; animation-duration: 2.25s; animation-delay: .2s; }
+.btn-spark .btn-spark-pts i:nth-child(9) { left: 96%; opacity: .6; animation-duration: 2.6s; animation-delay: .1s; }
+.btn-spark .btn-spark-pts i:nth-child(10) { left: 65%; animation-duration: 2.5s; animation-delay: .2s; }
+
+.btn-spark .btn-spark-inner {
+ position: relative;
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--on-accent);
+ font-weight: 700;
+ font-size: 15px;
+ letter-spacing: .06em;
+}
+.btn-spark .btn-spark-hex {
+ width: 18px;
+ height: 18px;
+ fill: transparent;
+ stroke: var(--on-accent);
+ stroke-width: 2.2;
+ stroke-linejoin: round;
+}
+.btn-spark:hover .btn-spark-hex,
+.btn-spark:focus-visible .btn-spark-hex {
+ animation: btn-spark-dash 1s linear forwards, btn-spark-fill .1s linear forwards .95s;
+}
+@keyframes btn-spark-dash { from { stroke-dasharray: 0 0 0 0; } to { stroke-dasharray: 68 68 0 0; } }
+@keyframes btn-spark-fill { to { fill: var(--on-accent); } }
+
+/* Mobile new-post variant: square, no fold, the plus turns 90 degrees. */
+.btn-spark.btn-spark-fab {
+ --spark-round: 16px;
+ min-width: 0;
+ width: 56px;
+ height: 56px;
+ padding: 0;
+ box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--accent), transparent 15%);
+}
+.btn-spark.btn-spark-fab::after { display: none; }
+.btn-spark.btn-spark-fab:active { transform: scale(.94); }
+.btn-spark .btn-spark-plus {
+ position: relative;
+ z-index: 2;
+ width: 24px;
+ height: 24px;
+ color: var(--on-accent);
+ transition: transform .5s cubic-bezier(.2,.9,.3,1.4);
+}
+.btn-spark:hover .btn-spark-plus,
+.btn-spark:focus-visible .btn-spark-plus { transform: rotate(90deg); }
+
+/* Glossy crimson icon button: send comment, delete confirm, play. */
+.btn-gloss {
+ display: inline-grid;
+ place-items: center;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border-radius: var(--r-ctl);
+ color: var(--on-accent);
+ background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo));
+ box-shadow:
+ inset 0 1px 0 color-mix(in srgb, white 32%, transparent),
+ inset 0 -1px 0 color-mix(in srgb, black 28%, transparent),
+ 0 0 0 1px var(--accent-lo),
+ 0 12px 26px -14px var(--accent);
+ transition: transform .18s var(--ease), box-shadow .3s;
+}
+.btn-gloss.btn-gloss-round { border-radius: 50%; }
+.btn-gloss.btn-gloss-sm { width: 36px; height: 36px; }
+.btn-gloss svg { width: 18px; height: 18px; color: var(--on-accent); }
+.btn-gloss::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: 50%;
+ background: linear-gradient(180deg, color-mix(in srgb, white 20%, transparent), transparent);
+ z-index: -1;
+}
+.btn-gloss::after {
+ content: "";
+ position: absolute;
+ top: -10%;
+ bottom: -10%;
+ width: 55%;
+ left: -90%;
+ background: linear-gradient(100deg, transparent, color-mix(in srgb, white 42%, transparent), transparent);
+ transform: skewX(-20deg);
+ transition: left .8s var(--ease);
+}
+.btn-gloss:hover::after,
+.btn-gloss:focus-visible::after { left: 140%; }
+.btn-gloss:active:not(:disabled) { transform: scale(.96); }
+/* Disabled keeps a solid face (no opacity) so the icon stays readable. */
+.btn-gloss:disabled {
+ opacity: 1;
+ cursor: not-allowed;
+ background: var(--tile-3);
+ color: var(--ink-2);
+ box-shadow: inset 0 0 0 1px var(--line-2);
+}
+.btn-gloss:disabled svg { color: var(--ink-2); }
+.btn-gloss:disabled::before,
+.btn-gloss:disabled::after { display: none; }
+
+/* Reduced motion: hover and press states still change color, but nothing moves. */
+@media (prefers-reduced-motion: reduce) {
+ .btn-flip,
+ .btn-flip *,
+ .btn-grow,
+ .btn-grow *,
+ .btn-sweep,
+ .btn-sweep::before,
+ .btn-depth,
+ .btn-depth *,
+ .btn-depth::before,
+ .btn-depth::after,
+ .btn-spark,
+ .btn-spark *,
+ .btn-spark::before,
+ .btn-spark::after,
+ .btn-gloss,
+ .btn-gloss::before,
+ .btn-gloss::after {
+ transition: none !important;
+ animation: none !important;
+ }
+ .btn-flip:active,
+ .btn-grow:active,
+ .btn-sweep:active,
+ .btn-spark:active,
+ .btn-gloss:active { transform: none !important; }
+ .btn-grow:hover .btn-grow-circle svg,
+ .btn-grow:focus-visible .btn-grow-circle svg,
+ .btn-spark:hover .btn-spark-plus,
+ .btn-spark:focus-visible .btn-spark-plus { transform: none !important; }
+ .btn-spark .btn-spark-pts { display: none; }
+ .btn-spark:hover .btn-spark-hex,
+ .btn-spark:focus-visible .btn-spark-hex { fill: var(--on-accent); }
+ .btn-gloss::after { display: none; }
+}
+
+/* Sparkle glow, disabled: a solid readable face, no glow, no sparks, no fade. */
+.btn-spark:disabled {
+ opacity: 1;
+ cursor: not-allowed;
+ background: var(--tile-3);
+ color: var(--ink-2);
+ box-shadow: inset 0 0 0 1px var(--line-2);
+ transform: none;
+}
+.btn-spark:disabled .btn-spark-inner,
+.btn-spark:disabled .btn-spark-hex { color: var(--ink-2); }
+.btn-spark:disabled .btn-spark-hex { stroke: var(--ink-2); animation: none; }
+.btn-spark:disabled::before,
+.btn-spark:disabled::after,
+.btn-spark:disabled .btn-spark-fold,
+.btn-spark:disabled .btn-spark-pts { display: none; }
diff --git a/src/styles/composer.css b/src/styles/composer.css
new file mode 100644
index 0000000..0028599
--- /dev/null
+++ b/src/styles/composer.css
@@ -0,0 +1,225 @@
+/*
+ * Composer, toolbar, emoji popover and attachment row (spec "Composer",
+ * ADR 0001). Ported from the mockup's .cmp, .bar, .emo and .att rules.
+ * Tokens only. The Post button is the flip reveal from buttons.css.
+ */
+.composer {
+ --composer-soft: color-mix(in srgb, var(--ink), transparent 93%);
+ position: relative;
+ border-radius: var(--r-card);
+ background: var(--tile-2);
+ box-shadow: inset 0 0 0 1px var(--line);
+ padding: 10px 10px 8px 12px;
+ display: flex;
+ flex-direction: column;
+ transition: box-shadow .3s, background-color .3s;
+}
+.composer.composer-open {
+ background: var(--tile);
+ box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
+}
+
+.composer-top { display: flex; gap: 12px; align-items: flex-start; }
+.composer-top .hex-avatar { flex: none; }
+.composer .composer-label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+.composer textarea {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ border: 0;
+ outline: 0;
+ resize: none;
+ background: transparent;
+ font: inherit;
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--ink);
+ padding: 12px 2px;
+ height: 50px;
+ transition: height .3s var(--ease);
+}
+.composer.composer-open textarea { height: 92px; }
+.composer textarea::placeholder { color: var(--ink-3); }
+.composer textarea:disabled { color: var(--ink-2); }
+
+/* Toolbar: photo, video, emoji | bulleted list ... Ctrl + Enter, Post. */
+.composer-bar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-top: 6px;
+ padding-top: 8px;
+ border-top: 1px solid var(--line);
+}
+.composer-tool {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--r-ctl);
+ display: inline-grid;
+ place-items: center;
+ color: var(--ink-2);
+ cursor: pointer;
+ flex: none;
+ transition: background-color .2s, color .2s, transform .15s;
+}
+.composer-tool svg { width: 20px; height: 20px; }
+.composer-tool:hover:not(:disabled),
+.composer-tool.composer-tool-on { background: var(--composer-soft); color: var(--ink); }
+.composer-tool:active:not(:disabled) { transform: scale(.9); }
+.composer-tool:disabled { cursor: not-allowed; color: var(--ink-3); background: transparent; }
+.composer-file {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ border: 0;
+}
+.composer-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 6px; flex: none; }
+.composer-spacer { flex: 1; }
+.composer-kbd { font-size: 11.5px; color: var(--ink-3); margin-right: 10px; white-space: nowrap; }
+.composer-kbd b {
+ font-weight: 700;
+ color: var(--ink-2);
+ padding: 1px 6px;
+ border-radius: 6px;
+ background: var(--tile-2);
+ box-shadow: inset 0 0 0 1px var(--line);
+}
+
+/* Emoji: a frosted popover with 32 frequent emoji. */
+.composer-emoji-wrap { position: relative; }
+.composer-emoji {
+ position: absolute;
+ z-index: 30;
+ top: calc(100% + 8px);
+ left: -8px;
+ width: 320px;
+ padding: 10px;
+ background: var(--glass);
+ -webkit-backdrop-filter: blur(20px) saturate(1.7);
+ backdrop-filter: blur(20px) saturate(1.7);
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ box-shadow: var(--shadow-pop);
+ animation: composer-pop .24s cubic-bezier(.2,.9,.3,1.25) both;
+}
+.composer-eyebrow {
+ display: block;
+ padding: 2px 6px 8px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .18em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+.composer-emoji-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; }
+.composer-emoji-grid button {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--ink);
+ height: 36px;
+ border-radius: 10px;
+ font-family: inherit;
+ font-size: 20px;
+ line-height: 1;
+ cursor: pointer;
+ transition: transform .15s cubic-bezier(.2,.9,.3,1.4), background-color .15s;
+}
+.composer-emoji-grid button:hover { background: var(--composer-soft); transform: scale(1.2); }
+
+/* Attachment row: thumbnail, name, type and size, progress, remove. */
+.composer-att {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 6px 0 2px;
+ padding: 8px;
+ border-radius: 14px;
+ background: var(--tile-2);
+ box-shadow: inset 0 0 0 1px var(--line);
+ animation: composer-rise .35s var(--ease) both;
+}
+.composer-att.composer-att-indent { margin-left: 58px; }
+.composer-thumb {
+ position: relative;
+ width: 56px;
+ height: 56px;
+ border-radius: 10px;
+ flex: none;
+ overflow: hidden;
+ background: var(--tile-3);
+}
+.composer-thumb img,
+.composer-thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
+.composer-thumb svg {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 20px;
+ height: 20px;
+ color: var(--on-accent);
+ filter: drop-shadow(0 1px 3px var(--accent-deep));
+}
+.composer-att-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
+.composer-att-text strong {
+ font-size: 13px;
+ color: var(--ink);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.composer-att-text span { font-size: 12px; color: var(--ink-3); }
+.composer-prog { height: 3px; border-radius: 3px; background: var(--tile-3); overflow: hidden; }
+.composer-prog i {
+ display: block;
+ height: 100%;
+ background: var(--accent);
+ transform-origin: left;
+ animation: composer-load 1.6s var(--ease) both;
+}
+/* While posting the upload has no byte count, so the bar sweeps. */
+.composer-prog.composer-prog-busy i { width: 40%; animation: composer-sweep 1.1s ease-in-out infinite; }
+
+.composer .error { margin: 8px 0 0; }
+
+/* The flip Post button keeps its own size inside the toolbar. */
+.composer-bar .composer-post { flex: none; }
+
+@keyframes composer-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
+@keyframes composer-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
+@keyframes composer-load { from { transform: scaleX(0); } to { transform: scaleX(1); } }
+@keyframes composer-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
+
+@media (prefers-reduced-motion: reduce) {
+ .composer,
+ .composer textarea,
+ .composer-tool,
+ .composer-emoji-grid button { transition: none !important; }
+ .composer-emoji,
+ .composer-att,
+ .composer-prog i { animation: none !important; }
+}
+
+/* Publish failure, above the toolbar. */
+.composer-error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
diff --git a/src/styles/featured.css b/src/styles/featured.css
new file mode 100644
index 0000000..e7885b0
--- /dev/null
+++ b/src/styles/featured.css
@@ -0,0 +1,116 @@
+/* Featured experts carousel (ADR 0001, section 6; spec "Featured experts
+ carousel"). Ported from the mockup's .cf rules; colors are tokens only.
+ --cfw is the center photo width, set by the component's size prop. */
+
+.featured { flex: none; }
+.featured-title { display: inline-flex; align-items: center; gap: 8px; }
+.featured-title svg { width: 16px; height: 16px; color: var(--accent); }
+.featured-count { font-variant-numeric: tabular-nums; }
+
+.featured-cf {
+ --cfw: 96px;
+ position: relative;
+ height: calc(var(--cfw) * 1.155 + 6px);
+ overflow: hidden;
+ overflow: clip;
+ overflow-clip-margin: 24px;
+}
+.featured-cf::before {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: calc(var(--cfw) * 1.9);
+ height: calc(var(--cfw) * 1.3);
+ transform: translate(-50%, -50%);
+ background: radial-gradient(closest-side, color-mix(in oklab, var(--accent), transparent 70%), transparent);
+ filter: blur(12px);
+ animation: featured-breathe 4s ease-in-out infinite;
+}
+@keyframes featured-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
+
+.featured-item {
+ position: absolute;
+ left: 50%;
+ top: 0;
+ width: var(--cfw);
+ margin-left: calc(var(--cfw) / -2);
+ transition: transform 1.1s var(--ease), opacity 1.1s, filter 1.1s;
+}
+.featured-ring {
+ display: block;
+ padding: 3px;
+ background: var(--line-2);
+ clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
+ transition: background .8s;
+}
+.featured-photo {
+ display: block;
+ width: 100%;
+ aspect-ratio: .866;
+ object-fit: cover;
+ clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
+}
+/* Photos sit on the tile while loading; initials keep their HexAvatar tone. */
+img.featured-photo { background: var(--tile-3); }
+.featured-initials {
+ display: grid;
+ place-items: center;
+ color: var(--avatar-ink);
+ font-weight: 600;
+ letter-spacing: .02em;
+ font-size: calc(var(--cfw) * .3);
+}
+
+.featured-c0 { z-index: 5; }
+.featured-c0 .featured-ring { background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo)); }
+.featured-r1 { transform: translateX(76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
+.featured-l1 { transform: translateX(-76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
+.featured-r2 { transform: translateX(132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
+.featured-l2 { transform: translateX(-132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
+.featured-r3 { transform: translateX(170%) scale(.3); opacity: 0; z-index: 1; }
+.featured-l3 { transform: translateX(-170%) scale(.3); opacity: 0; z-index: 1; }
+
+.featured-cap {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 3px;
+ padding: 12px 16px 2px;
+ animation: featured-cap .8s var(--ease) both;
+}
+@keyframes featured-cap { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
+.featured-name { font-size: 23px; line-height: 1.1; }
+.featured-by {
+ font-size: 12.5px;
+ color: var(--ink-3);
+ max-width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.featured-rate {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12.5px;
+ font-weight: 700;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+}
+.featured-rate > span { display: inline-flex; align-items: center; gap: 4px; }
+.featured-rate svg { width: 13px; height: 13px; color: var(--accent); }
+.featured-rate em { font-style: normal; font-weight: 500; color: var(--ink-3); }
+
+.featured-dots { display: flex; justify-content: center; gap: 5px; padding: 10px 0 16px; }
+.featured-dots-gap { height: 16px; }
+.featured-dot {
+ display: block;
+ width: 5px;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--line-2);
+ transition: width .5s var(--ease), background-color .5s;
+}
+.featured-dot-on { width: 18px; background: var(--accent); }
diff --git a/src/styles/feed.css b/src/styles/feed.css
new file mode 100644
index 0000000..e16d18a
--- /dev/null
+++ b/src/styles/feed.css
@@ -0,0 +1,72 @@
+/*
+ * Feed list: scroll snap, the new-posts pill and the loading-more skeleton
+ * (spec "Feed list", ADR 0001). Prefixed `feed-` classes, tokens only.
+ */
+.feed .feed-list { scroll-snap-type: y proximity; }
+
+.feed-message { text-align: center; color: var(--ink-3); padding: 32px 16px; }
+.feed-message button { margin-top: 8px; }
+.feed-center { display: grid; place-items: center; padding: 16px; }
+.sentinel { height: 1px; }
+
+.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* The pill floats at the top center of the list. The zero-height sticky
+ wrapper takes the entrance animation, so the button keeps its own transforms
+ (hover expand, press). */
+.feed-pill {
+ position: sticky;
+ top: 12px;
+ z-index: 4;
+ display: flex;
+ justify-content: center;
+ height: 0;
+ margin-bottom: -16px;
+ overflow: visible;
+ animation: feed-pill-in .45s var(--ease) backwards;
+}
+.feed-pill .btn-grow { flex: none; }
+@keyframes feed-pill-in { from { opacity: 0; transform: translateY(-10px) scale(.96); } to { opacity: 1; transform: none; } }
+
+/* Skeleton card: same footprint as a post, with a shimmer band sweeping over it. */
+.feed-skel {
+ flex: none;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px;
+ background: var(--tile);
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ scroll-snap-align: none;
+}
+.feed-skel::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--ink), transparent 90%) 50%, transparent 80%);
+ transform: translateX(-100%);
+ animation: feed-shimmer 1.4s ease-in-out infinite;
+}
+@keyframes feed-shimmer { to { transform: translateX(100%); } }
+.feed-skel-head { display: flex; align-items: center; gap: 12px; }
+.feed-skel-lines { display: flex; flex-direction: column; gap: 6px; flex: 1; }
+.feed-skel-dot { width: 40px; height: 40px; border-radius: 50%; background: var(--tile-3); flex: none; }
+.feed-skel-line { display: block; height: 12px; border-radius: 6px; background: var(--tile-3); }
+.feed-skel-w25 { width: 25%; }
+.feed-skel-w40 { width: 40%; }
+.feed-skel-w80 { width: 80%; }
+.feed-skel-w100 { width: 100%; }
+.feed-skel-media { display: block; aspect-ratio: 16 / 9; border-radius: 12px; background: var(--tile-3); }
+
+/* Refresh button in the feed header. Its disabled state keeps full opacity, so
+ the spinning icon is the only sign that a reload is running. */
+.refresh-icon { display: block; }
+.refresh-icon-spin { animation: spin .8s linear infinite; }
+@media (prefers-reduced-motion: reduce) {
+ .refresh-icon-spin { animation: none !important; }
+}
+.feed-refresh-error { margin-left: auto; font-size: 13px; color: var(--danger); }
diff --git a/src/styles/layout.css b/src/styles/layout.css
new file mode 100644
index 0000000..a6c86e2
--- /dev/null
+++ b/src/styles/layout.css
@@ -0,0 +1,212 @@
+/* Signed-in shell (spec "Desktop and tablet layout", ADR 0001 sections 3 and 6).
+ .app is the size container; .bento is its child, so container queries can
+ style it. The page never scrolls: only the panels' own lists do. */
+
+/* Session check: one centered line while the session loads. */
+.center { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
+
+.app {
+ container: app / inline-size;
+ height: 100vh;
+ height: 100dvh;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--ink);
+ transition: background-color .45s, color .45s;
+}
+
+/* Below 1001px the tablet and phone tasks own the layout. Until then the
+ panels stack in one column that scrolls inside the app frame. */
+.bento {
+ height: 100%;
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ overflow-y: auto;
+}
+
+.bento-col {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+ min-height: 0;
+}
+
+/* 761px to 1000px: the feed spans the full height on the left; the account and
+ search panels stack over the featured and trending panels on the right. */
+@container app (max-width: 1000px) and (min-width: 761px) {
+ .bento {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 300px;
+ grid-template-rows: auto minmax(0, 1fr);
+ overflow: hidden;
+ }
+ .bento-center { grid-column: 1; grid-row: 1 / 3; height: 100%; }
+ .bento-left { grid-column: 2; grid-row: 1; }
+ .bento-right { grid-column: 2; grid-row: 2; overflow-y: auto; overscroll-behavior: contain; }
+ .bento-left .expert-search { flex: 0 1 auto; max-height: 250px; }
+ .bento-right .rail-trending { flex: 1 0 200px; }
+ .bento .account-live { display: none; }
+}
+
+/* 1001px to 1180px: three columns, narrower sides. */
+@container app (min-width: 1001px) {
+ .bento {
+ display: grid;
+ grid-template-columns: 244px minmax(0, 1fr) 292px;
+ overflow: hidden;
+ }
+ .bento-col { height: 100%; }
+}
+
+/* 1181px and wider. */
+@container app (min-width: 1181px) {
+ .bento { grid-template-columns: 272px minmax(0, 1fr) 324px; }
+}
+
+/* Panels ----------------------------------------------------------------- */
+
+.tile {
+ position: relative;
+ min-height: 0;
+ background: var(--tile);
+ border: 1px solid var(--line);
+ border-radius: var(--r-panel);
+ box-shadow: var(--shadow-tile);
+ transition: background-color .45s, border-color .45s;
+}
+
+.panel { display: flex; flex-direction: column; }
+
+.panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 18px 18px 12px;
+}
+.panel-head h2 { margin: 0; font-size: 21px; }
+
+/* A list that scrolls inside its panel. */
+.panel-scroll {
+ flex: 1 1 0;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-width: thin;
+ scrollbar-color: var(--line-2) transparent;
+ scroll-behavior: smooth;
+}
+
+/* Faded top and bottom edges on a scrolling list. The mask color is only an
+ alpha source, it is never painted. */
+.fade-edges {
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
+ mask-image: linear-gradient(to bottom, transparent 0, black 16px, black calc(100% - 32px), transparent 100%);
+}
+
+/* Which panels grow to fill their column. */
+.bento-left .expert-search { flex: 1 1 0; }
+.bento-center .feed { flex: 1 1 0; }
+.bento-right { justify-content: flex-start; }
+.bento-right .rail-featured { flex: none; }
+.bento-right .rail-trending { flex: 1 1 0; }
+
+/* Panel contents keep their inner padding off the scroll edge. */
+.expert-search .search-form { margin: 0 14px; display: flex; flex-direction: column; }
+.expert-search .search-form input { width: 100%; }
+.expert-search .chips { padding: 0 14px; }
+.expert-search .search-results { padding: 12px 14px 24px; }
+.rail .rail-list { padding: 4px 14px 24px; }
+.feed .composer { margin: 0 14px 4px; }
+.feed .feed-list { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 32px; }
+
+/* 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) {
+ .bento { display: block; padding: 0; overflow: hidden; }
+ .bento-left,
+ .bento-right { display: none; }
+ .bento-center { height: 100%; }
+}
+
+/* Account panel ---------------------------------------------------------- */
+
+.account { flex: none; }
+
+.account-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 14px 12px 12px 16px;
+}
+.account-word { flex: 1; font-size: 15px; }
+
+.account-theme {
+ appearance: none;
+ flex: none;
+ display: inline-grid;
+ place-items: center;
+ width: 40px;
+ height: 40px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: var(--r-ctl);
+ background: transparent;
+ color: var(--ink-2);
+ cursor: pointer;
+ transition: background-color .2s, color .2s, transform .15s;
+}
+.account-theme:hover { background: var(--tile-2); color: var(--ink); }
+.account-theme:active { transform: scale(.9); }
+.account-theme svg { width: 20px; height: 20px; }
+
+.account-me {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 0 12px;
+ padding: 12px;
+ border-radius: 14px;
+ background: var(--tile-2);
+}
+.account-me-text { display: flex; flex-direction: column; min-width: 0; }
+.account-me-text strong { font-size: 14px; }
+.account-me-text span {
+ font-size: 12.5px;
+ color: var(--ink-3);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.account-live {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 18px 16px;
+ font-size: 12.5px;
+ color: var(--ink-3);
+}
+.account-dot {
+ flex: none;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ animation: account-ping 2.2s infinite;
+}
+@keyframes account-ping {
+ 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 45%); }
+ 70% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent), transparent 100%); }
+ 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 100%); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .account-dot { animation: none !important; }
+ .panel-scroll { scroll-behavior: auto !important; }
+}
diff --git a/src/styles/mobile.css b/src/styles/mobile.css
new file mode 100644
index 0000000..e0ce791
--- /dev/null
+++ b/src/styles/mobile.css
@@ -0,0 +1,598 @@
+/*
+ * 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; }
+/* Long text is open on the slide on screen: the reel holds still, swipes scroll the text. */
+.reel[data-locked="true"] { overflow-y: hidden; }
+
+.slide {
+ --slide-text-bg: radial-gradient(120% 70% at 0% 0%, var(--accent-soft), transparent 62%), var(--bg);
+ 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-video .slide-art video { background: transparent; }
+/* Wider than 4:5: show the whole video, centered, over a blurred, darkened, enlarged copy of its own thumbnail. */
+.slide-backdrop { display: none; }
+.slide-video[data-fit="contain"] .slide-backdrop {
+ display: block;
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transform: scale(1.25);
+ filter: blur(28px) brightness(0.5);
+ pointer-events: none;
+}
+.slide-video[data-fit="contain"] .slide-art video,
+.slide-video[data-fit="contain"] .slide-art img { object-fit: contain; }
+.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
+.slide-play { z-index: 4; }
+.slide-dur { top: 128px; right: 16px; z-index: 4; }
+/* Autoplay: a small frosted sound toggle on the video; a tap on the video pauses or resumes it. */
+.slide-video[data-autoplay="true"] .slide-art { cursor: pointer; }
+.slide-mute {
+ position: absolute;
+ top: calc(72px + env(safe-area-inset-top, 0px));
+ right: 16px;
+ z-index: 4;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 12px;
+ appearance: none;
+ color: var(--on-media);
+ background: var(--media-glass);
+ -webkit-backdrop-filter: blur(16px) saturate(1.6);
+ backdrop-filter: blur(16px) saturate(1.6);
+ box-shadow: inset 0 0 0 1px var(--on-media-line);
+ cursor: pointer;
+ transition: transform .2s var(--ease);
+}
+.slide-mute svg { width: 18px; height: 18px; }
+.slide-mute:active { transform: scale(.92); }
+
+/* Several media items: a sideways snap track; each pane is one full-slide item. */
+.slide-track {
+ 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.
+ The box spans from under the top bar (or the own-post menu) to above the author
+ block; SlideText steps the size 31 → 26 → 22 → 19px until the text fits. */
+.slide-quote-w {
+ position: absolute;
+ top: calc(72px + env(safe-area-inset-top, 0px));
+ bottom: 181px; /* .slide-meta bottom (108px) + the author block (53px) + 20px */
+ left: 24px;
+ right: 28px;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ pointer-events: none;
+}
+.slide-own .slide-quote-w { top: calc(122px + env(safe-area-inset-top, 0px)); }
+/* Auto margins center the quote and, unlike justify-content, never push overflow above the box. */
+.slide-quote-in { margin: auto 0; }
+.slide-quote-w.is-clamped .slide-quote-in { margin-top: 0; }
+.slide-quote-mark {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--accent);
+ font-family: var(--font-serif), Georgia, serif;
+ font-size: 84px;
+ font-weight: 600;
+ line-height: .62;
+}
+.slide-quote {
+ margin: 0;
+ font-family: var(--font-serif), Georgia, serif;
+ font-size: 31px;
+ font-weight: 600;
+ line-height: 1.2;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.slide-quote.is-clamped {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* "Read more", "Close", "more" and "less": real buttons, 44px tall to tap. */
+.slide-more {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ margin: 0;
+ padding: 0 18px;
+ border: 0;
+ border-radius: 14px;
+ font: inherit;
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: .01em;
+ color: var(--ink);
+ background: var(--glass);
+ -webkit-backdrop-filter: blur(16px) saturate(1.6);
+ backdrop-filter: blur(16px) saturate(1.6);
+ box-shadow: inset 0 0 0 1px var(--line);
+ cursor: pointer;
+ pointer-events: auto;
+ transition: transform .15s;
+}
+.slide-more:active { transform: scale(.96); }
+.slide-more:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
+.slide-more-quote { position: absolute; left: 0; bottom: 0; }
+
+/* The full text of a long text-only post, over the slide between the top bar and the dock. */
+@keyframes slide-reader-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
+.slide-reader {
+ position: absolute;
+ top: calc(64px + env(safe-area-inset-top, 0px));
+ bottom: calc(88px + env(safe-area-inset-bottom, 0px));
+ left: 12px;
+ right: 12px;
+ z-index: 7;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 20px 14px 14px 20px;
+ border-radius: 22px;
+ color: var(--ink);
+ background: var(--bg);
+ box-shadow: var(--shadow-pop), inset 0 0 0 1px var(--line);
+ animation: slide-reader-in .28s var(--ease) both;
+}
+.slide-reader-scroll {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding-right: 6px;
+}
+.slide-reader-scroll:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 6px; }
+.slide-reader-text {
+ margin: 0;
+ font-family: var(--font-serif), Georgia, serif;
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1.35;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.slide-reader-close { flex: none; align-self: flex-end; }
+
+/* Caption "more" / "less": a text button right under the three-line clamp. */
+.slide-more.slide-more-caption {
+ align-self: flex-start;
+ flex: none;
+ margin: -12px 0 -10px -6px;
+ padding: 0 6px;
+ min-width: 44px;
+ background: none;
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ box-shadow: none;
+ color: inherit;
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ text-shadow: 0 1px 12px var(--media-shadow);
+}
+/* Open caption: the full text over the media on a darker scrim, scrolling inside the slide. */
+.slide-who,
+.slide-error { flex: none; }
+.slide-text-open .slide-meta {
+ top: calc(122px + env(safe-area-inset-top, 0px));
+ justify-content: flex-end;
+}
+.slide-text.is-open {
+ display: block;
+ -webkit-line-clamp: unset;
+ flex: 0 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ animation: slide-reader-in .28s var(--ease) both;
+}
+.slide-text.is-open:focus-visible { outline: 2px solid var(--on-media); outline-offset: 4px; }
+.slide-text-open .slide-scrim { height: 100%; background: var(--media-scrim); }
+.slide-text-open .slide-scrim-top { opacity: 0; }
+
+/* The slide on screen: its content rises in when it snaps into place. */
+.is-cur .slide-meta,
+.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);
+}
+/* The crimson glow tints the light page enough to drop --ink-3 meta below 4.5:1. */
+[data-theme="light"] .slide-featured { --ink-3: var(--ink-2); }
+.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-text); }
+.dk.dk-like-on svg { 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-reader,
+ .slide-text.is-open,
+ .slide-hint svg,
+ .reel-pill,
+ .dk-beat svg { animation: none !important; }
+ .slide-art,
+ .slide-more { transition: none !important; }
+}
+
+/* Top bar (spec "Top bar"): frosted pills over the feed. Over a media slide they use a
+ 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; } }
diff --git a/src/styles/post.css b/src/styles/post.css
new file mode 100644
index 0000000..4b6915a
--- /dev/null
+++ b/src/styles/post.css
@@ -0,0 +1,248 @@
+/*
+ * Post card, comments and the own-post menu (spec "Post card", ADR 0001).
+ * Prefixed `post-` classes, tokens only. Every button sets margin, padding,
+ * font and color itself so no global `button` rule leaks in.
+ * --on-accent (white) appears only over video and on crimson faces.
+ */
+@keyframes post-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
+@keyframes post-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
+@keyframes post-beat { 0% { transform: scale(1); } 30% { transform: scale(1.4); } 60% { transform: scale(.9); } 100% { transform: scale(1); } }
+
+.post {
+ --post-soft: color-mix(in srgb, var(--ink), transparent 92%);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px 16px 10px;
+ background: var(--tile);
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ animation: post-rise .55s var(--ease) both;
+ animation-delay: calc(var(--i, 0) * 70ms);
+ scroll-snap-align: start;
+ scroll-margin-top: 16px;
+ transition: border-color .3s, box-shadow .3s;
+}
+.post:hover { border-color: var(--line-2); box-shadow: var(--shadow-tile); }
+
+.post-head { display: flex; align-items: center; gap: 12px; }
+.post-who { display: flex; flex-direction: column; flex: 1; min-width: 0; }
+.post-who strong { display: flex; align-items: center; font-size: 14.5px; font-weight: 700; }
+.post-who time { font-size: 12.5px; color: var(--ink-3); }
+.post-you {
+ margin-left: 8px;
+ padding: 2px 7px;
+ border-radius: 6px;
+ box-shadow: inset 0 0 0 1px var(--line-2);
+ color: var(--ink-2);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+}
+.post-text { margin: 0; font-size: 15px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
+.post-error { margin: 0; align-self: center; color: var(--danger); font-size: 13px; }
+
+/* Media: 16:9, 12px radius. */
+.post-media {
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ aspect-ratio: 16 / 9;
+ border-radius: var(--r-ctl);
+ background: var(--tile-3);
+ box-shadow: inset 0 0 0 1px var(--line);
+}
+.post-media img,
+.post-media video { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
+.post-media video { object-fit: contain; background: var(--tile-3); }
+.post-video-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
+.post-play {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ display: grid;
+ place-items: center;
+ width: 62px;
+ height: 62px;
+ margin: auto;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ color: var(--on-accent);
+ background: color-mix(in srgb, var(--on-accent), transparent 84%);
+ -webkit-backdrop-filter: blur(12px);
+ backdrop-filter: blur(12px);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--on-accent), transparent 60%), 0 12px 30px color-mix(in srgb, black 28%, transparent);
+ cursor: pointer;
+ transition: transform .3s cubic-bezier(.2,.9,.3,1.4), background-color .3s;
+}
+.post-play svg { width: 24px; height: 24px; margin-left: 3px; }
+.post-play:hover { transform: scale(1.1); background: color-mix(in srgb, var(--on-accent), transparent 72%); }
+.post-dur {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 2;
+ padding: 3px 8px;
+ border-radius: 8px;
+ color: var(--on-accent);
+ background: color-mix(in srgb, black 42%, transparent);
+ -webkit-backdrop-filter: blur(6px);
+ backdrop-filter: blur(6px);
+ font-size: 12px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ pointer-events: none;
+}
+
+/* Footer actions. */
+.post-acts { display: flex; align-items: center; gap: 2px; margin: 0 -8px; }
+.post-act {
+ appearance: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 38px;
+ margin: 0;
+ padding: 0 12px;
+ border: 0;
+ border-radius: 11px;
+ background: transparent;
+ color: var(--ink-2);
+ font: inherit;
+ font-size: 13.5px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ opacity: 1;
+ cursor: pointer;
+ transition: background-color .2s, color .2s;
+}
+.post-act svg { width: 20px; height: 20px; flex: none; }
+.post-act:hover:not(:disabled) { background: var(--post-soft); color: var(--ink); }
+.post-act:disabled { cursor: default; }
+.post-like:hover:not(:disabled),
+.post-like-on { color: var(--accent-text); }
+.post-like:hover:not(:disabled) .post-heart,
+.post-like-on .post-heart { color: var(--accent); }
+.post-like-beat .post-heart { animation: post-beat .5s cubic-bezier(.2,.9,.3,1.4); }
+.post-act-open { color: var(--ink); background: var(--post-soft); }
+
+/* Own-post menu. */
+.post-menu { position: relative; }
+.post-menu-btn {
+ appearance: none;
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: var(--r-ctl);
+ background: transparent;
+ color: var(--ink-2);
+ opacity: 1;
+ cursor: pointer;
+ transition: background-color .2s, color .2s;
+}
+.post-menu-btn svg { width: 20px; height: 20px; }
+.post-menu-btn:hover:not(:disabled),
+.post-menu-btn-open { background: var(--post-soft); color: var(--ink); }
+.post-menu-btn:disabled { cursor: default; }
+.post-pop {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 6px);
+ z-index: 6;
+ min-width: 210px;
+ padding: 6px;
+ color: var(--ink);
+ background: var(--glass);
+ -webkit-backdrop-filter: blur(18px);
+ backdrop-filter: blur(18px);
+ border: 1px solid var(--line-2);
+ border-radius: 14px;
+ box-shadow: var(--shadow-pop);
+ animation: post-pop .18s var(--ease) both;
+}
+.post-mi {
+ appearance: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ height: 40px;
+ margin: 0;
+ padding: 0 12px;
+ border: 0;
+ border-radius: var(--r-ctl);
+ background: transparent;
+ color: var(--ink);
+ font: inherit;
+ font-size: 13.5px;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+}
+.post-mi svg { width: 18px; height: 18px; flex: none; }
+.post-mi:hover { background: var(--post-soft); }
+.post-mi-danger { color: var(--danger); }
+.post-ask { padding: 8px 6px 4px; }
+.post-ask-title { margin: 0; padding: 0 4px; font-size: 13px; font-weight: 600; }
+.post-ask-sub { margin: 2px 0 0; padding: 0 4px; font-size: 12.5px; color: var(--ink-3); }
+.post-ask-row { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 12px; }
+
+/* Inline comments. */
+.post-cm {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin: 0 -16px;
+ padding: 12px 16px 6px;
+ border-top: 1px solid var(--line);
+ animation: post-rise .35s var(--ease) both;
+}
+.post-cm-list { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
+.post-cm-empty { margin: 0; font-size: 13px; color: var(--ink-3); }
+.post-c { display: flex; align-items: flex-start; gap: 10px; }
+.post-c-body { flex: 1; min-width: 0; padding: 8px 12px; background: var(--tile-2); border-radius: 4px 14px 14px 14px; }
+.post-c-head { display: flex; align-items: baseline; gap: 8px; }
+.post-c-head strong { font-size: 13px; }
+.post-c-head time { font-size: 12px; color: var(--ink-3); }
+.post-c-body p { margin: 2px 0 0; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
+.post-c-removed p { font-style: italic; color: var(--ink-3); }
+.post-cm-form { display: flex; align-items: center; gap: 8px; }
+.post-cm-form input {
+ flex: 1;
+ min-width: 0;
+ height: 44px;
+ margin: 0;
+ padding: 0 14px;
+ border: 0;
+ border-radius: var(--r-ctl);
+ background: var(--tile-2);
+ color: var(--ink);
+ font: inherit;
+ font-size: 14px;
+ box-shadow: inset 0 0 0 1px var(--line);
+}
+.post-cm-form input::placeholder { color: var(--ink-3); }
+.post-cm-form input:focus-visible { outline: none; box-shadow: inset 0 0 0 1.5px var(--ink-2); }
+.post-link {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .post, .post-cm, .post-pop { animation: none; }
+ .post-like-beat .post-heart { animation: none; }
+ .post-play { transition: none; }
+}
diff --git a/src/styles/search.css b/src/styles/search.css
new file mode 100644
index 0000000..dad1e9b
--- /dev/null
+++ b/src/styles/search.css
@@ -0,0 +1,74 @@
+/* Find experts panel (ADR 0001, section 6; spec "Find experts"). Ported from
+ the mockup's .field, .chips, .rows and .rate rules; tokens only. The
+ category chips are SweepButton (buttons.css). */
+
+.search-form label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+.search-field {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 44px;
+ padding: 0 14px;
+ border-radius: 12px;
+ background: var(--tile-2);
+ color: var(--ink-3);
+ box-shadow: inset 0 0 0 1px var(--line);
+ transition: box-shadow .25s, background-color .25s;
+}
+.search-field svg { width: 18px; height: 18px; flex: none; }
+.search-field:focus-within {
+ background: var(--tile);
+ box-shadow: inset 0 0 0 1px var(--ink-3), 0 0 0 4px var(--line);
+}
+.search-field input,
+.search-field input:focus-visible {
+ flex: 1;
+ min-width: 0;
+ width: auto;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ outline: 0;
+ background: transparent;
+ font: inherit;
+ font-size: 14px;
+ color: var(--ink);
+}
+.search-field input::placeholder { color: var(--ink-3); }
+
+.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
+
+.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
+.search-rows { gap: 2px; }
+.expert-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 10px;
+ border-radius: 14px;
+ transition: background-color .2s;
+}
+.expert-row:hover { background: var(--tile-2); }
+.expert-text { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
+.expert-text strong { font-size: 13.5px; color: var(--ink); }
+.expert-text span { font-size: 12.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.expert-rate {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12.5px;
+ font-weight: 700;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+}
+.expert-rate svg { width: 13px; height: 13px; color: var(--accent); }
diff --git a/src/styles/sheet.css b/src/styles/sheet.css
new file mode 100644
index 0000000..a929c12
--- /dev/null
+++ b/src/styles/sheet.css
@@ -0,0 +1,156 @@
+/*
+ * 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; }
+/* A small overlap: a bigger one hid the initials of the avatars underneath. */
+.depth-stack .hex-avatar { margin-left: -5px; }
+.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; }
+}
diff --git a/src/styles/signin.css b/src/styles/signin.css
new file mode 100644
index 0000000..74a88f5
--- /dev/null
+++ b/src/styles/signin.css
@@ -0,0 +1,103 @@
+/* Sign-in page (spec "Sign in", ADR 0001). Outside .app: one centered column, no breakpoints.
+ Tokens only, so it follows the active theme (dark by default). */
+.signin {
+ position: relative;
+ min-height: 100vh;
+ overflow: hidden;
+ display: flex;
+ justify-content: center;
+ padding: 28px 24px;
+ background: var(--bg);
+ color: var(--ink);
+}
+.signin-glow {
+ position: absolute;
+ left: 50%;
+ top: -220px;
+ width: 560px;
+ height: 420px;
+ transform: translateX(-50%);
+ border-radius: 50%;
+ background: radial-gradient(ellipse, color-mix(in srgb, var(--accent), transparent 84%), transparent 65%);
+ pointer-events: none;
+}
+.signin-col {
+ position: relative;
+ width: 100%;
+ max-width: 342px;
+ display: flex;
+ flex-direction: column;
+}
+.signin-brand {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding-top: 40px;
+ font-size: 15px;
+}
+.signin-head {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 14px;
+ text-align: center;
+ margin: 72px 0 40px;
+}
+.signin-title {
+ margin: 0;
+ font-family: var(--font-serif), Georgia, serif;
+ font-size: 40px;
+ font-weight: 600;
+ line-height: 1.05;
+ color: var(--ink);
+}
+.signin-title em { font-style: italic; font-weight: 500; }
+.signin-rule {
+ width: 48px;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--ink), transparent 40%), transparent);
+}
+.signin-form { display: flex; flex-direction: column; gap: 12px; }
+.signin-label {
+ margin: 0;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.16em;
+ color: var(--ink-3);
+}
+.signin-label-next { margin-top: 6px; }
+.signin-field {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 52px;
+ padding: 0 16px;
+ border-radius: var(--r-ctl);
+ background: var(--tile-2);
+ color: var(--ink-3);
+ box-shadow: inset 0 0 0 1px var(--line-2);
+ transition: box-shadow .25s;
+}
+.signin-field:focus-within {
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink), transparent 45%), 0 0 0 4px color-mix(in srgb, var(--ink), transparent 94%);
+}
+.signin-field svg { width: 18px; height: 18px; flex: none; }
+.signin-field input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ outline: 0;
+ background: transparent;
+ font: inherit;
+ font-size: 15px;
+ color: var(--ink);
+}
+.signin-field input::placeholder { color: var(--ink-3); }
+.signin-form .signin-submit { margin-top: 18px; width: 100%; height: 54px; }
+.signin-error { margin: 0; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
diff --git a/src/styles/trending.css b/src/styles/trending.css
new file mode 100644
index 0000000..ef2b7fc
--- /dev/null
+++ b/src/styles/trending.css
@@ -0,0 +1,103 @@
+/* Trending minnects panel (ADR 0001, section 6; spec "Trending minnects").
+ Ported from the mockup's .tr rules; tokens only. The white inside the media
+ frame is text and glass over the photo, which is dark in both themes. */
+
+.trend-list { gap: 16px; }
+.minnect-card { display: flex; flex-direction: column; gap: 9px; animation: trend-rise .55s var(--ease) both; }
+@keyframes trend-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
+
+.trend-media {
+ position: relative;
+ aspect-ratio: 16 / 9;
+ border-radius: 12px;
+ overflow: hidden;
+ isolation: isolate;
+ background: var(--tile-3);
+ box-shadow: inset 0 0 0 1px var(--line);
+}
+.trend-media .minnect-thumb {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 1s var(--ease);
+}
+.minnect-card:hover .trend-media:not(.trend-media-playing) .minnect-thumb { transform: scale(1.035); }
+.minnect-video { background: black; }
+
+.trend-scrim {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 62%;
+ z-index: 1;
+ pointer-events: none;
+ background: linear-gradient(180deg, transparent, color-mix(in srgb, black 62%, transparent));
+}
+.trend-who {
+ position: absolute;
+ left: 10px;
+ top: 10px;
+ z-index: 2;
+ max-width: calc(100% - 20px);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 3px 10px 3px 4px;
+ border-radius: 12px;
+ font-size: 12px;
+ color: var(--on-accent);
+ background: color-mix(in srgb, black 34%, transparent);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+ pointer-events: none;
+}
+.trend-who strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
+.trend-play {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ z-index: 2;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ cursor: pointer;
+ color: var(--on-accent);
+ background: color-mix(in srgb, var(--on-accent) calc(100% - 84%), transparent);
+ -webkit-backdrop-filter: blur(12px);
+ backdrop-filter: blur(12px);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--on-accent) calc(100% - 60%), transparent), 0 12px 30px color-mix(in srgb, black 28%, transparent);
+ transition: transform .3s cubic-bezier(.2, .9, .3, 1.4), background-color .3s;
+}
+.trend-play svg { width: 18px; height: 18px; margin-left: 2px; }
+.trend-play:hover { transform: scale(1.1); background: color-mix(in srgb, var(--on-accent) calc(100% - 72%), transparent); }
+.trend-play:focus-visible { outline: 2px solid var(--on-accent); outline-offset: 2px; }
+.trend-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.trend-views { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--ink-2); }
+.trend-views svg { width: 15px; height: 15px; }
+.trend-kind { font-size: 10.5px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); }
+/* While a video plays its own controls take over the frame. */
+.trend-media-playing .trend-scrim,
+.trend-media-playing .trend-who,
+.trend-media-playing .trend-play { opacity: 0; visibility: hidden; }
+
+.trend-q {
+ margin: 0;
+ font-size: 13.5px;
+ line-height: 1.45;
+ font-weight: 600;
+ color: var(--ink);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.minnect-audio { width: 100%; height: 36px; }
+
+.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
diff --git a/src/styles/type.css b/src/styles/type.css
new file mode 100644
index 0000000..fc41d71
--- /dev/null
+++ b/src/styles/type.css
@@ -0,0 +1,39 @@
+/* Reusable type classes (spec "Typography", ADR 0001 section 4).
+ Manrope is the body font on . Cormorant Garamond is for titles only. */
+
+/* Wordmark: the text MINNECT. */
+.type-wordmark {
+ font-family: var(--font-sans), system-ui, sans-serif;
+ font-weight: 800;
+ letter-spacing: 0.26em;
+}
+
+/* Eyebrow label. */
+.type-eyebrow {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ color: var(--ink-3);
+}
+
+/* Serif title: feed title, panel titles, featured expert names, sheet titles,
+ text-only post slides on mobile. Add .type-title-italic for the italic 500 cut. */
+.type-title {
+ font-family: var(--font-serif), Georgia, serif;
+ font-weight: 500;
+}
+.type-title-strong { font-weight: 600; }
+.type-title-italic { font-style: italic; font-weight: 500; }
+
+/* Post body. */
+.type-post-body {
+ font-size: 15px;
+ line-height: 1.7;
+}
+
+/* Meta text (times, counts). */
+.type-meta {
+ font-size: 12.5px;
+ color: var(--ink-3);
+}
diff --git a/tests/autoplay.test.ts b/tests/autoplay.test.ts
new file mode 100644
index 0000000..eba565f
--- /dev/null
+++ b/tests/autoplay.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { readFileSync } from "node:fs";
+import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
+import { VideoPlayer } from "@/components/VideoPlayer";
+
+describe("session mute choice", () => {
+ it("starts muted and keeps the person's choice", () => {
+ expect(getSessionMuted()).toBe(true);
+ setSessionMuted(false);
+ expect(getSessionMuted()).toBe(false);
+ setSessionMuted(true);
+ expect(getSessionMuted()).toBe(true);
+ });
+});
+
+describe("autoplay refusal", () => {
+ it("only counts the browser's NotAllowedError", () => {
+ expect(isAutoplayBlocked({ name: "NotAllowedError" })).toBe(true);
+ expect(isAutoplayBlocked({ name: "AbortError" })).toBe(false);
+ expect(isAutoplayBlocked(null)).toBe(false);
+ expect(isAutoplayBlocked("NotAllowedError")).toBe(false);
+ });
+});
+
+describe("VideoPlayer", () => {
+ it("is inline and not muted unless asked (desktop unchanged)", () => {
+ const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v" }));
+ expect(html).toContain("playsInline");
+ expect(html).toContain("controls");
+ expect(html).not.toContain("muted");
+ });
+ it("renders muted inline video for the mobile autoplay", () => {
+ const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v", controls: false, muted: true }));
+ expect(html).toContain("muted");
+ expect(html).not.toContain("controls");
+ });
+});
+
+describe("mobile autoplay styles", () => {
+ it("has a frosted mute button", () => {
+ const css = readFileSync("src/styles/mobile.css", "utf8");
+ expect(css).toMatch(/\.slide-mute \{[^}]*backdrop-filter/);
+ });
+});
diff --git a/tests/bottom-sheet.test.ts b/tests/bottom-sheet.test.ts
new file mode 100644
index 0000000..7261808
--- /dev/null
+++ b/tests/bottom-sheet.test.ts
@@ -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]*]*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(" body
");
+ });
+ 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(' ");
+ });
+});
diff --git a/tests/buttons.test.ts b/tests/buttons.test.ts
new file mode 100644
index 0000000..7eb470b
--- /dev/null
+++ b/tests/buttons.test.ts
@@ -0,0 +1,182 @@
+import { describe, expect, it } from "vitest";
+import { existsSync, readdirSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import {
+ DepthButton,
+ FlipButton,
+ GrowButton,
+ IconButton,
+ SendIcon,
+ SparkleButton,
+ SweepButton,
+ flipLabels,
+} from "@/components/Buttons";
+import { ButtonGallery } from "@/components/ButtonGallery";
+
+const html = (el: Parameters[0]) => renderToStaticMarkup(el);
+const css = readFileSync(path.resolve(__dirname, "../src/styles/buttons.css"), "utf8");
+const globals = readFileSync(path.resolve(__dirname, "../src/app/globals.css"), "utf8");
+
+/** Body of the first rule whose selector list matches exactly. */
+function rule(selector: string): string {
+ const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const m = css.match(new RegExp(`(?:^|\\n|,)\\s*${esc}\\s*\\{([^}]*)\\}`));
+ if (!m) throw new Error(`no rule for ${selector}`);
+ return m[1];
+}
+
+describe("SparkleButton disabled", () => {
+ it("keeps full opacity and a solid readable face", () => {
+ const body = rule(".btn-spark:disabled");
+ expect(body).toMatch(/opacity:\s*1\b/);
+ expect(body).toContain("var(--tile-3)");
+ expect(body).toContain("var(--ink-2)");
+ expect(body).toContain("not-allowed");
+ for (const f of readdirSync(path.resolve(__dirname, "../src/styles"))) {
+ expect(readFileSync(path.resolve(__dirname, "../src/styles", f), "utf8")).not.toMatch(/(^|[\s,}])button:disabled\s*\{[^}]*opacity/);
+ }
+ expect(html(createElement(SparkleButton, { label: "Sign in", disabled: true }))).toContain("disabled");
+ });
+});
+
+describe("legacy.css", () => {
+ it("is deleted and no longer imported, and no global button margin rule is left", () => {
+ expect(existsSync(path.resolve(__dirname, "../src/styles/legacy.css"))).toBe(false);
+ expect(readFileSync(path.resolve(__dirname, "../src/app/layout.tsx"), "utf8")).not.toContain("legacy.css");
+ expect(globals).not.toMatch(/(^|[\s,}])button\s*\{[^}]*margin/);
+ });
+});
+
+describe("FlipButton states", () => {
+ it("ready: POST plus send icon, flips to PUBLISH with the icon", () => {
+ expect(flipLabels("ready")).toEqual({ front: "Post", back: "Publish", icon: true });
+ const out = html(createElement(FlipButton, { state: "ready" }));
+ expect(out).toContain('class="btn-flip"');
+ expect(out).not.toContain("disabled");
+ expect(out.match(/Publish/);
+ });
+
+ it("disabled: native disabled, flips to WRITE FIRST, no icon", () => {
+ const out = html(createElement(FlipButton, { state: "disabled" }));
+ expect(out).toContain('disabled=""');
+ expect(out).toMatch(/btn-flip-front">Post);
+ expect(out).toMatch(/btn-flip-back" aria-hidden="true">Write first);
+ expect(out).not.toContain(" {
+ const out = html(createElement(FlipButton, { state: "posting" }));
+ expect(out).toContain("btn-flip btn-flip-busy");
+ expect(out).toContain('aria-busy="true"');
+ expect(out).toMatch(/btn-flip-front">Posting);
+ expect(out).not.toContain("btn-flip-back");
+ });
+
+ it("the disabled face is solid graphite and never faded", () => {
+ const disabled = rule(".btn-flip:disabled");
+ expect(disabled).toMatch(/opacity:\s*1/);
+ expect(disabled).toMatch(/background:\s*var\(--tile-3\)/);
+ expect(disabled).toMatch(/color:\s*var\(--ink-2\)/);
+ expect(css).not.toMatch(/\.btn-flip[^{]*\{[^}]*opacity:\s*0?\.\d/);
+ });
+
+ it("sets text color explicitly for every state, never by inheritance", () => {
+ expect(rule(".btn-flip")).toMatch(/color:\s*var\(--bg\)/);
+ expect(rule(".btn-flip .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
+ expect(rule(".btn-flip .btn-flip-back")).toMatch(/color:\s*var\(--on-accent\)/);
+ expect(rule(".btn-flip:disabled .btn-flip-front")).toMatch(/color:\s*var\(--ink-2\)/);
+ expect(rule(".btn-flip:disabled .btn-flip-back")).toMatch(/color:\s*var\(--ink\)/);
+ expect(rule(".btn-flip.btn-flip-busy:disabled .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
+ // Face and text never share a token in any state.
+ expect(rule(".btn-flip")).toMatch(/background:\s*var\(--ink\)/);
+ expect(rule(".btn-flip.btn-flip-busy:disabled")).toMatch(/background:\s*var\(--ink\)/);
+ });
+});
+
+describe("the other styles", () => {
+ it("GrowButton renders the crimson circle and the label", () => {
+ const out = html(createElement(GrowButton, { label: "3 new posts" }));
+ expect(out).toContain('class="btn-grow"');
+ expect(out).toContain("btn-grow-circle");
+ expect(out).toMatch(/btn-grow-text">3 new posts);
+ });
+
+ it("SweepButton stays filled and pressed when selected", () => {
+ expect(html(createElement(SweepButton, { label: "Business", selected: true }))).toContain('class="btn-sweep btn-sweep-on" aria-pressed="true"');
+ expect(html(createElement(SweepButton, { label: "Media", selected: false }))).toContain('aria-pressed="false"');
+ expect(html(createElement(SweepButton, { label: "Cancel" }))).not.toContain("aria-pressed");
+ });
+
+ it("DepthButton renders label, sublabel and icon", () => {
+ const out = html(createElement(DepthButton, { label: "Trending", sublabel: "Hot now", icon: createElement(SendIcon) }));
+ expect(out).toContain("btn-depth-icon");
+ expect(out).toMatch(/btn-depth-label">Trending);
+ expect(out).toMatch(/btn-depth-sub">Hot now);
+ });
+
+ it("SparkleButton: sign in has the fold, hexagon and ten sparks; fab has a plus and a name", () => {
+ const signin = html(createElement(SparkleButton, { label: "Sign in", type: "submit" }));
+ expect(signin).toContain("btn-spark-fold");
+ expect(signin).toContain("btn-spark-hex");
+ expect(signin.match(/<\/i>/g)).toHaveLength(10);
+ expect(signin).toContain('type="submit"');
+ const fab = html(createElement(SparkleButton, { variant: "fab", label: "New post" }));
+ expect(fab).toContain('aria-label="New post"');
+ expect(fab).toContain("btn-spark btn-spark-fab");
+ expect(fab).not.toContain("btn-spark-fold");
+ expect(fab).toContain("btn-spark-plus");
+ });
+
+ it("IconButton is the glossy crimson button with an accessible name", () => {
+ const out = html(createElement(IconButton, { label: "Send comment", icon: createElement(SendIcon), shape: "round", size: "sm" }));
+ expect(out).toContain('class="btn-gloss btn-gloss-round btn-gloss-sm"');
+ expect(out).toContain('aria-label="Send comment"');
+ expect(rule(".btn-gloss:disabled")).toMatch(/opacity:\s*1/);
+ });
+});
+
+describe("button CSS rules", () => {
+ it("uses only .btn-* class selectors", () => {
+ const selectors = css.replace(/\/\*[\s\S]*?\*\//g, "").match(/\.[a-zA-Z][\w-]*/g) ?? [];
+ expect(selectors.filter((s) => !s.startsWith(".btn-"))).toEqual([]);
+ });
+
+ it("does not include the rejected liquid waves or 3D drop buttons", () => {
+ expect(css).not.toMatch(/wave|wv/i);
+ });
+
+ it("stops hover and press motion under prefers-reduced-motion", () => {
+ const block = css.slice(css.indexOf("@media (prefers-reduced-motion: reduce)"));
+ expect(block.length).toBeGreaterThan(0);
+ for (const cls of ["btn-flip", "btn-grow", "btn-sweep", "btn-depth", "btn-spark", "btn-gloss"]) {
+ expect(block).toContain(`.${cls}`);
+ }
+ expect(block).toMatch(/transition:\s*none !important/);
+ expect(block).toMatch(/animation:\s*none !important/);
+ expect(block).toMatch(/transform:\s*none !important/);
+ });
+
+ it("every theme defines the button tokens", () => {
+ for (const token of ["--shadow-btn", "--shadow-float", "--neu-d", "--neu-l"]) {
+ expect(globals.match(new RegExp(`${token}:`, "g"))).toHaveLength(2);
+ }
+ expect(globals).toContain("--on-accent:");
+ });
+});
+
+describe("ButtonGallery story", () => {
+ it("shows every style in both themes", () => {
+ for (const theme of ["dark", "light"] as const) {
+ const out = html(createElement(ButtonGallery, { theme }));
+ expect(out).toContain(`data-theme="${theme}"`);
+ for (const cls of ["btn-flip", "btn-flip-busy", "btn-grow", "btn-sweep-on", "btn-depth", "btn-spark-fold", "btn-spark-fab", "btn-gloss"]) {
+ expect(out).toContain(cls);
+ }
+ expect(out).toMatch(/Write first/);
+ expect(out).toMatch(/Posting/);
+ }
+ });
+});
diff --git a/tests/composer.test.ts b/tests/composer.test.ts
new file mode 100644
index 0000000..a556d3f
--- /dev/null
+++ b/tests/composer.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { Composer, isPublishShortcut } from "@/components/Composer";
+import { attachmentMeta, BULLET, COMPOSER_EMOJI, formatBytes, insertBullets } from "@/components/composer-text";
+
+describe("insertBullets", () => {
+ it("adds a bullet at the start of the caret's line", () => {
+ expect(insertBullets("hello", 3, 3)).toEqual({ text: "• hello", selectionStart: 5, selectionEnd: 5 });
+ expect(insertBullets("one\ntwo\nthree", 5, 5).text).toBe("one\n• two\nthree");
+ });
+ it("adds a bullet to an empty text and to an empty last line", () => {
+ expect(insertBullets("", 0, 0)).toEqual({ text: BULLET, selectionStart: 2, selectionEnd: 2 });
+ expect(insertBullets("a\n", 2, 2).text).toBe("a\n• ");
+ });
+ it("adds a bullet to every selected line and keeps the selection on the same characters", () => {
+ const r = insertBullets("one\ntwo\nthree", 1, 9);
+ expect(r.text).toBe("• one\n• two\n• three");
+ expect(r.text.slice(r.selectionStart, r.selectionEnd)).toBe("ne\n• two\n• t");
+ });
+ it("does not bullet the next line when the selection ends right after a line break", () => {
+ expect(insertBullets("one\ntwo", 0, 4).text).toBe("• one\ntwo");
+ });
+ it("leaves lines that already start with a bullet unchanged", () => {
+ expect(insertBullets("• one\ntwo", 0, 9).text).toBe("• one\n• two");
+ expect(insertBullets("• one", 3, 3).text).toBe("• one");
+ });
+ it("changes nothing but the inserted characters", () => {
+ const text = "**bold** _it_\n indented";
+ const r = insertBullets(text, 0, text.length);
+ expect(r.text.replaceAll(BULLET, "")).toBe(text);
+ });
+});
+
+describe("composer helpers", () => {
+ it("offers 32 distinct emoji", () => {
+ expect(COMPOSER_EMOJI).toHaveLength(32);
+ expect(new Set(COMPOSER_EMOJI).size).toBe(32);
+ });
+ it("describes an attachment by type and size", () => {
+ expect(formatBytes(512)).toBe("512 B");
+ expect(formatBytes(2048)).toBe("2 KB");
+ expect(attachmentMeta({ type: "image/png", size: 2.4 * 1024 * 1024 })).toBe("Image, 2.4 MB");
+ expect(attachmentMeta({ type: "video/mp4", size: 18.2 * 1024 * 1024 })).toBe("Video, 18.2 MB");
+ });
+ it("publishes on Ctrl + Enter and Cmd + Enter only", () => {
+ expect(isPublishShortcut({ key: "Enter", ctrlKey: true, metaKey: false })).toBe(true);
+ expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: true })).toBe(true);
+ expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: false })).toBe(false);
+ expect(isPublishShortcut({ key: "a", ctrlKey: true, metaKey: false })).toBe(false);
+ });
+});
+
+describe("Composer markup", () => {
+ const base = { value: "", busy: false, error: false, onChange: () => {}, onSubmit: () => {} };
+ const html = (p: Partial[0]>) => renderToStaticMarkup(createElement(Composer, { ...base, ...p }));
+
+ it("is collapsed when empty and open when holding text or a file", () => {
+ expect(html({})).toMatch(/class="composer"/);
+ expect(html({ value: "hi" })).toContain("composer composer-open");
+ const f = new File(["x"], "a.png", { type: "image/png" });
+ expect(html({ file: f, onFile: () => {} })).toContain("composer composer-open");
+ });
+ it("orders the toolbar: photo, video, emoji, divider, bulleted list, hint, Post", () => {
+ const out = html({ onFile: () => {} });
+ const order = ["Add image", "Add video", "Add emoji", "composer-sep", "Bulleted list", "composer-kbd", "btn-flip"];
+ const at = order.map((s) => out.indexOf(s));
+ expect(at.every((i) => i >= 0)).toBe(true);
+ expect([...at].sort((a, b) => a - b)).toEqual(at);
+ expect(out).not.toMatch(/aria-label="(Bold|Italic)"/);
+ });
+ it("shows the three Post states", () => {
+ expect(html({})).toMatch(/btn-flip-front">Post<.*btn-flip-back[^>]*>Write first/);
+ expect(html({ value: "hi" })).toMatch(/btn-flip-back[^>]*>PublishPosting<");
+ });
+ it("shows the attachment row with name, type, size, progress and remove", () => {
+ const f = new File([new Uint8Array(3 * 1024)], "clip.mp4", { type: "video/mp4" });
+ const out = html({ file: f, onFile: () => {}, person: { name: "Jordan Rivera" } });
+ expect(out).toContain("clip.mp4");
+ expect(out).toContain("Video, 3 KB");
+ expect(out).toContain('role="progressbar"');
+ expect(out).toContain('aria-label="Remove attachment"');
+ expect(out).toContain("composer-att-indent");
+ expect(out).toContain("hex-avatar");
+ });
+});
diff --git a/tests/disabled-opacity.test.ts b/tests/disabled-opacity.test.ts
new file mode 100644
index 0000000..6d21d36
--- /dev/null
+++ b/tests/disabled-opacity.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import path from "node:path";
+
+/** Every .css file under src/ (src/styles, src/app/globals.css, and any other). */
+function cssFiles(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const full = path.join(dir, name);
+ if (statSync(full).isDirectory()) return cssFiles(full);
+ return full.endsWith(".css") ? [full] : [];
+ });
+}
+
+/** Rules whose selector targets a disabled state and whose opacity is below 1. */
+function fadedDisabledRules(source: string): string[] {
+ const text = source.replace(/\/\*[\s\S]*?\*\//g, "");
+ const found: string[] = [];
+ // Innermost blocks only: the selector text runs back to the previous brace,
+ // so rules nested inside @media or @supports are scanned too.
+ for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
+ const selector = m[1].trim();
+ if (!/:disabled|\[disabled\]|\[aria-disabled(="true")?\]/.test(selector)) continue;
+ for (const o of m[2].matchAll(/(?:^|;|\s)opacity\s*:\s*([\d.]+)(%?)/g)) {
+ const value = parseFloat(o[1]) / (o[2] ? 100 : 1);
+ if (value < 1) found.push(`${selector} { opacity: ${o[1]}${o[2]} }`);
+ }
+ }
+ return found;
+}
+
+describe("disabled buttons are never faded", () => {
+ const root = path.resolve(__dirname, "../src");
+ const files = cssFiles(root);
+
+ it("finds the stylesheets, globals.css included", () => {
+ expect(files.some((f) => f.endsWith(path.join("app", "globals.css")))).toBe(true);
+ expect(files.some((f) => f.endsWith(path.join("styles", "signin.css")))).toBe(true);
+ });
+
+ it("the scanner catches a faded disabled rule", () => {
+ expect(fadedDisabledRules(".a:disabled { opacity: .5; }")).toHaveLength(1);
+ expect(fadedDisabledRules("@media (x) { .a[disabled] { color: red; opacity: 40% } }")).toHaveLength(1);
+ expect(fadedDisabledRules(".a:disabled { opacity: 1; }")).toHaveLength(0);
+ expect(fadedDisabledRules(".a:hover { opacity: .5; }")).toHaveLength(0);
+ });
+
+ for (const file of files) {
+ it(`${path.relative(root, file)} has no :disabled rule with opacity below 1`, () => {
+ expect(fadedDisabledRules(readFileSync(file, "utf8"))).toEqual([]);
+ });
+ }
+});
diff --git a/tests/discover-rails.test.ts b/tests/discover-rails.test.ts
index 87f87af..2a77253 100644
--- a/tests/discover-rails.test.ts
+++ b/tests/discover-rails.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
-import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
+import { FeaturedCarousel } from "@/components/FeaturedCarousel";
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { fetchDiscover } from "@/lib/discover-client";
@@ -18,9 +18,9 @@ const minnect = (o: Partial = {}): DiscoverMinnect => ({
});
const html = (el: ReturnType) => renderToStaticMarkup(el);
-describe("FeaturedExpertsRail", () => {
- it("renders a card with name, byline, rating and reviews", () => {
- const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
+describe("FeaturedCarousel (static render)", () => {
+ it("renders the center expert with name, byline, rating and reviews", () => {
+ const out = html(createElement(FeaturedCarousel, { experts: [expert()] }));
expect(out).toContain(" {
expect(out).toContain(" {
- const out = html(createElement(FeaturedExpertsRail, {
+ const out = html(createElement(FeaturedCarousel, {
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
}));
expect(out).not.toContain(" {
expect(out).not.toContain("review");
});
it("renders nothing when empty", () => {
- expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
+ expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
});
});
@@ -57,7 +57,9 @@ describe("TrendingMinnectsRail", () => {
minnects: [minnect({ media: { kind: "video", url, duration: 9, waveform: null } })],
}));
expect(out).toContain("]*controls/);
+ expect(out).toContain('class="trend-play"');
expect(out).toContain("preload=\"none\"");
expect(out).toContain("X-Amz-Signature=abc");
expect(out).toContain('poster="https://x/t.jpg"');
diff --git a/tests/discover-sheet.test.ts b/tests/discover-sheet.test.ts
new file mode 100644
index 0000000..fbab132
--- /dev/null
+++ b/tests/discover-sheet.test.ts
@@ -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) => 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%/);
+ });
+});
diff --git a/tests/expert-search.test.ts b/tests/expert-search.test.ts
index 5b64beb..392d76d 100644
--- a/tests/expert-search.test.ts
+++ b/tests/expert-search.test.ts
@@ -91,7 +91,7 @@ describe("fetchExperts", () => {
const view = (o: Partial = {}) =>
renderToStaticMarkup(createElement(ExpertSearchView, {
query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }],
- activeCategory: null, onCategory: () => {}, status: "idle", results: [], defaultOpen: true, ...o,
+ activeCategory: null, onCategory: () => {}, status: "idle", results: [], ...o,
}));
describe("ExpertSearchView", () => {
diff --git a/tests/featured-carousel.test.ts b/tests/featured-carousel.test.ts
new file mode 100644
index 0000000..6739d3a
--- /dev/null
+++ b/tests/featured-carousel.test.ts
@@ -0,0 +1,109 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { FeaturedCarousel } from "@/components/FeaturedCarousel";
+import { FEATURED_INTERVAL_MS, featuredLiveMode, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
+import type { DiscoverExpert } from "@/lib/discover";
+
+const expert = (i: number): DiscoverExpert => ({
+ id: i, slug: `e${i}`, displayName: `Expert ${i}`, byline: `Byline ${i}`, pictureUrl: i % 2 ? `https://x/${i}.jpg` : null,
+ rating: 4.5, reviewsCount: i, available: true, location: { city: null, country: null },
+});
+const experts = (n: number) => Array.from({ length: n }, (_, i) => expert(i + 1));
+
+describe("featuredSlot", () => {
+ it("places items by offset from the current index, wrapping round", () => {
+ const slots = Array.from({ length: 8 }, (_, i) => featuredSlot(i, 0, 8));
+ expect(slots).toEqual(["c0", "r1", "r2", "r3", "r3", "l3", "l2", "l1"]);
+ expect(featuredSlot(0, 7, 8)).toBe("r1");
+ expect(featuredSlot(6, 7, 8)).toBe("l1");
+ });
+ it("degrades with one or two experts: no empty neighbors", () => {
+ expect(featuredSlot(0, 0, 1)).toBe("c0");
+ expect([featuredSlot(0, 0, 2), featuredSlot(1, 0, 2)]).toEqual(["c0", "r1"]);
+ expect([featuredSlot(0, 1, 2), featuredSlot(1, 1, 2)]).toEqual(["r1", "c0"]);
+ expect([0, 1, 2].map((i) => featuredSlot(i, 0, 3))).toEqual(["c0", "r1", "l1"]);
+ });
+});
+
+describe("startFeaturedAutoplay", () => {
+ beforeEach(() => { vi.useFakeTimers(); });
+ afterEach(() => { vi.useRealTimers(); });
+
+ it("advances every 2.8s and wraps to the start", () => {
+ const seen: number[] = [];
+ const ctl = startFeaturedAutoplay({ count: 3, onChange: (i) => seen.push(i) });
+ expect(FEATURED_INTERVAL_MS).toBe(2800);
+ vi.advanceTimersByTime(2799);
+ expect(seen).toEqual([]);
+ vi.advanceTimersByTime(1);
+ expect(seen).toEqual([1]);
+ vi.advanceTimersByTime(2800 * 2);
+ expect(seen).toEqual([1, 2, 0]);
+ expect(ctl.index).toBe(0);
+ ctl.stop();
+ });
+
+ it("does not advance while paused and resumes after", () => {
+ const onChange = vi.fn();
+ const ctl = startFeaturedAutoplay({ count: 5, onChange });
+ ctl.pause();
+ expect(ctl.paused).toBe(true);
+ vi.advanceTimersByTime(4000 * 3);
+ expect(onChange).not.toHaveBeenCalled();
+ expect(ctl.index).toBe(0);
+ ctl.resume();
+ vi.advanceTimersByTime(2800);
+ expect(onChange).toHaveBeenCalledWith(1);
+ ctl.stop();
+ });
+
+ it("never rotates a single expert and stops cleanly", () => {
+ const one = vi.fn();
+ startFeaturedAutoplay({ count: 1, onChange: one });
+ vi.advanceTimersByTime(10_000);
+ expect(one).not.toHaveBeenCalled();
+ const many = vi.fn();
+ const ctl = startFeaturedAutoplay({ count: 4, onChange: many });
+ ctl.stop();
+ vi.advanceTimersByTime(10_000);
+ expect(many).not.toHaveBeenCalled();
+ });
+});
+
+describe("FeaturedCarousel markup", () => {
+ it("starts with the live region off while it rotates, and hides non-center items and shows the counter and dots", () => {
+ const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
+ expect(out).toContain('aria-live="off"');
+ expect(out.match(/data-testid="featured-item"/g)).toHaveLength(8);
+ expect(out.match(/aria-hidden="true" data-testid="featured-item"/g)).toHaveLength(7);
+ expect(out).toContain("featured-c0");
+ expect(out).toContain(">1 / 8<");
+ expect(out.match(/class="featured-dot( featured-dot-on)?"/g)).toHaveLength(8);
+ expect(out.match(/featured-dot-on/g)).toHaveLength(1);
+ expect(out).toContain("--cfw:96px");
+ });
+ it("takes a size prop for mobile", () => {
+ const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(3), size: 156 }));
+ expect(out).toContain("--cfw:156px");
+ });
+ it("shows no dots for a single expert", () => {
+ const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
+ expect(out).not.toContain("featured-dot ");
+ expect(out).toContain(">1 / 1<");
+ expect(out).toContain('aria-live="polite"');
+ });
+});
+
+describe("featuredLiveMode (WAI-ARIA carousel live region)", () => {
+ it("is off while autoplay rotates", () => {
+ expect(featuredLiveMode({ rotating: true, paused: false })).toBe("off");
+ });
+ it("is polite while paused by hover or focus", () => {
+ expect(featuredLiveMode({ rotating: true, paused: true })).toBe("polite");
+ });
+ it("is polite when rotation is stopped (reduced motion or one expert)", () => {
+ expect(featuredLiveMode({ rotating: false, paused: false })).toBe("polite");
+ expect(featuredLiveMode({ rotating: false, paused: true })).toBe("polite");
+ });
+});
diff --git a/tests/feed-view.test.ts b/tests/feed-view.test.ts
index 48bcfbb..4c51248 100644
--- a/tests/feed-view.test.ts
+++ b/tests/feed-view.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
-import { FeedView, type FeedViewProps } from "@/components/FeedView";
+import { FeedView, RefreshButton, type FeedViewProps } from "@/components/FeedView";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
-import { mergePosts, relativeTime, toCardPosts, upgradeVideos } from "@/lib/feed-model";
+import { commentsLabel, firstLoadFailure, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const post = (id: string, publishedAt: string, extra: Record = {}) => ({
@@ -115,9 +115,11 @@ describe("FeedView", () => {
expect(html).toContain("Try again");
});
- it("spinner while loading more and a new-posts control", () => {
+ it("skeleton card while loading more and the new-posts pill", () => {
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
- expect(html).toContain('class="spinner"');
+ expect(html).toContain('data-testid="feed-skeleton"');
+ expect(html).not.toContain('class="spinner"');
+ expect(html).toContain("btn-grow");
expect(html).toContain("2 new posts");
expect(html).not.toContain("Load more");
});
@@ -185,3 +187,83 @@ describe("video transcoding", () => {
expect(live).toContain(" {
+ it("reads videoDuration (seconds) first and formats it as m:ss", () => {
+ const [m] = toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", videoDuration: 154, duration: 9 }] } })!.media;
+ expect(m.durationSeconds).toBe(154);
+ expect(formatDuration(154)).toBe("2:34");
+ expect(formatDuration(5)).toBe("0:05");
+ expect(formatDuration(3725)).toBe("1:02:05");
+ expect(formatDuration(0)).toBeNull();
+ expect(formatDuration(undefined)).toBeNull();
+ });
+ it("falls back to durationSeconds, then duration, else none", () => {
+ const dur = (item: object) => toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", ...item }] } })!.media[0].durationSeconds;
+ expect(dur({ durationSeconds: 30 })).toBe(30);
+ expect(dur({ duration: 12 })).toBe(12);
+ expect(dur({})).toBeUndefined();
+ });
+});
+
+describe("refresh button", () => {
+ it("is in the Home feed header with its accessible name", () => {
+ const html = render({ onRefresh: () => {} });
+ expect(html).toContain('aria-label="Refresh feed"');
+ expect(html.indexOf("Home feed")).toBeLessThan(html.indexOf("Refresh feed"));
+ });
+
+ it("is absent without a handler", () => {
+ expect(render({})).not.toContain("Refresh feed");
+ });
+
+ it("triggers a reload when clicked", () => {
+ const onRefresh = vi.fn();
+ const el = RefreshButton({ onRefresh }) as { props: { onClick: () => void } };
+ el.props.onClick();
+ expect(onRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("is disabled and spinning while a reload runs", () => {
+ const html = render({ onRefresh: () => {}, refreshing: true });
+ expect(html).toMatch(/]*disabled[^>]*aria-label="Refresh feed"|]*aria-label="Refresh feed"[^>]*disabled/);
+ expect(html).toContain("refresh-icon-spin");
+ });
+});
+
+describe("comments label", () => {
+ it("is singular for 1 and plural otherwise", () => {
+ expect(commentsLabel(1)).toBe("1 comment");
+ expect(commentsLabel(0)).toBe("0 comments");
+ expect(commentsLabel(2)).toBe("2 comments");
+ });
+
+ it("renders singular on a one-comment post", () => {
+ const posts = toCardPosts([{ ...post("1", "2026-10-19T11:00:00Z"), commentsCount: 1 }]);
+ const html = render({ posts });
+ expect(html).toContain("1 comment<");
+ expect(html).not.toContain("1 comments");
+ });
+});
+
+describe("failed refresh versus failed first load", () => {
+ it("a failed refresh keeps the posts and shows an inline error with a usable button", () => {
+ const posts = toCardPosts([post("1", "2026-10-19T11:00:00Z")]);
+ const html = render({ posts, onRefresh: () => {}, refreshFailed: true });
+ expect(html).toContain("Could not refresh. Try again.");
+ expect(html).toContain("text 1");
+ expect(html).not.toContain("The feed did not load.");
+ expect(html).not.toMatch(/]*disabled[^>]*aria-label="Refresh feed"|]*aria-label="Refresh feed"[^>]*disabled/);
+ });
+
+ it("a failed first load shows the full error state", () => {
+ const html = render({ status: "error", onRefresh: () => {} });
+ expect(html).toContain("The feed did not load.");
+ expect(html).not.toContain("Could not refresh");
+ });
+
+ it("the loader maps a failure to the right outcome", () => {
+ expect(firstLoadFailure(true)).toEqual({ fullError: false, refreshFailed: true });
+ expect(firstLoadFailure(false)).toEqual({ fullError: true, refreshFailed: false });
+ });
+});
diff --git a/tests/hex-avatar.test.ts b/tests/hex-avatar.test.ts
new file mode 100644
index 0000000..eff7e96
--- /dev/null
+++ b/tests/hex-avatar.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { HexAvatar, HEX_SIZES, HEX_TONES, toneFor } from "@/components/HexAvatar";
+
+const render = (props: Parameters[0]) => renderToStaticMarkup(createElement(HexAvatar, props));
+
+describe("HexAvatar", () => {
+ it("renders the photo when there is one", () => {
+ const html = render({ name: "Ada Lovelace", pictureUrl: "https://img/a.jpg", size: "lg" });
+ expect(html).toContain(" {
+ const html = render({ name: "Ada Lovelace", pictureUrl: null, size: "md" });
+ expect(html).not.toContain(" AL<");
+ expect(html).toContain("hex-avatar-initials");
+ expect(html).toContain(`hex-avatar-${toneFor("Ada Lovelace")}`);
+ expect(html).toContain('aria-hidden="true"');
+ });
+
+ it("falls back to initials for an empty picture url", () => {
+ expect(render({ name: "Bo", pictureUrl: "" })).toContain(">B<");
+ });
+
+ it("has the three spec sizes at a 0.866 ratio", () => {
+ expect(HEX_SIZES).toEqual({
+ lg: { width: 46, height: 53 },
+ md: { width: 38, height: 44 },
+ sm: { width: 30, height: 35 },
+ });
+ for (const { width, height } of Object.values(HEX_SIZES)) {
+ expect(width / height).toBeCloseTo(0.866, 1);
+ }
+ });
+
+ it("picks the same tone for the same name, from the six tones", () => {
+ expect(toneFor("Ada Lovelace")).toBe(toneFor(" ada lovelace "));
+ for (const n of ["A", "Bo", "Carl Sagan", "Dee"]) expect(HEX_TONES).toContain(toneFor(n));
+ });
+});
diff --git a/tests/mobile-dock.test.ts b/tests/mobile-dock.test.ts
new file mode 100644
index 0000000..146a760
--- /dev/null
+++ b/tests/mobile-dock.test.ts
@@ -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, 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}`);
+ 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;/);
+ });
+});
diff --git a/tests/mobile-feed.test.ts b/tests/mobile-feed.test.ts
new file mode 100644
index 0000000..0330d53
--- /dev/null
+++ b/tests/mobile-feed.test.ts
@@ -0,0 +1,259 @@
+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";
+import { pickQuoteFit, QUOTE_SIZES } from "@/components/SlideText";
+
+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, 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).toMatch(/class="slide-quote" data-fit-size="31" style="font-size:31px">text t1<\/p>/);
+ expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/);
+ expect(html).toContain("slide-scrim-top");
+ expect(html).toMatch(/class="slide-text"[^>]*>text i1<\/p>/);
+ expect(html).toContain('aria-label="Play video"');
+ // The quote slide shows its text once, as the quote.
+ expect(html.match(/text t1/g)).toHaveLength(1);
+ });
+
+ it("marks the first slide as the one on screen", () => {
+ const html = render({});
+ expect(html).toMatch(/class="slide slide-on-text is-cur slide-own"/);
+ 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-mark \{[^}]*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 = /]*>/.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/);
+ });
+});
+
+describe("videoFit", () => {
+ it("fills the slide for 4:5 and taller, shows whole for wider, covers while unknown", async () => {
+ const { videoFit } = await import("@/components/MobileFeed");
+ expect(videoFit(720, 1280)).toBe("cover");
+ expect(videoFit(1080, 1350)).toBe("cover");
+ expect(videoFit(1080, 1080)).toBe("contain");
+ expect(videoFit(1920, 1080)).toBe("contain");
+ expect(videoFit(0, 0)).toBe("cover");
+ });
+});
+
+describe("long post text", () => {
+ it("picks the largest quote size that fits, measured, not counted", () => {
+ const fitsBelow = (max: number) => (size: number) => size <= max;
+ expect(pickQuoteFit(fitsBelow(40), () => 0)).toEqual({ size: 31, lines: 0 });
+ expect(pickQuoteFit(fitsBelow(26), () => 0)).toEqual({ size: 26, lines: 0 });
+ expect(pickQuoteFit(fitsBelow(22), () => 0)).toEqual({ size: 22, lines: 0 });
+ expect(pickQuoteFit(fitsBelow(19), () => 0)).toEqual({ size: 19, lines: 0 });
+ expect([...QUOTE_SIZES]).toEqual([31, 26, 22, 19]);
+ });
+
+ it("clamps at 19px to the whole lines that fit when nothing fits", () => {
+ const tried: number[] = [];
+ const fit = pickQuoteFit((s) => (tried.push(s), false), () => 17.8);
+ expect(tried).toEqual([31, 26, 22, 19]);
+ expect(fit).toEqual({ size: 19, lines: 17 });
+ expect(pickQuoteFit(() => false, () => 0.2)).toEqual({ size: 19, lines: 1 });
+ });
+
+ it("shows no button on short text until a measurement says it is long", () => {
+ const html = render({});
+ expect(html).not.toContain("Read more");
+ expect(html).not.toMatch(/>more<\/button>/);
+ expect(html).not.toContain("slide-reader");
+ });
+
+ it("keeps taps on open text from counting toward a double-tap like", () => {
+ const el = { closest: (sel: string) => (sel.includes("[data-expanded-text]") ? {} : null) } as unknown as Element;
+ expect(isTapTarget(el)).toBe(false);
+ });
+});
+
+describe("long post text stylesheet", () => {
+ const css = readFileSync("src/styles/mobile.css", "utf8");
+ it("fits the quote between the top bar and the author block", () => {
+ expect(css).toMatch(/\.slide-quote-w \{[^}]*top: calc\(72px[^}]*bottom: 181px[^}]*overflow: hidden/);
+ });
+ it("makes the buttons at least 44px tall", () => {
+ expect(css).toMatch(/\.slide-more \{[^}]*min-height: 44px/);
+ });
+ it("holds the reel still while text is open, and the open text scrolls", () => {
+ expect(css).toMatch(/\.reel\[data-locked="true"\] \{ overflow-y: hidden; \}/);
+ expect(css).toMatch(/\.slide-reader-scroll \{[^}]*overflow-y: auto/);
+ expect(css).toMatch(/\.slide-text\.is-open \{[^}]*overflow-y: auto/);
+ expect(css).toMatch(/\.slide-text-open \.slide-scrim \{[^}]*var\(--media-scrim\)/);
+ });
+ it("drops the expand animation under reduced motion", () => {
+ expect(css).toMatch(/prefers-reduced-motion: reduce\) \{[^}]*\.slide-reader,\s*\.slide-text\.is-open,/);
+ });
+});
diff --git a/tests/post.test.ts b/tests/post.test.ts
index fc290e0..8d64d22 100644
--- a/tests/post.test.ts
+++ b/tests/post.test.ts
@@ -158,7 +158,8 @@ describe("Composer", () => {
const html = renderToStaticMarkup(createElement(Composer, { ...base, file: f, onFile: () => {} }));
expect(html).toContain("Add image");
expect(html).toContain("photo.jpg");
- expect(html).not.toMatch(/]*class="composer-post"[^>]*disabled/);
+ expect(html).toMatch(/]*type="submit"[^>]*class="[^"]*composer-post/);
+ expect(html).not.toMatch(/]*class="[^"]*composer-post[^"]*"[^>]*disabled/);
});
it("keeps the text and the image name on a publish failure", () => {
const f = new File(["x"], "sunset.png", { type: "image/png" });
diff --git a/tests/theme.test.ts b/tests/theme.test.ts
new file mode 100644
index 0000000..efaac74
--- /dev/null
+++ b/tests/theme.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import path from "node:path";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { applyTheme, currentTheme, readStoredTheme, THEME_KEY, THEME_SCRIPT } from "@/lib/theme";
+import { useTheme } from "@/lib/use-theme";
+
+const memoryStorage = (initial: Record = {}) => {
+ const data = new Map(Object.entries(initial));
+ return {
+ data,
+ getItem: (k: string) => data.get(k) ?? null,
+ setItem: (k: string, v: string) => void data.set(k, v),
+ };
+};
+const throwingStorage = {
+ getItem: () => {
+ throw new Error("blocked");
+ },
+ setItem: () => {
+ throw new Error("blocked");
+ },
+};
+const fakeRoot = () => ({ dataset: {} as DOMStringMap, style: { colorScheme: "" } });
+
+describe("readStoredTheme", () => {
+ it("is dark on a first visit", () => {
+ expect(readStoredTheme(memoryStorage())).toBe("dark");
+ expect(readStoredTheme(null)).toBe("dark");
+ });
+ it("returns a stored light or dark choice", () => {
+ expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "light" }))).toBe("light");
+ expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "dark" }))).toBe("dark");
+ });
+ it("ignores junk values and storage that throws", () => {
+ expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "neon" }))).toBe("dark");
+ expect(readStoredTheme(throwingStorage)).toBe("dark");
+ });
+});
+
+describe("applyTheme", () => {
+ it("sets the attribute and color-scheme and stores the choice", () => {
+ const root = fakeRoot();
+ const storage = memoryStorage();
+ applyTheme("light", root, storage);
+ expect(root.dataset.theme).toBe("light");
+ expect(root.style.colorScheme).toBe("light");
+ expect(storage.data.get(THEME_KEY)).toBe("light");
+ expect(currentTheme(root)).toBe("light");
+ });
+ it("still changes the theme when storage throws", () => {
+ const root = fakeRoot();
+ expect(() => applyTheme("light", root, throwingStorage)).not.toThrow();
+ expect(root.dataset.theme).toBe("light");
+ });
+ it("reads a missing attribute as dark", () => {
+ expect(currentTheme(fakeRoot())).toBe("dark");
+ });
+});
+
+describe("THEME_SCRIPT (pre-paint)", () => {
+ const run = (storage: unknown) => {
+ const documentElement = fakeRoot();
+ new Function("localStorage", "document", THEME_SCRIPT)(storage, { documentElement });
+ return documentElement;
+ };
+ it("applies a stored light theme", () => {
+ expect(run(memoryStorage({ [THEME_KEY]: "light" })).dataset.theme).toBe("light");
+ });
+ it("falls back to dark for nothing stored, junk, or throwing storage", () => {
+ expect(run(memoryStorage()).dataset.theme).toBe("dark");
+ expect(run(memoryStorage({ [THEME_KEY]: "x" })).dataset.theme).toBe("dark");
+ expect(run(throwingStorage).dataset.theme).toBe("dark");
+ });
+});
+
+describe("useTheme", () => {
+ it("renders dark on the server", () => {
+ let seen = "";
+ const Probe = () => {
+ seen = useTheme().theme;
+ return null;
+ };
+ renderToStaticMarkup(createElement(Probe));
+ expect(seen).toBe("dark");
+ });
+});
+
+describe("no raw colors outside the token definitions", () => {
+ const files = (dir: string): string[] =>
+ readdirSync(dir).flatMap((f) => {
+ const p = path.join(dir, f);
+ return statSync(p).isDirectory() ? files(p) : [p];
+ });
+ it("has no hex color in any source file except globals.css", () => {
+ const src = path.resolve(__dirname, "../src");
+ const offenders = files(src)
+ .filter((f) => /\.(tsx?|css)$/.test(f) && !f.endsWith(path.join("app", "globals.css")))
+ .filter((f) => /#[0-9a-fA-F]{3,8}\b|rgba?\(/.test(readFileSync(f, "utf8")));
+ expect(offenders).toEqual([]);
+ });
+});