From 89064e88c17f0fb334f72dd705fc9faea589eb8b Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 22:08:21 -0600 Subject: [PATCH 01/13] rnxcmkidd: Add the viewport-fit bento shell and a dev feed preview with fixtures --- src/app/dev/feed/page.tsx | 11 ++ src/app/globals.css | 3 + src/app/layout.tsx | 1 + src/components/App.tsx | 32 +--- src/components/AppShell.tsx | 90 +++++++++++ src/components/ExpertSearch.tsx | 17 +-- src/components/FeaturedExpertsRail.tsx | 8 +- src/components/Feed.tsx | 7 +- src/components/FeedView.tsx | 99 +++++++------ src/components/TrendingMinnectsRail.tsx | 8 +- src/dev/FeedPreview.tsx | 73 +++++++++ src/dev/fixtures.ts | 182 +++++++++++++++++++++++ src/styles/layout.css | 189 ++++++++++++++++++++++++ src/styles/legacy.css | 36 +---- tests/expert-search.test.ts | 2 +- 15 files changed, 638 insertions(+), 120 deletions(-) create mode 100644 src/app/dev/feed/page.tsx create mode 100644 src/components/AppShell.tsx create mode 100644 src/dev/FeedPreview.tsx create mode 100644 src/dev/fixtures.ts create mode 100644 src/styles/layout.css diff --git a/src/app/dev/feed/page.tsx b/src/app/dev/feed/page.tsx new file mode 100644 index 0000000..b1a4792 --- /dev/null +++ b/src/app/dev/feed/page.tsx @@ -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 ; +} diff --git a/src/app/globals.css b/src/app/globals.css index 55ebebe..053314b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea80f4a..d0c2484 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ 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"; diff --git a/src/components/App.tsx b/src/components/App.tsx index 804e83a..78eb0be 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 ( -
-
- -
- {state.person.name} - {state.person.email} -
-
-
- -
- setState({ kind: "out" })} /> -
- -
-
+ } + feed={ setState({ kind: "out" })} />} + discover={} + /> ); } diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..36935be --- /dev/null +++ b/src/components/AppShell.tsx @@ -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 ( + + + + + ); +} + +function MoonIcon() { + return ( + + + + ); +} + +/** 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 ( +
+
+ + MINNECT + +
+
+ +
+ {person.name} + {person.email} +
+
+
+
+
+ ); +} + +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 ( +
+
+
+ + {search} +
+
{feed}
+ +
+
+ ); +} diff --git a/src/components/ExpertSearch.tsx b/src/components/ExpertSearch.tsx index edfb1bf..d94c9d1 100644 --- a/src/components/ExpertSearch.tsx +++ b/src/components/ExpertSearch.tsx @@ -17,14 +17,15 @@ export interface ExpertSearchViewProps { onCategory: (name: string | null) => void; status: SearchStatus; results: ExpertResult[]; - defaultOpen?: boolean; } export function ExpertSearchView(p: ExpertSearchViewProps) { return ( -
- Find experts -
e.preventDefault()}> +
+
+

Find experts

+
+ e.preventDefault()}> ) : null} -
+
{p.status === "loading" ?

Searching...

: null} {p.status === "error" ?

Search is unavailable. Try again.

: null} {p.status === "ready" && p.results.length === 0 ? ( @@ -72,7 +73,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) { ) : null}
-
+ ); } @@ -84,11 +85,9 @@ export function ExpertSearch() { const [categories, setCategories] = useState([]); const [status, setStatus] = useState("idle"); const [results, setResults] = useState([]); - const [open, setOpen] = useState(true); const seq = useRef(0); useEffect(() => { - setOpen(window.matchMedia("(min-width: 960px)").matches); let live = true; fetchDiscover().then( (d) => { if (live) setCategories(d.categories); }, @@ -117,8 +116,6 @@ export function ExpertSearch() { return ( -

Featured experts

-
    +
    +
    +

    Featured experts

    +
    +
      {experts.map((e) => (
    • diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index d6c4aa1..10afa2c 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -29,6 +29,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(null); + const list = useRef(null); const busy = useRef(false); const shown = useRef>(new Set()); @@ -93,7 +94,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 +226,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee function showNew() { setPosts((cur) => mergePosts(cur, pending)); setPending([]); - window.scrollTo({ top: 0, behavior: "smooth" }); + list.current?.scrollTo({ top: 0, behavior: "smooth" }); } return ( @@ -246,6 +248,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee onSignedOut={onSignedOut} composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }} sentinelRef={sentinel} + listRef={list} /> ); } diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx index fbe02cd..85ac004 100644 --- a/src/components/FeedView.tsx +++ b/src/components/FeedView.tsx @@ -1,4 +1,4 @@ -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 { PostCard } from "./PostCard"; @@ -24,6 +24,8 @@ export interface FeedViewProps { deleteErrors?: ReadonlySet; composer?: ComposerProps; sentinelRef?: Ref; + /** The scrolling feed list; the page itself never scrolls. */ + listRef?: Ref; now?: number; } @@ -31,63 +33,76 @@ function Spinner() { return ; } +function FeedPanel({ children, composer }: { children: ReactNode; composer?: ReactNode }) { + return ( +
      +
      +

      Home feed

      +
      + {composer} + {children} +
      + ); +} + export function FeedView(p: FeedViewProps) { if (p.status === "loading") { return ( -
      +
      -
      + ); } if (p.status === "error") { return ( -
      +

      The feed did not load.

      -
      + ); } return ( -
      - {p.composer ? : null} - {p.newCount > 0 ? ( - - ) : null} - {p.posts.length === 0 ? ( -

      No posts yet.

      - ) : ( - p.posts.map((post) => ) - )} - {p.loadMoreFailed ? ( -
      -

      The feed did not load.

      - -
      - ) : null} - {p.loadingMore ? ( -
      - -
      - ) : null} -
      + : null}> +
      + {p.newCount > 0 ? ( + + ) : null} + {p.posts.length === 0 ? ( +

      No posts yet.

      + ) : ( + p.posts.map((post) => ) + )} + {p.loadMoreFailed ? ( +
      +

      The feed did not load.

      + +
      + ) : null} + {p.loadingMore ? ( +
      + +
      + ) : null} + + ); } diff --git a/src/components/TrendingMinnectsRail.tsx b/src/components/TrendingMinnectsRail.tsx index dba8447..a16cb6b 100644 --- a/src/components/TrendingMinnectsRail.tsx +++ b/src/components/TrendingMinnectsRail.tsx @@ -5,9 +5,11 @@ import { MinnectMedia } from "./MinnectMedia"; export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) { if (minnects.length === 0) return null; return ( -
      - -
        +
        +
        + +
        +
          {minnects.map((m) => { const who = m.expert.displayName || "Expert"; return ( diff --git a/src/dev/FeedPreview.tsx b/src/dev/FeedPreview.tsx new file mode 100644 index 0000000..469ae1c --- /dev/null +++ b/src/dev/FeedPreview.tsx @@ -0,0 +1,73 @@ +"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 { 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 { toCardPosts, type CardPost } from "@/lib/feed-model"; +import { + FIXTURE_CATEGORIES, + FIXTURE_DISCOVER, + FIXTURE_FEED_ITEMS, + FIXTURE_NOW, + FIXTURE_PERSON, + FIXTURE_SEARCH_RESULTS, +} from "./fixtures"; + +export function FeedPreview() { + const [posts, setPosts] = useState(() => toCardPosts(FIXTURE_FEED_ITEMS)); + const [draft, setDraft] = useState(""); + const [query, setQuery] = useState(""); + const [category, setCategory] = useState(null); + + // 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 ( + + } + feed={ + {}} + onShowNew={() => {}} + onToggleLike={toggleLike} + feedUserId={FIXTURE_PERSON.feedUserId} + onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))} + composer={{ value: draft, busy: false, error: false, onChange: setDraft, onSubmit: () => setDraft("") }} + now={FIXTURE_NOW} + /> + } + discover={} + /> + ); +} diff --git a/src/dev/fixtures.ts b/src/dev/fixtures.ts new file mode 100644 index 0000000..e644852 --- /dev/null +++ b/src/dev/fixtures.ts @@ -0,0 +1,182 @@ +// 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 = + `` + + `` + + `` + + `` + + (label + ? `${label}` + : "") + + ``; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +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"), 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): DiscoverExpert => ({ + id, + slug: displayName.replace(/\s+/g, ""), + displayName, + byline, + pictureUrl: null, + 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), +]; + +const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({ + id, + question, + views, + thumbnailUrl: placeholderImage(hue), + media: null, + expert: { slug: who.slug, displayName: who.displayName, pictureUrl: null, byline: who.byline, rating: who.rating }, +}); + +export const FIXTURE_TRENDING: DiscoverMinnect[] = [ + minnect("m1", FIXTURE_FEATURED[0], "How do I raise prices without losing my best customers?", 12840, 352), + minnect("m2", FIXTURE_FEATURED[3], "What should a pre-seed deck show in the first three slides?", 9310, 30), + minnect("m3", FIXTURE_FEATURED[1], "How often should a remote team meet in person?", 6120, 210), + minnect("m4", FIXTURE_FEATURED[2], "When is the right time to hire a first product manager?", 4470, 270), + minnect("m5", FIXTURE_FEATURED[4], "How many customer interviews are enough before building?", 2015, 150), +]; + +export const FIXTURE_CATEGORIES: DiscoverCategory[] = [ + { id: 1, name: "Business", icon: null, sortOrder: 1 }, + { id: 2, name: "Coaching", icon: null, sortOrder: 2 }, + { id: 3, name: "Finance", icon: null, sortOrder: 3 }, + { id: 4, name: "Product", icon: null, sortOrder: 4 }, + { id: 5, name: "Media", icon: null, sortOrder: 5 }, +]; + +export const FIXTURE_DISCOVER: DiscoverData = { + featuredExperts: FIXTURE_FEATURED, + trendingMinnects: FIXTURE_TRENDING, + categories: FIXTURE_CATEGORIES, +}; + +/** Find experts results: every featured expert plus a few more, filterable by name. */ +export const FIXTURE_SEARCH_RESULTS: ExpertResult[] = [ + ...FIXTURE_FEATURED, + expert("e6", "Daniel Moreau", "Sales operations", 4.5, 21), + expert("e7", "Aiko Tanaka", "Brand strategy", 4.8, 77), + expert("e8", "Rafael Duarte", "Engineering management", 4.4, 18), +].map((e) => ({ + id: e.id, + slug: e.slug, + displayName: e.displayName, + byline: e.byline, + pictureUrl: e.pictureUrl, + rating: e.rating, + categories: [], +})); diff --git a/src/styles/layout.css b/src/styles/layout.css new file mode 100644 index 0000000..64f07c7 --- /dev/null +++ b/src/styles/layout.css @@ -0,0 +1,189 @@ +/* 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; +} + +/* 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: 0 1 auto; max-height: 40%; } +.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 (below 1001px): lists get a usable height. */ +@container app (max-width: 1000px) { + .bento-col .panel { flex: 0 0 auto; } + .bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; } + .bento-right .rail-featured { max-height: none; } +} + +/* Account panel ---------------------------------------------------------- */ + +.account { flex: none; } + +.account-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 12px 12px 16px; +} +.account-word { flex: 1; font-size: 15px; } + +.account-theme { + appearance: none; + flex: none; + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + margin: 0; + padding: 0; + border: 0; + border-radius: var(--r-ctl); + background: transparent; + color: var(--ink-2); + cursor: pointer; + transition: background-color .2s, color .2s, transform .15s; +} +.account-theme:hover { background: var(--tile-2); color: var(--ink); } +.account-theme:active { transform: scale(.9); } +.account-theme svg { width: 20px; height: 20px; } + +.account-me { + display: flex; + align-items: center; + gap: 12px; + margin: 0 12px; + padding: 12px; + border-radius: 14px; + background: var(--tile-2); +} +.account-me-text { display: flex; flex-direction: column; min-width: 0; } +.account-me-text strong { font-size: 14px; } +.account-me-text span { + font-size: 12.5px; + color: var(--ink-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-live { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 18px 16px; + font-size: 12.5px; + color: var(--ink-3); +} +.account-dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + animation: account-ping 2.2s infinite; +} +@keyframes account-ping { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 45%); } + 70% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent), transparent 100%); } + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent), transparent 100%); } +} + +@media (prefers-reduced-motion: reduce) { + .account-dot { animation: none !important; } + .panel-scroll { scroll-behavior: auto !important; } +} diff --git a/src/styles/legacy.css b/src/styles/legacy.css index 70f518d..9f06f75 100644 --- a/src/styles/legacy.css +++ b/src/styles/legacy.css @@ -9,12 +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; } -.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; } @@ -31,7 +25,7 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde .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; } +.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); } @@ -77,16 +71,6 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde .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; } @@ -100,25 +84,9 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde .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%; } +/* Category chips (Find experts panel). Squared corners, monochrome + red. */ .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%; } -} diff --git a/tests/expert-search.test.ts b/tests/expert-search.test.ts index 5b64beb..392d76d 100644 --- a/tests/expert-search.test.ts +++ b/tests/expert-search.test.ts @@ -91,7 +91,7 @@ describe("fetchExperts", () => { const view = (o: Partial = {}) => renderToStaticMarkup(createElement(ExpertSearchView, { query: "", onQuery: () => {}, categories: [{ id: 1, name: "Business", icon: null, sortOrder: 1 }], - activeCategory: null, onCategory: () => {}, status: "idle", results: [], defaultOpen: true, ...o, + activeCategory: null, onCategory: () => {}, status: "idle", results: [], ...o, })); describe("ExpertSearchView", () => { From 11e4ec561bff95077e5d9fca9413f2e024f6e68a Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 22:13:28 -0600 Subject: [PATCH 02/13] 8zbhzr21j: Restyle post cards, comments and the own-post menu to the redesign spec --- src/app/layout.tsx | 1 + src/components/Buttons.tsx | 2 +- src/components/CommentsPanel.tsx | 29 ++-- src/components/PostCard.tsx | 150 +++++++++++++++---- src/dev/FeedPreview.tsx | 23 ++- src/dev/fixtures.ts | 15 +- src/lib/feed-model.ts | 15 +- src/styles/legacy.css | 38 ----- src/styles/post.css | 243 +++++++++++++++++++++++++++++++ 9 files changed, 432 insertions(+), 84 deletions(-) create mode 100644 src/styles/post.css diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d0c2484..49c42a8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx index 8ce2346..41ac174 100644 --- a/src/components/Buttons.tsx +++ b/src/components/Buttons.tsx @@ -227,8 +227,8 @@ export interface IconButtonProps extends NativeButton { export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) { return ( : null} + {p.onRetry ? : null}

          ) : null} { 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)} /> - + } />
      ); diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index d49d3a4..64189e8 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -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 ( -
      + + {started ? null : ( + <> + + {duration ? {duration} : null} + + )} +
      + ); +} + 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(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 (
      - {post.authorName} + + {post.authorName} + {own ? You : null} +
      - {canDelete && onDelete ? ( -
      + {own ? ( +
      {menu ? ( -
      +
      {confirming ? ( - <> - Delete this post? - - - +
      +

      Delete this post?

      +

      This cannot be undone.

      +
      + { setConfirming(false); setMenu(false); }} + /> + } + size="sm" + disabled={deleteBusy} + onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }} + /> +
      +
      ) : ( - )} @@ -78,13 +169,11 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
      ) : null}
      - {deleteError ?

      Could not delete the post.

      : null} + {deleteError ?

      Could not delete the post.

      : null} {post.text ?

      {post.text}

      : null} {post.media.map((m, i) => m.kind === "video" && (m.mp4Url || m.hlsUrl) ? ( -
      - -
      + ) : m.src ? (
      {/* eslint-disable-next-line @next/next/no-img-element */} @@ -96,28 +185,29 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
      ) : null, )} -