Feed browser: Desktop bento layout #3

Merged
calcazar merged 13 commits from feature/feed-browser-desktop-bento-layout into develop 2026-10-20 08:10:54 -06:00
37 changed files with 2802 additions and 393 deletions
+3 -2
View File
@@ -130,7 +130,8 @@ A compact spotlight that moves on its own. Nobody has to click or swipe. These a
- Every 2.8 seconds the next expert moves to the center with a 1.1s ease.
- Below: the name in serif, the byline, the rating and review count. The caption fades up on each change.
- Progress dots: the active one is a crimson bar. A counter ("3 / 10") sits in the panel header.
- Hover pauses the rotation. `prefers-reduced-motion` stops autoplay.
- Hover, or keyboard focus inside the carousel, pauses the rotation. `prefers-reduced-motion` stops autoplay.
- Live region (WAI-ARIA carousel pattern): `aria-live="off"` while autoplay is rotating, so slides are not announced every 2.8 seconds; `aria-live="polite"` when rotation is paused (hover or focus) or stopped (reduced motion, or a single expert).
### Trending minnects
@@ -198,7 +199,7 @@ All sheets slide up from the bottom (450ms) over a dimmed, slightly blurred back
- Every control is a real `<button>`, `<a>` or `<input>` with a label. Icon-only buttons have `aria-label`.
- The like button uses `aria-pressed` and labels its count. Comments toggles use `aria-expanded`.
- The carousel region is `aria-live="polite"`. The non-center items are `aria-hidden`.
- The carousel region follows the WAI-ARIA carousel pattern: `aria-live="off"` while autoplay rotates, `aria-live="polite"` while rotation is paused by hover or keyboard focus, or stopped by reduced motion. Keyboard focus inside the carousel pauses it. The non-center items are `aria-hidden`.
- Visible focus ring on every control, `--ink` colored.
- Touch targets at least 44px on mobile.
+11
View File
@@ -0,0 +1,11 @@
// Dev-only preview of the signed-in feed shell with fixture data (no Minnect
// login, no network). Not linked from the app and returns 404 in production.
import { notFound } from "next/navigation";
import { FeedPreview } from "@/dev/FeedPreview";
export const metadata = { title: "Feed (dev)" };
export default function FeedDevPage() {
if (process.env.NODE_ENV === "production") notFound();
return <FeedPreview />;
}
+35
View File
@@ -19,6 +19,8 @@
--danger: #FF6B70;
--glass: rgba(18,18,21,.72);
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
/* Panels (bento tiles): a faint top highlight and a soft drop. */
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 0 0 1px rgba(255,255,255,.02), 0 24px 44px -24px rgba(0,0,0,.8);
/* Buttons: drop shadows and the soft-depth (neumorphic) light and shade. */
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
@@ -42,6 +44,7 @@
--danger: #B3121A;
--glass: rgba(251,250,248,.74);
--shadow-pop: 0 10px 28px rgba(26,28,31,.14);
--shadow-tile: inset 0 1px 0 rgba(255,255,255,.035), 0 1px 2px rgba(26,28,31,.04), 0 16px 36px -20px rgba(26,28,31,.22);
--shadow-btn: 0 10px 20px -6px rgba(26,28,31,.22);
--shadow-float: 0 14px 28px -12px rgba(26,28,31,.3);
--neu-d: rgba(26,28,31,.17);
@@ -89,3 +92,35 @@
}
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
}
/* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off
card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and
the light sweep on buttons, the featured glow, caption fade and slide (its
autoplay is stopped in FeaturedCarousel via matchMedia), and stops snap and smooth scrolling. State
changes still happen instantly. The loading spinner is kept, only slowed,
because it is the only sign that something is loading. */
@media (prefers-reduced-motion: reduce) {
.post,
.post-cm,
.post-pop,
.feed-pill,
.feed-skel::after,
.post-like-beat .post-heart,
.btn-spark *,
.btn-spark::before,
.btn-spark::after,
.btn-gloss::before,
.btn-gloss::after,
.btn-sweep::before,
.account-dot,
.featured-cf::before,
.featured-cap,
.minnect-card { animation: none !important; }
.featured-item,
.featured-ring,
.featured-dot { transition: none !important; }
.btn-sweep::before,
.btn-gloss::after { transition: none !important; }
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
.spinner { animation-duration: 2.4s !important; }
}
+7
View File
@@ -4,9 +4,16 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
import "./globals.css";
import "../styles/type.css";
import "../styles/layout.css";
import "../styles/buttons.css";
import "../styles/avatar.css";
import "../styles/signin.css";
import "../styles/post.css";
import "../styles/composer.css";
import "../styles/featured.css";
import "../styles/feed.css";
import "../styles/trending.css";
import "../styles/search.css";
import "../styles/legacy.css";
const manrope = Manrope({
+7 -25
View File
@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { loadSession, type Person } from "@/lib/auth-client";
import { SignInForm } from "./SignInForm";
import { Logo } from "./Logo";
import { AppShell } from "./AppShell";
import { Feed } from "./Feed";
import { DiscoverRails } from "./DiscoverRails";
import { ExpertSearch } from "./ExpertSearch";
@@ -41,28 +40,11 @@ export function App() {
}} />;
}
return (
<main className="shell">
<header>
<Logo size={32} />
<div>
<strong>{state.person.name}</strong>
<span>{state.person.email}</span>
</div>
</header>
<div className="columns">
<nav className="col-left" aria-label="Main">
<ul>
<li><Link href="/" aria-current="page">Feed</Link></li>
</ul>
<ExpertSearch />
</nav>
<div className="col-center">
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</div>
<aside className="col-right" aria-label="Discover">
<DiscoverRails />
</aside>
</div>
</main>
<AppShell
person={state.person}
search={<ExpertSearch />}
feed={<Feed feedUserId={state.person.feedUserId} person={state.person} onSignedOut={() => setState({ kind: "out" })} />}
discover={<DiscoverRails />}
/>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import type { ReactNode } from "react";
import type { Person } from "@/lib/auth-client";
import { useTheme } from "@/lib/use-theme";
import { HexAvatar } from "./HexAvatar";
import { Logo } from "./Logo";
const iconProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
function SunIcon() {
return (
<svg {...iconProps}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
function MoonIcon() {
return (
<svg {...iconProps}>
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" />
</svg>
);
}
/** Left account panel: logo, wordmark, theme toggle, the person, live status. */
export function AccountPanel({ person }: { person: Person }) {
const { theme, toggleTheme } = useTheme();
const next = theme === "dark" ? "light" : "dark";
return (
<section className="tile account" aria-label="Account">
<div className="account-brand">
<Logo size={32} />
<span className="account-word type-wordmark">MINNECT</span>
<button type="button" className="account-theme" aria-label={`Switch to ${next} theme`} title={`Switch to ${next} theme`} onClick={toggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
</div>
<div className="account-me">
<HexAvatar name={person.name || person.email} pictureUrl={person.pictureUrl} size="lg" />
<div className="account-me-text">
<strong>{person.name}</strong>
<span>{person.email}</span>
</div>
</div>
<div className="account-live">
<span className="account-dot" aria-hidden="true" />
Feed updates live
</div>
</section>
);
}
export interface AppShellProps {
person: Person;
/** Find experts panel (left column, under the account panel). */
search: ReactNode;
/** Feed panel (center column). */
feed: ReactNode;
/** Featured experts and Trending minnects panels (right column). */
discover: ReactNode;
}
/** Signed-in bento shell (spec "Desktop and tablet layout", ADR 0001 section 3). */
export function AppShell({ person, search, feed, discover }: AppShellProps) {
return (
<div className="app">
<main className="bento">
<div className="bento-col bento-left">
<AccountPanel person={person} />
{search}
</div>
<div className="bento-col bento-center">{feed}</div>
<aside className="bento-col bento-right" aria-label="Discover">
{discover}
</aside>
</main>
</div>
);
}
+1 -1
View File
@@ -227,8 +227,8 @@ export interface IconButtonProps extends NativeButton {
export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) {
return (
<button
{...rest}
type={type}
{...rest}
aria-label={label}
title={rest.title ?? label}
className={cx("btn-gloss", shape === "round" && "btn-gloss-round", size === "sm" && "btn-gloss-sm", className)}
+17 -12
View File
@@ -1,4 +1,6 @@
import type { CommentItem } from "@/lib/comment-model";
import { HexAvatar } from "@/components/HexAvatar";
import { IconButton, SendIcon } from "@/components/Buttons";
import { relativeTime } from "@/lib/feed-model";
export interface CommentsPanelProps {
@@ -17,30 +19,33 @@ export interface CommentsPanelProps {
export function CommentsPanel(p: CommentsPanelProps) {
const empty = !p.value.trim();
return (
<div className="comments" data-testid="comments-panel">
<div className="post-cm" data-testid="comments-panel">
{p.loading ? <span className="spinner" role="status" aria-label="Loading comments" /> : null}
{!p.loading && p.comments.length === 0 && !p.error ? (
<p className="comments-empty">No comments yet.</p>
<p className="post-cm-empty">No comments yet.</p>
) : null}
<ul className="comment-list">
<ul className="post-cm-list">
{p.comments.map((c) => (
<li key={c.key} className={c.removed ? "comment removed" : "comment"} data-testid="comment">
<div className="comment-head">
<strong>{c.authorName}</strong>
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
<li key={c.key} className="post-c" data-testid="comment">
<HexAvatar name={c.authorName} size="sm" />
<div className={c.removed ? "post-c-body post-c-removed" : "post-c-body"}>
<div className="post-c-head">
<strong>{c.authorName}</strong>
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
</div>
{c.removed ? <p>This comment was removed.</p> : <p>{c.content}</p>}
</div>
{c.removed ? <p className="comment-removed">This comment was removed.</p> : <p>{c.content}</p>}
</li>
))}
</ul>
{p.error ? (
<p className="comment-error" role="alert">
<p className="post-error" role="alert">
{p.error}{" "}
{p.onRetry ? <button type="button" className="link-btn" onClick={p.onRetry}>Try again</button> : null}
{p.onRetry ? <button type="button" className="post-link" onClick={p.onRetry}>Try again</button> : null}
</p>
) : null}
<form
className="comment-form"
className="post-cm-form"
onSubmit={(e) => {
e.preventDefault();
if (!empty && !p.busy) p.onSubmit?.();
@@ -54,7 +59,7 @@ export function CommentsPanel(p: CommentsPanelProps) {
maxLength={5000}
onChange={(e) => p.onChange?.(e.target.value)}
/>
<button type="submit" disabled={empty || p.busy}>{p.busy ? "Posting" : "Post"}</button>
<IconButton type="submit" disabled={empty || p.busy} label={p.busy ? "Posting" : "Post"} icon={<SendIcon />} />
</form>
</div>
);
+260 -43
View File
@@ -1,6 +1,18 @@
"use client";
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
// Composer (spec "Composer", ADR 0001). Styles live in src/styles/composer.css.
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type FocusEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import { FlipButton, type FlipState } from "./Buttons";
import { HexAvatar } from "./HexAvatar";
import { attachmentMeta, COMPOSER_EMOJI, insertBullets } from "./composer-text";
export interface ComposerProps {
value: string;
@@ -12,17 +24,42 @@ export interface ComposerProps {
/** When present, the composer shows image and video pickers and reports the choice. */
onFile?: (file: File | null) => void;
onSubmit: () => void;
/** The signed-in person, shown as a hex avatar beside the field. */
person?: { name: string; email?: string; pictureUrl?: string | null } | null;
}
// Content types the picker offers (spec: allowed image types).
const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic";
const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm";
export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit }: ComposerProps) {
const iconProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
/** True for Ctrl + Enter or Cmd + Enter. */
export function isPublishShortcut(e: { key: string; ctrlKey: boolean; metaKey: boolean }): boolean {
return e.key === "Enter" && (e.ctrlKey || e.metaKey);
}
export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit, person }: ComposerProps) {
const empty = value.trim().length === 0;
// A post needs either text or a file.
const canSubmit = (!empty || !!file) && !busy;
const [preview, setPreview] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const [emojiOpen, setEmojiOpen] = useState(false);
const text = useRef<HTMLTextAreaElement | null>(null);
const imageInput = useRef<HTMLInputElement | null>(null);
const videoInput = useRef<HTMLInputElement | null>(null);
const emojiWrap = useRef<HTMLDivElement | null>(null);
// Caret to restore after a toolbar edit re-renders the text.
const pendingSelection = useRef<[number, number] | null>(null);
// Build a preview URL client-side only, and revoke it when the file changes.
useEffect(() => {
@@ -35,10 +72,44 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
return () => URL.revokeObjectURL(url);
}, [file]);
useEffect(() => {
const sel = pendingSelection.current;
if (!sel || !text.current) return;
pendingSelection.current = null;
text.current.focus();
text.current.setSelectionRange(sel[0], sel[1]);
}, [value]);
// Close the emoji popover on an outside click or Escape.
useEffect(() => {
if (!emojiOpen) return;
function onDown(e: MouseEvent) {
if (emojiWrap.current && !emojiWrap.current.contains(e.target as Node)) setEmojiOpen(false);
}
function onKey(e: globalThis.KeyboardEvent) {
if (e.key === "Escape") setEmojiOpen(false);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [emojiOpen]);
useEffect(() => {
if (busy) setEmojiOpen(false);
}, [busy]);
function submit(e: FormEvent) {
e.preventDefault();
if (canSubmit) onSubmit();
}
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (!isPublishShortcut(e)) return;
e.preventDefault();
if (canSubmit) onSubmit();
}
function pick(e: ChangeEvent<HTMLInputElement>) {
const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null;
onFile?.(chosen);
@@ -48,56 +119,202 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
function remove() {
onFile?.(null);
}
function addEmoji(emoji: string) {
// Spec: the emoji goes at the end of the text.
const next = value + emoji;
pendingSelection.current = [next.length, next.length];
onChange(next);
setEmojiOpen(false);
}
function addBullets() {
const el = text.current;
const start = el ? el.selectionStart : value.length;
const end = el ? el.selectionEnd : value.length;
const edit = insertBullets(value, start, end);
pendingSelection.current = [edit.selectionStart, edit.selectionEnd];
if (edit.text !== value) onChange(edit.text);
else el?.focus();
}
function onFocus() {
setFocused(true);
}
function onBlur(e: FocusEvent<HTMLFormElement>) {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
}
const isVideo = !!file && file.type.startsWith("video/");
const open = focused || !empty || !!file;
const flip: FlipState = busy ? "posting" : canSubmit ? "ready" : "disabled";
return (
<form className="composer" onSubmit={submit}>
<label htmlFor="composer-text" className="composer-label">New post</label>
<textarea
id="composer-text"
value={value}
rows={3}
placeholder="Share something with the feed"
disabled={busy}
onChange={(e) => onChange(e.target.value)}
/>
{onFile ? (
<div className="composer-media">
<label className="composer-image">
<span>Add image</span>
<input
type="file"
accept={IMAGE_ACCEPT}
disabled={busy}
onChange={pick}
/>
</label>
<label className="composer-image composer-video">
<span>Add video</span>
<input type="file" accept={VIDEO_ACCEPT} disabled={busy} onChange={pick} />
</label>
{file ? (
<div className="composer-preview">
{preview && isVideo ? (
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
) : preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt={`Selected image: ${file.name}`} />
) : null}
<span className="composer-filename">{file.name}</span>
<button type="button" className="composer-remove" onClick={remove} disabled={busy}>
Remove
</button>
<form
className={`composer${open ? " composer-open" : ""}`}
onSubmit={submit}
onFocus={onFocus}
onBlur={onBlur}
>
<div className="composer-top">
{person ? <HexAvatar name={person.name || person.email || ""} pictureUrl={person.pictureUrl} size="lg" /> : null}
<label htmlFor="composer-text" className="composer-label">New post</label>
<textarea
id="composer-text"
ref={text}
value={value}
placeholder="Share an insight with the feed"
disabled={busy}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
</div>
{onFile && file ? (
<div className={`composer-att${person ? " composer-att-indent" : ""}`}>
<span className="composer-thumb">
{preview && isVideo ? (
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
) : preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt={`Selected image: ${file.name}`} />
) : null}
{isVideo ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5.5v13l11-6.5z" fill="currentColor" />
</svg>
) : null}
</span>
<div className="composer-att-text">
<strong className="composer-filename">{file.name}</strong>
<span>{attachmentMeta(file)}{busy ? ", uploading" : ""}</span>
<div
className={`composer-prog${busy ? " composer-prog-busy" : ""}`}
role="progressbar"
aria-label={busy ? "Uploading" : "Attached"}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={busy ? undefined : 100}
>
<i />
</div>
) : null}
</div>
<button
type="button"
className="composer-tool composer-remove"
aria-label="Remove attachment"
title="Remove attachment"
onClick={remove}
disabled={busy}
>
<svg {...iconProps}>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
) : null}
{error ? (
<p className="error" role="alert">Publishing the post failed. Your text is still here.</p>
) : null}
<button type="submit" className="composer-post" disabled={!canSubmit}>
{busy ? "Posting" : "Post"}
</button>
<div className="composer-bar">
{onFile ? (
<>
<button
type="button"
className="composer-tool"
aria-label="Add image"
title="Add image"
disabled={busy}
onClick={() => imageInput.current?.click()}
>
<svg {...iconProps}>
<rect x="3" y="4" width="18" height="16" rx="3" />
<circle cx="9" cy="10" r="1.8" />
<path d="m21 16-5-5-9 9" />
</svg>
</button>
<input
ref={imageInput}
className="composer-file"
type="file"
accept={IMAGE_ACCEPT}
disabled={busy}
tabIndex={-1}
aria-hidden="true"
onChange={pick}
/>
<button
type="button"
className="composer-tool"
aria-label="Add video"
title="Add video"
disabled={busy}
onClick={() => videoInput.current?.click()}
>
<svg {...iconProps}>
<rect x="2.5" y="5.5" width="13" height="13" rx="3" />
<path d="m15.5 10 6-3.5v11l-6-3.5" />
</svg>
</button>
<input
ref={videoInput}
className="composer-file"
type="file"
accept={VIDEO_ACCEPT}
disabled={busy}
tabIndex={-1}
aria-hidden="true"
onChange={pick}
/>
</>
) : null}
<div className="composer-emoji-wrap" ref={emojiWrap}>
<button
type="button"
className={`composer-tool${emojiOpen ? " composer-tool-on" : ""}`}
aria-label="Add emoji"
title="Emoji"
aria-expanded={emojiOpen}
aria-haspopup="dialog"
disabled={busy}
onClick={() => setEmojiOpen((o) => !o)}
>
<svg {...iconProps}>
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
<path d="M9 9.5h.01M15 9.5h.01" strokeWidth={2.8} />
</svg>
</button>
{emojiOpen ? (
<div className="composer-emoji" role="dialog" aria-label="Emoji">
<span className="composer-eyebrow">Frequently used</span>
<div className="composer-emoji-grid">
{COMPOSER_EMOJI.map((m) => (
<button type="button" key={m} aria-label={`Insert ${m}`} onClick={() => addEmoji(m)}>
{m}
</button>
))}
</div>
</div>
) : null}
</div>
<span className="composer-sep" aria-hidden="true" />
<button
type="button"
className="composer-tool"
aria-label="Bulleted list"
title="Bulleted list"
disabled={busy}
onMouseDown={(e) => e.preventDefault()}
onClick={addBullets}
>
<svg {...iconProps}>
<path d="M9 6h11M9 12h11M9 18h11" />
<path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" strokeWidth={2.8} />
</svg>
</button>
<span className="composer-spacer" />
<span className="composer-kbd" aria-hidden="true">
<b>Ctrl</b> + <b>Enter</b>
</span>
<FlipButton state={flip} className="composer-post" type="submit" aria-keyshortcuts="Control+Enter Meta+Enter" />
</div>
</form>
);
}
+2 -2
View File
@@ -3,14 +3,14 @@
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 (
<>
<FeaturedExpertsRail experts={data.featuredExperts} />
<FeaturedCarousel experts={data.featuredExperts} />
<TrendingMinnectsRail minnects={data.trendingMinnects} />
</>
);
+37 -32
View File
@@ -1,5 +1,6 @@
"use client";
import { SweepButton } from "@/components/Buttons";
import { HexAvatar } from "@/components/HexAvatar";
import { useEffect, useRef, useState } from "react";
import type { DiscoverCategory } from "@/lib/discover";
@@ -17,62 +18,70 @@ export interface ExpertSearchViewProps {
onCategory: (name: string | null) => void;
status: SearchStatus;
results: ExpertResult[];
defaultOpen?: boolean;
}
export function ExpertSearchView(p: ExpertSearchViewProps) {
return (
<details className="expert-search" open={p.defaultOpen}>
<summary>Find experts</summary>
<form role="search" onSubmit={(e) => e.preventDefault()}>
<section className="tile panel expert-search" aria-labelledby="h-find">
<div className="panel-head">
<h2 id="h-find" className="type-title type-title-strong">Find experts</h2>
</div>
<form role="search" className="search-form" onSubmit={(e) => e.preventDefault()}>
<label htmlFor="expert-q">Search experts</label>
<input
id="expert-q"
type="search"
value={p.query}
placeholder="Name or topic"
autoComplete="off"
maxLength={80}
onChange={(e) => p.onQuery(e.target.value)}
/>
<div className="search-field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
<input
id="expert-q"
type="search"
value={p.query}
placeholder="Name or topic"
autoComplete="off"
maxLength={80}
onChange={(e) => p.onQuery(e.target.value)}
/>
</div>
</form>
{p.categories.length > 0 ? (
<div role="group" aria-label="Browse experts by category" className="chips">
{p.categories.map((c) => (
<button
<SweepButton
key={String(c.id)}
type="button"
className="chip"
aria-pressed={p.activeCategory === c.name}
label={c.name}
selected={p.activeCategory === c.name}
onClick={() => p.onCategory(p.activeCategory === c.name ? null : c.name)}
>
{c.name}
</button>
/>
))}
</div>
) : null}
<div aria-live="polite" className="search-results">
<div aria-live="polite" className="search-results panel-scroll fade-edges">
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
{p.status === "ready" && p.results.length === 0 ? (
<p className="search-note">No experts found.</p>
) : null}
{p.status === "ready" && p.results.length > 0 ? (
<ul className="rail-list">
<ul className="rail-list search-rows">
{p.results.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
<li key={String(e.id)} className="expert-row" data-testid="search-result">
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
{e.rating !== null ? <span className="rail-meta">★ {e.rating.toFixed(1)}</span> : null}
<div className="expert-text">
<strong>{e.displayName || "Expert"}</strong>
{e.byline ? <span>{e.byline}</span> : null}
</div>
{e.rating !== null ? (
<span className="expert-rate" aria-label={`Rating ${e.rating.toFixed(1)}`}>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 2.8 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.6l-5.8 3.1 1.1-6.5L2.6 9.6l6.5-.9L12 2.8Z" /></svg>
{e.rating.toFixed(1)}
</span>
) : null}
</li>
))}
</ul>
) : null}
</div>
</details>
</section>
);
}
@@ -84,11 +93,9 @@ export function ExpertSearch() {
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
const [status, setStatus] = useState<SearchStatus>("idle");
const [results, setResults] = useState<ExpertResult[]>([]);
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); },
@@ -117,8 +124,6 @@ export function ExpertSearch() {
return (
<ExpertSearchView
key={open ? "open" : "closed"}
defaultOpen={open}
query={query}
onQuery={setQuery}
categories={categories}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
import { toneFor } from "./HexAvatar";
import { featuredLiveMode, featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
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 (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" fill="currentColor" />
</svg>
);
}
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 <img className="featured-photo" src={expert.pictureUrl} alt={center ? name : ""} />;
}
return (
<span className={`featured-photo featured-initials hex-avatar-${toneFor(name)}`} role="img" aria-label={center ? name : undefined}>
<span aria-hidden="true">{initials(name)}</span>
</span>
);
}
export interface FeaturedCarouselProps {
experts: DiscoverExpert[];
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
size?: number;
}
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
const count = experts.length;
const [index, setIndex] = useState(0);
const reduced = useReducedMotion();
const autoplay = useRef<FeaturedAutoplay | null>(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;
return (
<section
className="tile panel rail rail-featured featured"
aria-labelledby="featured-title"
onMouseEnter={() => 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}
>
<div className="panel-head">
<h2 id="featured-title" className="type-title type-title-strong featured-title">
<Star />Featured experts
</h2>
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
</div>
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
{experts.map((e, i) => {
const slot = featuredSlot(i, current, count);
return (
<div
key={String(e.id)}
className={`featured-item featured-${slot}`}
aria-hidden={slot === "c0" ? undefined : true}
data-testid="featured-item"
>
<span className="featured-ring"><Photo expert={e} center={slot === "c0"} /></span>
</div>
);
})}
</div>
<div className="featured-cap" key={current}>
<strong className="type-title type-title-strong featured-name">{cap.displayName || "Expert"}</strong>
{cap.byline ? <span className="featured-by">{cap.byline}</span> : null}
{cap.rating !== null || cap.reviewsCount !== null ? (
<span className="featured-rate">
{cap.rating !== null ? (
<span aria-label={`Rated ${cap.rating.toFixed(1)} out of 5`}><Star />{cap.rating.toFixed(1)}</span>
) : null}
{cap.reviewsCount !== null ? (
<em>({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})</em>
) : null}
</span>
) : null}
</div>
{count > 1 ? (
<div className="featured-dots" aria-hidden="true">
{experts.map((e, i) => (
<span key={String(e.id)} className={i === current ? "featured-dot featured-dot-on" : "featured-dot"} />
))}
</div>
) : <div className="featured-dots-gap" />}
</section>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverExpert } from "@/lib/discover";
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
if (rating === null && reviews === null) return null;
return (
<span className="rail-meta">
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
</span>
);
}
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
if (experts.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-experts">
<h2 id="rail-experts">Featured experts</h2>
<ul className="rail-list">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
<Rating rating={e.rating} reviews={e.reviewsCount} />
</div>
</li>
))}
</ul>
</section>
);
}
+40 -8
View File
@@ -3,18 +3,30 @@
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 { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView";
const POLL_MS = 45_000;
export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
export function Feed({
onSignedOut,
feedUserId,
person,
}: {
onSignedOut: () => void;
feedUserId?: string | null;
/** The signed-in person, shown beside the composer. */
person?: ComposerProps["person"];
}) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
const [nextCursor, setNextCursor] = useState<string | null>(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<CardPost[]>([]);
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
@@ -29,6 +41,7 @@ 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<HTMLDivElement | null>(null);
const list = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
@@ -43,10 +56,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 +72,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,7 +112,8 @@ 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();
@@ -224,7 +244,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 +264,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 +275,9 @@ 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 }}
sentinelRef={sentinel}
listRef={list}
/>
);
}
+112 -42
View File
@@ -1,6 +1,7 @@
import type { Ref } from "react";
import type { ReactNode, Ref } from "react";
import { canDelete, type CardPost } from "@/lib/feed-model";
import { Composer, type ComposerProps } from "./Composer";
import { GrowButton, IconButton } from "./Buttons";
import { PostCard } from "./PostCard";
export type FeedStatus = "loading" | "ready" | "error";
@@ -13,6 +14,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<string>;
likeErrors?: ReadonlySet<string>;
@@ -24,6 +30,8 @@ export interface FeedViewProps {
deleteErrors?: ReadonlySet<string>;
composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>;
/** The scrolling feed list; the page itself never scrolls. */
listRef?: Ref<HTMLDivElement>;
now?: number;
}
@@ -31,63 +39,125 @@ function Spinner() {
return <span className="spinner" role="status" aria-label="Loading" />;
}
/** Shimmer placeholder card shown while the next page loads. */
function SkeletonCard() {
return (
<div className="feed-skel" role="status" aria-label="Loading more posts" data-testid="feed-skeleton">
<div className="feed-skel-head">
<span className="feed-skel-dot" />
<span className="feed-skel-lines">
<span className="feed-skel-line feed-skel-w40" />
<span className="feed-skel-line feed-skel-w25" />
</span>
</div>
<span className="feed-skel-line feed-skel-w100" />
<span className="feed-skel-line feed-skel-w80" />
<span className="feed-skel-media" />
</div>
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={spinning ? "refresh-icon refresh-icon-spin" : "refresh-icon"}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
);
}
export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy?: boolean }) {
return (
<IconButton
label="Refresh feed"
shape="round"
size="sm"
icon={<RefreshIcon spinning={!!busy} />}
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 (
<section className="tile panel feed" aria-labelledby="h-feed">
<div className="panel-head feed-head">
<h2 id="h-feed" className="type-title type-title-strong">Home feed</h2>
{refreshFailed ? <span className="feed-refresh-error" role="alert">Could not refresh. Try again.</span> : null}
{onRefresh ? <RefreshButton onRefresh={onRefresh} busy={busy} /> : null}
</div>
{composer}
{children}
</section>
);
}
export function FeedView(p: FeedViewProps) {
if (p.status === "loading") {
return (
<section className="feed">
<FeedPanel onRefresh={p.onRefresh} busy>
<div className="feed-center">
<Spinner />
</div>
</section>
</FeedPanel>
);
}
if (p.status === "error") {
return (
<section className="feed">
<FeedPanel onRefresh={p.onRefresh}>
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
</section>
</FeedPanel>
);
}
return (
<section className="feed">
{p.composer ? <Composer {...p.composer} /> : null}
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
</button>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard
key={post.key}
post={post}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? (
<div className="feed-center">
<Spinner />
</div>
) : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</section>
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null} onRefresh={p.onRefresh} busy={p.refreshing} refreshFailed={p.refreshFailed}>
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
{p.newCount > 0 ? (
<div className="feed-pill">
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
</div>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post, i) => <PostCard
key={post.key}
post={post}
index={i}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? <SkeletonCard /> : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</div>
</FeedPanel>
);
}
+68 -24
View File
@@ -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,79 @@ 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 <video> with a
* poster; audio keeps its <audio> 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 while a video plays.
*/
export function MinnectMedia({ media, thumbnailUrl, question, who, pictureUrl, views = null }: Props) {
const [videoFailed, setVideoFailed] = useState(false);
const [playing, setPlaying] = useState(false);
const video = useRef<HTMLVideoElement | null>(null);
const audio = useRef<HTMLAudioElement | null>(null);
const thumb = thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
) : 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 (
<video
className="minnect-thumb minnect-video"
controls
playsInline
preload="none"
src={media.url}
poster={thumbnailUrl ?? undefined}
aria-label={`Play answer from ${who}`}
onError={() => setVideoFailed(true)}
/>
);
}
return (
<>
{thumb}
{media?.kind === "audio" ? (
<audio className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
<div className={`trend-media${playing ? " trend-media-playing" : ""}`}>
{isVideo ? (
<video
ref={video}
className="minnect-thumb minnect-video"
controls
playsInline
preload="none"
src={media.url}
poster={thumbnailUrl ?? undefined}
aria-label={`Play answer from ${who}`}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onError={() => setVideoFailed(true)}
/>
) : thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
) : null}
<span className="trend-scrim" aria-hidden="true" />
<span className="trend-who">
<HexAvatar name={who} pictureUrl={pictureUrl} size="sm" />
<strong>{who}</strong>
</span>
{playable ? (
<button type="button" className="trend-play" aria-label={`Play answer from ${who}`} onClick={play}>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5Z" /></svg>
</button>
) : null}
</div>
{views !== null || playable ? (
<div className="trend-meta">
{views !== null ? (
<span className="trend-views">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />
</svg>
{views.toLocaleString("en-US")} {views === 1 ? "view" : "views"}
</span>
) : <span />}
{playable ? <span className="trend-kind">{isVideo ? "Video" : "Audio"}</span> : null}
</div>
) : null}
{isAudio ? (
<audio ref={audio} className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
) : null}
</>
);
+125 -33
View File
@@ -1,14 +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 { 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;
@@ -20,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 (
<svg className="heart-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"
<svg className="post-heart" viewBox="0 0 24 24" aria-hidden="true"
fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"
strokeLinejoin="round">
<path d="M12 21s-7.5-4.6-9.6-9.3C.9 8.4 2.7 4.5 6.4 4.5c2.1 0 3.6 1.2 4.4 2.5.4.6 1.4.6 1.8 0 .8-1.3 2.3-2.5 4.4-2.5 3.7 0 5.5 3.9 4 7.2C19.5 16.4 12 21 12 21z" />
@@ -30,46 +43,126 @@ function Heart({ filled }: { filled: boolean }) {
);
}
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
function CommentIcon() {
return (
<svg {...icon} className="post-act-icon">
<path d="M21 11.5a8.5 8.5 0 0 1-12.6 7.4L3 20l1.2-4.6A8.5 8.5 0 1 1 21 11.5z" />
</svg>
);
}
function TrashIcon() {
return (
<svg {...icon}>
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />
</svg>
);
}
function PlayIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
</svg>
);
}
/** 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<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
void box.current?.querySelector("video")?.play().catch(() => {});
};
return (
<div className="post-media post-video" ref={box}>
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} />
{started ? null : (
<>
<button type="button" className="post-play" aria-label="Play video" onClick={start}>
<PlayIcon />
</button>
{duration ? <span className="post-dur">{duration}</span> : null}
</>
)}
</div>
);
}
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false);
const [beat, setBeat] = useState(false);
const menuBox = useRef<HTMLDivElement>(null);
const own = !!(canDelete && onDelete);
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 (
<article className="post" data-testid="post-card">
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
<header className="post-head">
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div className="post-who">
<strong>{post.authorName}</strong>
<strong>
{post.authorName}
{own ? <span className="post-you">You</span> : null}
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{canDelete && onDelete ? (
<div className="post-menu">
{own ? (
<div className="post-menu" ref={menuBox}>
<button
type="button"
className="menu-btn"
className={menu ? "post-menu-btn post-menu-btn-open" : "post-menu-btn"}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
...
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{menu ? (
<div className="menu" role="menu">
<div className="post-pop" role="menu">
{confirming ? (
<>
<span className="menu-ask">Delete this post?</span>
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
Confirm delete
</button>
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
Cancel
</button>
</>
<div className="post-ask">
<p className="post-ask-title">Delete this post?</p>
<p className="post-ask-sub">This cannot be undone.</p>
<div className="post-ask-row">
<SweepButton
label="Cancel"
role="menuitem"
onClick={() => { setConfirming(false); setMenu(false); }}
/>
<IconButton
label="Delete"
role="menuitem"
icon={<TrashIcon />}
size="sm"
disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }}
/>
</div>
</div>
) : (
<button type="button" role="menuitem" className="menu-danger" onClick={() => setConfirming(true)}>
<button type="button" role="menuitem" className="post-mi post-mi-danger" onClick={() => setConfirming(true)}>
<TrashIcon />
Delete post
</button>
)}
@@ -78,13 +171,11 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
</div>
) : null}
</header>
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) =>
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
<div className="post-media post-video" key={i}>
<VideoPlayer mp4Url={m.mp4Url} hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
</div>
<PostVideo key={i} media={m} author={post.authorName} />
) : m.src ? (
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -96,28 +187,29 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
</div>
) : null,
)}
<footer className="post-stats">
<footer className="post-acts">
<button
type="button"
className={post.isLiked ? "like-btn liked" : "like-btn"}
className={post.isLiked ? (beat ? "post-act post-like post-like-on post-like-beat" : "post-act post-like post-like-on") : "post-act post-like"}
aria-pressed={post.isLiked}
aria-label={post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`}
disabled={likeBusy || !post.id || !onToggleLike}
onClick={() => onToggleLike?.(post)}
onClick={() => { setBeat(!post.isLiked); onToggleLike?.(post); }}
>
<Heart filled={post.isLiked} /> <span className="like-count">{post.likesCount}</span>
<Heart filled={post.isLiked} /> <span className="post-count">{post.likesCount}</span>
</button>
<button
type="button"
className="comments-toggle"
className={open ? "post-act post-comments-toggle post-act-open" : "post-act post-comments-toggle"}
aria-expanded={open}
disabled={!post.id}
onClick={() => setOpen((o) => !o)}
>
{post.commentsCount} comments
<CommentIcon />
{commentsLabel(post.commentsCount)}
</button>
{likeError ? (
<span className="like-error" role="alert">Could not update like.</span>
<span className="post-error" role="alert">Could not update like.</span>
) : null}
</footer>
{open && post.id ? <CommentsSection postId={post.id} onSignedOut={onSignedOut} /> : null}
+15 -14
View File
@@ -1,26 +1,27 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverMinnect } from "@/lib/discover";
import { MinnectMedia } from "./MinnectMedia";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-trending">
<h2 id="rail-trending">Trending minnects</h2>
<ul className="rail-list">
<section className="tile panel rail rail-trending" aria-labelledby="rail-trending">
<div className="panel-head">
<h2 id="rail-trending" className="type-title type-title-strong">Trending minnects</h2>
</div>
<ul className="rail-list trend-list panel-scroll fade-edges">
{minnects.map((m) => {
const who = m.expert.displayName || "Expert";
return (
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
<div className="minnect-who">
<HexAvatar name={who} pictureUrl={m.expert.pictureUrl} size="sm" />
<strong className="rail-name">{who}</strong>
</div>
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
{m.question ? <p className="minnect-question">{m.question}</p> : null}
{m.views !== null ? (
<span className="rail-meta">{m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}</span>
) : null}
<li key={String(m.id)} className="minnect-card" data-testid="minnect-card">
<MinnectMedia
media={m.media}
thumbnailUrl={m.thumbnailUrl}
question={m.question}
who={who}
pictureUrl={m.expert.pictureUrl}
views={m.views}
/>
{m.question ? <p className="trend-q">{m.question}</p> : null}
</li>
);
})}
+69
View File
@@ -0,0 +1,69 @@
// Plain-text helpers for the composer (spec "Composer", formatting decided by
// Cesar on 2026-10-19: posts are plain text, no bold or italic).
export const BULLET = "• ";
export interface TextEdit {
text: string;
selectionStart: number;
selectionEnd: number;
}
/**
* Insert the plain character "• " at the start of the line holding the caret,
* or of every line the selection touches. A line that already starts with
* "• " is left as it is. Nothing else in the text changes. The returned
* selection is shifted so it still covers the same characters.
*/
export function insertBullets(text: string, selectionStart: number, selectionEnd: number): TextEdit {
const start = Math.max(0, Math.min(selectionStart, selectionEnd, text.length));
let end = Math.max(0, Math.min(Math.max(selectionStart, selectionEnd), text.length));
// A selection that ends right after a line break does not include the next line.
if (end > 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;
+68
View File
@@ -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";
}
+136
View File
@@ -0,0 +1,136 @@
"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 { 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 { toCardPosts, type CardPost } from "@/lib/feed-model";
import {
FIXTURE_CATEGORIES,
FIXTURE_COMMENTS,
FIXTURE_DISCOVER,
FIXTURE_FEED_ITEMS,
FIXTURE_NOW,
FIXTURE_PERSON,
FIXTURE_SEARCH_RESULTS,
} from "./fixtures";
export function FeedPreview() {
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
const [file, setFile] = useState<File | null>(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(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<HTMLDivElement | null>(null);
// The comments calls answer from the fixtures; the preview never reaches the network.
useEffect(() => {
const real = window.fetch;
const lists: Record<string, unknown[]> = { ...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 (
<>
<div style={{ position: "fixed", left: "50%", bottom: 8, zIndex: 50, display: "flex", gap: 12, padding: "6px 10px", font: "12px sans-serif", background: "var(--tile-2)", color: "var(--ink)", border: "1px solid var(--line-2)", borderRadius: 8 }}>
<label style={{ margin: 0, fontSize: 12 }}>
<input type="checkbox" data-testid="fx-new" checked={newCount > 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
</label>
<label style={{ margin: 0, fontSize: 12 }}>
<input type="checkbox" data-testid="fx-more" checked={loadingMore} onChange={(e) => setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
</label>
</div>
<AppShell
person={FIXTURE_PERSON}
search={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
feed={
<FeedView
posts={posts}
status="ready"
loadingMore={loadingMore}
listRef={list}
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(toCardPosts(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={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
/>
</>
);
}
+207
View File
@@ -0,0 +1,207 @@
// 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 type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import type { ExpertResult } from "@/lib/experts-search";
/** Fixed "now" so relative times in the preview are stable. */
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = ""): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
`</linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/>` +
(label
? `<text x="64" y="660" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
: "") +
`</svg>`;
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";
/** Comments for the preview, in the shape /api/posts/[id]/comments returns. p2 holds a removed one; p4 has none. */
export const FIXTURE_COMMENTS: Record<string, unknown[]> = {
p1: [{ id: "c1", content: "Congratulations, the flow reads well.", createdAt: "2026-10-19T11:50:00Z", author: { displayName: "Priya Natarajan" } }],
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: [],
},
},
{
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-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: [],
}));
+24 -1
View File
@@ -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 "";
+222
View File
@@ -0,0 +1,222 @@
/*
* 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; }
}
+116
View File
@@ -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); }
+72
View File
@@ -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); }
+205
View File
@@ -0,0 +1,205 @@
/* 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. */
.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; }
/* Single-column fallback (760px and below, until the Mobile milestone): lists get a usable height. */
@container app (max-width: 760px) {
.bento-col .panel { flex: 0 0 auto; }
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
}
/* 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; }
}
-109
View File
@@ -9,116 +9,7 @@ input { font: inherit; padding: 12px; border: 1px solid var(--line-2); border-ra
input:focus-visible, button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; border: 0; border-radius: 8px; background: var(--accent); color: white; cursor: pointer; }
.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; }
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--tile); border-bottom: 1px solid var(--line); }
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
.shell header span { color: var(--ink-3); }
.shell header { position: sticky; top: 0; z-index: 5; }
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: transparent; color: var(--ink); border: 1px solid var(--ink); }
.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.post { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.post-head { display: flex; align-items: center; gap: 12px; }
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
.post-who time { color: var(--ink-3); font-size: 13px; }
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; }
.post-video { background: black; border-radius: 8px; overflow: hidden; }
.post-video-pending { color: white; padding: 48px 12px; text-align: center; }
.post-video video { display: block; width: 100%; max-height: 520px; background: black; }
.post-stats { display: flex; gap: 16px; color: var(--ink-3); font-size: 14px; }
.heart { color: var(--accent); }
.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; }
.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); } }
.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
.sentinel { height: 1px; }
.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--ink-3); border-radius: 8px; font-size: 14px; font-weight: 400; }
.like-btn:hover:not(:disabled) { color: var(--accent); background: var(--accent-soft); }
.like-btn.liked { color: var(--accent); }
.like-btn:disabled { opacity: 1; cursor: default; }
.like-btn:disabled:not(.liked) { color: var(--ink-3); }
.like-error { color: var(--danger); font-size: 13px; align-self: center; }
.post-stats { align-items: center; }
.composer { background: var(--tile); border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
.composer-label { margin: 0; }
.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid var(--line-2); border-radius: 0; resize: vertical; background: var(--tile-2); color: var(--ink); }
.composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; }
.composer-media { display: flex; flex-direction: column; gap: 8px; }
.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; }
.composer-image span { padding: 6px 12px; border: 1px solid var(--line-2); background: var(--tile-2); }
.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; }
.composer-preview { display: flex; align-items: center; gap: 10px; }
.composer-preview video { width: 96px; height: 64px; object-fit: cover; border: 1px solid var(--line); background: black; }
.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); }
.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.composer-remove { margin: 0; padding: 6px 12px; border-radius: 0; }
.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.comment p { margin: 2px 0 0; font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
.comment-head { display: flex; gap: 8px; align-items: baseline; font-size: 13px; }
.comment-head time { color: var(--ink-3); font-size: 12px; }
.comment.removed .comment-removed { color: var(--ink-3); font-style: italic; }
.comments-empty { margin: 0; color: var(--ink-3); font-size: 14px; }
.comment-error { margin: 0; color: var(--danger); font-size: 13px; }
.comment-form { display: flex; gap: 8px; }
.comment-form input { flex: 1; margin: 0; }
.comment-form button { margin: 0; }
.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; }
.comments-toggle { background: transparent; color: var(--ink-3); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
.post-menu { position: relative; margin-left: auto; }
.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--ink-3); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; }
.menu { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 6px; background: var(--tile); border: 1px solid var(--line-2); border-radius: 10px; box-shadow: var(--shadow-pop); }
.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; }
.menu button:hover { background: var(--tile-2); }
.menu .menu-danger { color: var(--danger); }
.menu-ask { font-size: 13px; color: var(--ink-3); padding: 4px 8px; }
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; }
.col-left > ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; }
.col-left a[aria-current="page"] { background: var(--ink); color: var(--bg); }
.col-center { flex: 1 1 400px; min-width: 0; }
.col-center .feed { padding-left: 0; padding-right: 0; }
.col-right { flex: 0 0 300px; position: sticky; top: 72px; max-height: calc(100vh - 88px); overflow-y: auto; padding-top: 16px; display: flex; flex-direction: column; gap: 20px; }
.rail h2 { margin: 0 0 8px; font-size: 16px; font-weight: 700; }
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.rail-card { background: var(--tile); border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
.expert-card { align-items: center; }
.minnect-card { flex-direction: column; }
.rail-text { display: flex; flex-direction: column; min-width: 0; font-size: 14px; }
.rail-name { font-size: 14px; }
.rail-byline { color: var(--ink-3); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-meta { color: var(--ink-3); font-size: 13px; }
.minnect-who { display: flex; align-items: center; gap: 8px; }
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.minnect-video { background: black; border-radius: 0; }
.minnect-audio { width: 100%; height: 36px; }
@media (max-width: 959px) {
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
.col-left > ul { flex-direction: row; }
.col-center { flex: 1 1 100%; order: 3; }
.col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; }
.col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; }
.col-right .rail-card { flex: 0 0 260px; }
}
/* Expert search + category chips (left column). Squared corners, monochrome + red. */
.expert-search { margin-top: 16px; }
.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; }
.expert-search form { display: flex; flex-direction: column; }
.expert-search input { border-radius: 0; width: 100%; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: transparent; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: white; }
.search-results { margin-top: 12px; }
.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
@media (max-width: 959px) {
.col-left .expert-search { flex-basis: 100%; }
}
+246
View File
@@ -0,0 +1,246 @@
/*
* 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 the legacy `button` rule never 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); }
.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; }
}
+74
View File
@@ -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); }
+103
View File
@@ -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; }
+89
View File
@@ -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<Parameters<typeof Composer>[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[^>]*>Publish<svg/);
const busy = html({ value: "hi", busy: true });
expect(busy).toContain("btn-flip-busy");
expect(busy).toContain(">Posting<");
});
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");
});
});
+6 -6
View File
@@ -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> = {}): DiscoverMinnect => ({
});
const html = (el: ReturnType<typeof createElement>) => 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("<h2");
expect(out).toContain("Ann Lee");
expect(out).toContain("Growth coach");
@@ -29,7 +29,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).toContain("<img");
});
it("falls back to initials and omits missing fields", () => {
const out = html(createElement(FeaturedExpertsRail, {
const out = html(createElement(FeaturedCarousel, {
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
}));
expect(out).not.toContain("<img");
@@ -37,7 +37,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).not.toContain("review");
});
it("renders nothing when empty", () => {
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
});
});
+1 -1
View File
@@ -91,7 +91,7 @@ describe("fetchExperts", () => {
const view = (o: Partial<ExpertSearchViewProps> = {}) =>
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", () => {
+109
View File
@@ -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");
});
});
+86 -4
View File
@@ -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<string, unknown> = {}) => ({
@@ -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("<video");
});
});
describe("video duration", () => {
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(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*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(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*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 });
});
});
+2 -1
View File
@@ -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(/<button[^>]*class="composer-post"[^>]*disabled/);
expect(html).toMatch(/<button[^>]*type="submit"[^>]*class="[^"]*composer-post/);
expect(html).not.toMatch(/<button[^>]*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" });