vhg6j0bp2: Add frosted top bar and progress rail to the mobile feed
This commit is contained in:
@@ -7,6 +7,8 @@ import { HexAvatar } from "./HexAvatar";
|
|||||||
import { PlayIcon, PostMenu } from "./PostCard";
|
import { PlayIcon, PostMenu } from "./PostCard";
|
||||||
import { VideoPlayer } from "./VideoPlayer";
|
import { VideoPlayer } from "./VideoPlayer";
|
||||||
import { FeaturedCarousel } from "./FeaturedCarousel";
|
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
import { useTheme } from "@/lib/use-theme";
|
||||||
import { MobileDock } from "./MobileDock";
|
import { MobileDock } from "./MobileDock";
|
||||||
import { BottomSheet } from "./BottomSheet";
|
import { BottomSheet } from "./BottomSheet";
|
||||||
import { CommentsSection } from "./CommentsSection";
|
import { CommentsSection } from "./CommentsSection";
|
||||||
@@ -220,6 +222,35 @@ function PostSlide({ post, index, current, p }: { post: CardPost; index: number;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Height of the progress rail, as a percent: the slide on screen out of the loaded slides. */
|
||||||
|
export function railPercent(index: number, count: number): number {
|
||||||
|
if (count <= 0) return 0;
|
||||||
|
return Math.round((Math.min(Math.max(index, 0), count - 1) + 1) / count * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topIcon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as const;
|
||||||
|
|
||||||
|
/** Frosted pills over the feed (spec "Top bar"): logo and wordmark left, theme toggle right. */
|
||||||
|
function TopBar() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const next = theme === "dark" ? "light" : "dark";
|
||||||
|
return (
|
||||||
|
<div className="ph-top" data-testid="top-bar">
|
||||||
|
<div className="ph-brand ph-glass">
|
||||||
|
<Logo size={24} />
|
||||||
|
<span className="type-wordmark">MINNECT</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ph-theme ph-glass" data-testid="top-theme" aria-label={`Switch to ${next} theme`} onClick={toggleTheme}>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<svg {...topIcon}><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg {...topIcon}><path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5a8.5 8.5 0 1 0 11 11z" /></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ChevronDown() {
|
function ChevronDown() {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
@@ -371,6 +402,8 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
const currentPost = p.status === "ready" && currentPostIndex >= 0 ? p.posts[currentPostIndex] ?? null : null;
|
const currentPost = p.status === "ready" && currentPostIndex >= 0 ? p.posts[currentPostIndex] ?? null : null;
|
||||||
const kind: CurrentSlide["kind"] = currentPost ? "post" : featuredAt >= 0 && index === featuredAt ? "featured" : "load";
|
const kind: CurrentSlide["kind"] = currentPost ? "post" : featuredAt >= 0 && index === featuredAt ? "featured" : "load";
|
||||||
|
|
||||||
|
const onMedia = !!currentPost && currentPost.media.some((m) => m.kind === "video" || m.src);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCurrentChange?.({ index, kind, post: currentPost });
|
onCurrentChange?.({ index, kind, post: currentPost });
|
||||||
}, [index, kind, currentPost, onCurrentChange]);
|
}, [index, kind, currentPost, onCurrentChange]);
|
||||||
@@ -383,12 +416,16 @@ export function MobileFeed(p: FeedViewProps) {
|
|||||||
}, [index]);
|
}, [index]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="reel-feed" aria-label="Home feed">
|
<section className="reel-feed" aria-label="Home feed" data-on-media={onMedia ? "true" : "false"}>
|
||||||
{p.newCount > 0 && p.status === "ready" ? (
|
{p.newCount > 0 && p.status === "ready" ? (
|
||||||
<div className="reel-pill">
|
<div className="reel-pill">
|
||||||
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<TopBar />
|
||||||
|
<div className="rail-v" data-testid="feed-rail" aria-hidden="true">
|
||||||
|
<i style={{ height: `${railPercent(index, slideCount)}%` }} />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="reel"
|
className="reel"
|
||||||
data-testid="feed-reel"
|
data-testid="feed-reel"
|
||||||
|
|||||||
+55
-2
@@ -174,7 +174,7 @@
|
|||||||
.is-cur.slide-playing .slide-meta { animation: none; }
|
.is-cur.slide-playing .slide-meta { animation: none; }
|
||||||
|
|
||||||
/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */
|
/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */
|
||||||
.slide-menu { position: absolute; top: 70px; right: 12px; z-index: 6; }
|
.slide-menu { position: absolute; top: calc(70px + env(safe-area-inset-top, 0px)); right: 12px; z-index: 6; }
|
||||||
.slide-menu-btn {
|
.slide-menu-btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -309,7 +309,7 @@
|
|||||||
/* New-posts pill floats over the top of the reel. */
|
/* New-posts pill floats over the top of the reel. */
|
||||||
.reel-pill {
|
.reel-pill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 76px;
|
top: calc(76px + env(safe-area-inset-top, 0px));
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
@@ -377,3 +377,56 @@
|
|||||||
.dk-beat svg { animation: none !important; }
|
.dk-beat svg { animation: none !important; }
|
||||||
.slide-art { transition: none !important; }
|
.slide-art { transition: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Top bar (spec "Top bar"): frosted pills over the feed. Over a media slide they use a
|
||||||
|
dark translucent glass with white text; over a text slide the theme glass. The bar
|
||||||
|
ends 56px (plus the top inset) down, above the own-post menu at 70px. */
|
||||||
|
.ph-top {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: calc(14px + env(safe-area-inset-top, 0px)) 14px 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ph-top > * { pointer-events: auto; }
|
||||||
|
.ph-glass {
|
||||||
|
background: var(--glass);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.6);
|
||||||
|
backdrop-filter: blur(16px) saturate(1.6);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
transition: background .3s var(--ease), color .3s var(--ease);
|
||||||
|
}
|
||||||
|
.reel-feed[data-on-media="true"] .ph-glass {
|
||||||
|
background: var(--media-glass);
|
||||||
|
color: var(--on-media);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||||
|
}
|
||||||
|
.ph-brand { display: flex; align-items: center; gap: 8px; height: 42px; padding: 0 14px 0 10px; border-radius: 14px; }
|
||||||
|
.ph-brand .type-wordmark { font-size: 13px; }
|
||||||
|
.ph-theme { appearance: none; border: 0; margin: 0; display: grid; place-items: center; width: 42px; height: 42px; padding: 0; border-radius: 14px; cursor: pointer; }
|
||||||
|
.ph-theme svg { width: 20px; height: 20px; }
|
||||||
|
.ph-theme:active { transform: scale(.94); }
|
||||||
|
|
||||||
|
/* Progress rail: position in the loaded feed, on the right edge. */
|
||||||
|
.rail-v {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
height: 110px;
|
||||||
|
margin-top: -55px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--line-2);
|
||||||
|
z-index: 15;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.rail-v i { display: block; width: 100%; background: var(--accent); border-radius: 2px; transition: height .5s var(--ease); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .rail-v i { transition: none; } }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createElement } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
import { FeedView, type FeedViewProps } from "@/components/FeedView";
|
||||||
import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex } from "@/components/MobileFeed";
|
import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex, railPercent } from "@/components/MobileFeed";
|
||||||
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
|
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
|
||||||
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
|
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
|
||||||
import { toCardPosts } from "@/lib/feed-model";
|
import { toCardPosts } from "@/lib/feed-model";
|
||||||
@@ -171,3 +171,27 @@ describe("featured experts slide", () => {
|
|||||||
expect(render({})).not.toContain("feed-slide-featured");
|
expect(render({})).not.toContain("feed-slide-featured");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("top bar and progress rail", () => {
|
||||||
|
it("renders the brand pill, the theme toggle and the rail", () => {
|
||||||
|
const html = render({});
|
||||||
|
expect(html).toContain('data-testid="top-bar"');
|
||||||
|
expect(html).toContain("MINNECT");
|
||||||
|
expect(html).toMatch(/data-testid="top-theme"[^>]*aria-label="Switch to (light|dark) theme"/);
|
||||||
|
expect(html).toContain('data-testid="feed-rail"');
|
||||||
|
expect(html).toContain('data-on-media="false"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sizes the rail by the slide on screen out of the loaded slides", () => {
|
||||||
|
expect(railPercent(0, 4)).toBe(25);
|
||||||
|
expect(railPercent(2, 4)).toBe(75);
|
||||||
|
expect(railPercent(9, 4)).toBe(100);
|
||||||
|
expect(railPercent(0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the own-post menu below the bar and the safe area", () => {
|
||||||
|
const css = readFileSync("src/styles/mobile.css", "utf8");
|
||||||
|
expect(css).toMatch(/\.slide-menu \{[^}]*top: calc\(70px \+ env\(safe-area-inset-top/);
|
||||||
|
expect(css).toMatch(/\.ph-top \{[^}]*safe-area-inset-top/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user