Promote to main: Feed browser luxury redesign #7

Merged
calcazar merged 49 commits from develop into main 2026-10-20 10:33:01 -06:00
9 changed files with 432 additions and 84 deletions
Showing only changes of commit 11e4ec561b - Show all commits
+1
View File
@@ -8,6 +8,7 @@ import "../styles/layout.css";
import "../styles/buttons.css";
import "../styles/avatar.css";
import "../styles/signin.css";
import "../styles/post.css";
import "../styles/legacy.css";
const manrope = Manrope({
+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)}
+15 -10
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">
<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 className="comment-removed">This comment was removed.</p> : <p>{c.content}</p>}
{c.removed ? <p>This comment was removed.</p> : <p>{c.content}</p>}
</div>
</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>
);
+120 -30
View File
@@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } 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 { formatDuration, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
export interface PostCardProps {
post: CardPost;
@@ -20,9 +21,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 +41,126 @@ function Heart({ filled }: { filled: boolean }) {
);
}
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, 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">
<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 +169,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 +185,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)}
>
<CommentIcon />
{post.commentsCount} comments
</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}
+22 -1
View File
@@ -3,14 +3,16 @@
// 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 { useMemo, useState } from "react";
import { useEffect, useMemo, 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,
@@ -24,6 +26,25 @@ export function FeedPreview() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | 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();
+14 -1
View File
@@ -22,6 +22,19 @@ export function placeholderImage(hue: number, label = ""): string {
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 = {
@@ -73,7 +86,7 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
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"), order: 0 }],
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), mp4Url: FIXTURE_VIDEO_URL, durationSeconds: 154, order: 0 }],
},
},
{
+14 -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 = typeof m.durationSeconds === "number" ? m.durationSeconds : typeof m.duration === "number" ? m.duration : 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);
-38
View File
@@ -9,17 +9,6 @@ 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; }
.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; }
@@ -27,13 +16,6 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
@keyframes spin { to { transform: rotate(360deg); } }
.new-posts { position: sticky; top: 12px; 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); }
@@ -49,27 +31,7 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
.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; }
.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; }
+243
View File
@@ -0,0 +1,243 @@
/*
* 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;
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; }
}