Promote to main: Feed browser luxury redesign #7
@@ -37,6 +37,36 @@ The browser only ever talks to this app. This app talks to Minnect server-side.
|
||||
That keeps your Minnect tokens off the browser entirely and avoids cross-origin
|
||||
restrictions. See `CLAUDE.md` and `docs/` (if present) for the design notes.
|
||||
|
||||
## Design system
|
||||
|
||||
The redesign (obsidian, crimson, no gold) is specified in
|
||||
[`docs/minnect-feed-redesign.spec.md`](docs/minnect-feed-redesign.spec.md). The
|
||||
styling decisions are in
|
||||
[`docs/adr/0001-redesign-styling-approach.md`](docs/adr/0001-redesign-styling-approach.md).
|
||||
The handoff for the redesign is [`docs/handoff-redesign.md`](docs/handoff-redesign.md).
|
||||
|
||||
- **Themes.** Dark is the default; light is a toggle. The choice is stored in
|
||||
`localStorage` and applied by a small script in `src/app/layout.tsx` before
|
||||
first paint (`src/lib/theme.ts`), so there is no flash. `src/lib/use-theme.ts`
|
||||
is the hook the toggles use.
|
||||
- **Tokens.** All colors are CSS custom properties in `src/app/globals.css`, one
|
||||
set for dark and one for light. `--accent-text` is the readable crimson for
|
||||
small text (4.5:1). Components use tokens only, never raw colors.
|
||||
- **Stylesheets.** One file per component area in `src/styles/` (`buttons.css`,
|
||||
`feed.css`, `post.css`, `mobile.css` and so on), loaded from `globals.css`.
|
||||
- **Buttons.** Five styles plus a small icon button, all in
|
||||
`src/components/Buttons.tsx`: Flip (the composer Post button), Grow (the
|
||||
"new posts" pill), Sweep (outline actions: Try again, Cancel, expert search
|
||||
chips), Depth (the Discover sheet rows), Sparkle (Sign in and the mobile dock
|
||||
new-post button) and IconButton (refresh, comment send, delete confirm). Disabled buttons stay at full opacity.
|
||||
- **Avatars.** `HexAvatar` (`src/components/HexAvatar.tsx`) is used for every
|
||||
avatar: photo, or initials on a gradient, in a hexagon.
|
||||
- **Breakpoints.** Container queries on the app root: 1181 and up (three
|
||||
columns), 1001 to 1180 (narrower columns), 761 to 1000 (tablet), 760 and below
|
||||
(full-screen snap feed with a bottom dock).
|
||||
- **Dev previews.** `/dev/feed` (fixture feed, no login) and `/dev/buttons` (every
|
||||
button style in both themes). Both return 404 in production builds.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20 or newer (built and tested on Node 22).
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Redesign handoff
|
||||
|
||||
The Feed browser redesign (luxury business look, dark default plus light, desktop
|
||||
bento layout, mobile immersive feed). Spec: `docs/minnect-feed-redesign.spec.md`.
|
||||
Styling decisions: `docs/adr/0001-redesign-styling-approach.md`. CRM project
|
||||
Minnect, module 2.
|
||||
|
||||
## What shipped, per milestone
|
||||
|
||||
All four build milestones are merged into `develop` with merge commits.
|
||||
|
||||
| Milestone | PR | Merge commit | Shipped |
|
||||
|---|---|---|---|
|
||||
| Redesign mobilization | #1 | c0f4260 | Spec and original contract in the repo; styling ADR; formatting decision (no bold or italic, bullets insert a bullet character) |
|
||||
| Redesign foundation | #2 | 9aeb9a3 | Dark and light tokens with persisted toggle; Manrope and Cormorant Garamond; HexAvatar; five button styles; sign-in screen |
|
||||
| Desktop bento layout | #3 | 25fd360 | Viewport-fit grid where only the feed scrolls; post cards; composer; new-posts pill and motion; featured carousel; Trending and Find experts; tablet layout; refresh button |
|
||||
| Mobile immersive feed | #4 | f3fd4b5 | Snap feed; sideways media swipe; double-tap like; featured slide; bottom dock; comments and composer sheets; Discover sheet; top bar and progress rail |
|
||||
| Redesign finalization | #5 | pending | Acceptance pass, contrast and hover fixes, `legacy.css` removed, README and this handoff. The orchestrator merges it. |
|
||||
|
||||
Promotion of `develop` to `main` is Cesar's.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- The upload progress bar is an animated sweep, not real progress. Real progress
|
||||
needs upload events from the proxy. New scope (change request), not built.
|
||||
- NEEDS CESAR (from the acceptance pass, task 99puxwol5):
|
||||
1. Acceptance 2 with a real account, dark and light, at 1440, 1024, 820 and 390px:
|
||||
reading and paging, like and unlike, comments, posting text, image and video,
|
||||
deleting your own post, expert search and categories, featured experts,
|
||||
trending playback.
|
||||
2. With real photos, check the 3-photo stack in mobile More.
|
||||
3. On a real phone (iOS Safari and Android Chrome): vertical swipe snaps one
|
||||
post, sideways swipe moves the dots, a playing video pauses on swipe-away,
|
||||
double-tap likes with the burst, the featured carousel rotates again after
|
||||
a tap elsewhere.
|
||||
4. Lighthouse Accessibility on the feed, both themes, desktop and mobile; note
|
||||
any contrast item below 4.5:1.
|
||||
|
||||
## Run and verify
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:3000, sign in with a Minnect account
|
||||
npm run typecheck && npx eslint --no-eslintrc -c .eslintrc.json src tests
|
||||
npm test && npm run build
|
||||
```
|
||||
|
||||
Without an account: open `/dev/feed` (fixture feed) and `/dev/buttons` (button
|
||||
styles). Use browser device mode at 1440, 1024, 820 and 390px and toggle the
|
||||
theme. These routes 404 in production.
|
||||
@@ -16,6 +16,8 @@
|
||||
--ink-2: #BFBDB8;
|
||||
--ink-3: #95938E;
|
||||
--accent: #C8141C;
|
||||
/* Crimson for small text (like counts): plain crimson is 3.2:1 on the dark tiles. */
|
||||
--accent-text: color-mix(in srgb, #C8141C, white 40%);
|
||||
--danger: #FF6B70;
|
||||
--glass: rgba(18,18,21,.72);
|
||||
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
|
||||
@@ -41,6 +43,7 @@
|
||||
--ink-2: #46484D;
|
||||
--ink-3: #686A70;
|
||||
--accent: #C8141C;
|
||||
--accent-text: #C8141C;
|
||||
--danger: #B3121A;
|
||||
--glass: rgba(251,250,248,.74);
|
||||
--shadow-pop: 0 10px 28px rgba(26,28,31,.14);
|
||||
@@ -91,6 +94,8 @@
|
||||
@layer reset {
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
/* Form controls take the page font instead of the UA's 13.33px Arial. */
|
||||
button, input, textarea, select { font: inherit; }
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -16,7 +16,6 @@ import "../styles/trending.css";
|
||||
import "../styles/search.css";
|
||||
import "../styles/mobile.css";
|
||||
import "../styles/sheet.css";
|
||||
import "../styles/legacy.css";
|
||||
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
|
||||
@@ -210,7 +210,7 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="error" role="alert">Publishing the post failed. Your text is still here.</p>
|
||||
<p className="composer-error" role="alert">Publishing the post failed. Your text is still here.</p>
|
||||
) : null}
|
||||
<div className="composer-bar">
|
||||
{onFile ? (
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode, Ref } from "react";
|
||||
import { canDelete, type CardPost } from "@/lib/feed-model";
|
||||
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
|
||||
import { Composer, type ComposerProps } from "./Composer";
|
||||
import { GrowButton, IconButton } from "./Buttons";
|
||||
import { GrowButton, IconButton, SweepButton } from "./Buttons";
|
||||
import { PostCard } from "./PostCard";
|
||||
import { useCompactLayout } from "./layout-context";
|
||||
import { MobileFeed, type CurrentSlide } from "./MobileFeed";
|
||||
@@ -142,7 +142,7 @@ export function FeedView(p: FeedViewProps) {
|
||||
<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>
|
||||
<SweepButton label="Try again" onClick={p.onRetry} />
|
||||
</div>
|
||||
</FeedPanel>
|
||||
);
|
||||
@@ -176,7 +176,7 @@ export function FeedView(p: FeedViewProps) {
|
||||
{p.loadMoreFailed ? (
|
||||
<div className="feed-message" role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
<SweepButton label="Try again" onClick={p.onRetry} />
|
||||
</div>
|
||||
) : null}
|
||||
{p.loadingMore ? <SkeletonCard /> : null}
|
||||
|
||||
@@ -17,11 +17,14 @@ interface 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.
|
||||
* the thumbnail and step aside once a video has started, and only then does the
|
||||
* video show its native controls.
|
||||
*/
|
||||
export function MinnectMedia({ media, thumbnailUrl, question, who, pictureUrl, views = null }: Props) {
|
||||
const [videoFailed, setVideoFailed] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
// Native controls only after the first play, so the frame never shows our
|
||||
// play button and the browser's controls at the same time.
|
||||
const [started, setStarted] = useState(false);
|
||||
const video = useRef<HTMLVideoElement | null>(null);
|
||||
const audio = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
@@ -36,20 +39,19 @@ export function MinnectMedia({ media, thumbnailUrl, question, who, pictureUrl, v
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`trend-media${playing ? " trend-media-playing" : ""}`}>
|
||||
<div className={`trend-media${started ? " trend-media-playing" : ""}`}>
|
||||
{isVideo ? (
|
||||
<video
|
||||
ref={video}
|
||||
className="minnect-thumb minnect-video"
|
||||
controls
|
||||
controls={started}
|
||||
playsInline
|
||||
preload="none"
|
||||
src={media.url}
|
||||
poster={thumbnailUrl ?? undefined}
|
||||
aria-label={`Play answer from ${who}`}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
onClick={started ? undefined : play}
|
||||
onPlay={() => setStarted(true)}
|
||||
onError={() => setVideoFailed(true)}
|
||||
/>
|
||||
) : thumbnailUrl ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type Ref } from "react";
|
||||
import { canDelete, formatDuration, isPlayableVideo, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
|
||||
import { GrowButton } from "./Buttons";
|
||||
import { GrowButton, SweepButton } from "./Buttons";
|
||||
import { HexAvatar } from "./HexAvatar";
|
||||
import { PlayIcon, PostMenu } from "./PostCard";
|
||||
import { VideoPlayer } from "./VideoPlayer";
|
||||
@@ -438,7 +438,7 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
{p.status === "error" ? (
|
||||
<section className="slide slide-message is-cur" data-index={0} role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
<SweepButton label="Try again" onClick={p.onRetry} />
|
||||
</section>
|
||||
) : null}
|
||||
{p.status === "ready" && p.posts.length === 0 && !showLoad ? (
|
||||
@@ -457,7 +457,7 @@ export function MobileFeed(p: FeedViewProps) {
|
||||
{p.status === "ready" && p.loadMoreFailed ? (
|
||||
<section className="slide slide-message" data-index={slideCount} role="alert">
|
||||
<p>The feed did not load.</p>
|
||||
<button type="button" onClick={p.onRetry}>Try again</button>
|
||||
<SweepButton label="Try again" onClick={p.onRetry} />
|
||||
</section>
|
||||
) : null}
|
||||
{showLoad ? (
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void })
|
||||
</div>
|
||||
<form className="signin-form" onSubmit={submit} noValidate>
|
||||
{error && (
|
||||
<p role="alert" className="error signin-error">
|
||||
<p role="alert" className="signin-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Button system (spec "Buttons", ADR 0001 sections 6 to 8).
|
||||
* Five styles plus the glossy crimson icon button, ported from the mockup's
|
||||
* recolored rules. Tokens only; white and black appear only on crimson faces.
|
||||
* Every class sets margin, padding, font and color itself so the legacy
|
||||
* `button` rules and any `button { color: inherit }` never leak in.
|
||||
* Every class sets margin, padding, font and color itself so no global
|
||||
* `button` rule, such as `button { color: inherit }`, can leak in.
|
||||
*/
|
||||
|
||||
/* Shared base for every style. */
|
||||
@@ -242,7 +242,7 @@
|
||||
.btn-depth .btn-depth-icon svg { width: 20px; height: 20px; }
|
||||
.btn-depth .btn-depth-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.btn-depth .btn-depth-label { font-size: 15px; font-weight: 700; color: inherit; }
|
||||
.btn-depth .btn-depth-sub { font-size: 12.5px; color: var(--ink-3); transition: color .3s; }
|
||||
.btn-depth .btn-depth-sub { font-size: 12.5px; font-weight: 500; color: var(--ink-3); transition: color .3s; }
|
||||
.btn-depth:hover,
|
||||
.btn-depth:focus-visible { color: var(--on-accent); }
|
||||
.btn-depth:hover::before,
|
||||
|
||||
@@ -220,3 +220,6 @@
|
||||
.composer-att,
|
||||
.composer-prog i { animation: none !important; }
|
||||
}
|
||||
|
||||
/* Publish failure, above the toolbar. */
|
||||
.composer-error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
.app is the size container; .bento is its child, so container queries can
|
||||
style it. The page never scrolls: only the panels' own lists do. */
|
||||
|
||||
/* Session check: one centered line while the session loads. */
|
||||
.center { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
|
||||
|
||||
.app {
|
||||
container: app / inline-size;
|
||||
height: 100vh;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/* Pre-redesign component rules, recolored with the tokens so the current
|
||||
screens stay readable in both themes. Each redesign component task moves its
|
||||
rules into its own file in src/styles/ (ADR 0001, section 6) and deletes them
|
||||
here; the file goes when it is empty. Do not add new rules here.
|
||||
Literal white and black are only text on crimson and video letterboxing. */
|
||||
.center { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
|
||||
label { font-size: 14px; font-weight: 500; margin-top: 8px; }
|
||||
input { font: inherit; padding: 12px; border: 1px solid var(--line-2); border-radius: 8px; background: var(--tile-2); color: var(--ink); }
|
||||
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; }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -234,6 +234,8 @@
|
||||
padding: 70px 0 110px;
|
||||
background: radial-gradient(90% 55% at 50% 46%, color-mix(in oklab, var(--accent), transparent 84%), transparent 70%), var(--bg);
|
||||
}
|
||||
/* The crimson glow tints the light page enough to drop --ink-3 meta below 4.5:1. */
|
||||
[data-theme="light"] .slide-featured { --ink-3: var(--ink-2); }
|
||||
.slide-feat-title {
|
||||
margin: 10px 24px 0;
|
||||
text-align: center;
|
||||
@@ -362,7 +364,8 @@
|
||||
.dk svg { width: 22px; height: 22px; flex: none; }
|
||||
@media (hover: hover) { .dk:hover:not(:disabled) { background: var(--tile-3); } }
|
||||
.dk:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.dk.dk-like-on { color: var(--accent); }
|
||||
.dk.dk-like-on { color: var(--accent-text); }
|
||||
.dk.dk-like-on svg { color: var(--accent); }
|
||||
.dk-beat svg { animation: post-beat .5s cubic-bezier(.2,.9,.3,1.4); }
|
||||
.dk:disabled { opacity: 1; color: var(--ink-3); cursor: default; }
|
||||
.btn-spark.btn-spark-fab.dk-plus { flex: 0 0 64px; width: 64px; height: 52px; }
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* 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.
|
||||
* font and color itself so no global `button` rule leaks in.
|
||||
* --on-accent (white) appears only over video and on crimson faces.
|
||||
*/
|
||||
@keyframes post-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
@@ -122,7 +122,9 @@
|
||||
.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-on { color: var(--accent-text); }
|
||||
.post-like:hover:not(:disabled) .post-heart,
|
||||
.post-like-on .post-heart { color: var(--accent); }
|
||||
.post-like-beat .post-heart { animation: post-beat .5s cubic-bezier(.2,.9,.3,1.4); }
|
||||
.post-act-open { color: var(--ink); background: var(--post-soft); }
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
.sheet-discover .btn-depth:hover .btn-depth-chev,
|
||||
.sheet-discover .btn-depth:focus-visible .btn-depth-chev { color: var(--on-accent); }
|
||||
.depth-stack { display: flex; flex: none; }
|
||||
.depth-stack .hex-avatar { margin-left: -10px; }
|
||||
/* A small overlap: a bigger one hid the initials of the avatars underneath. */
|
||||
.depth-stack .hex-avatar { margin-left: -5px; }
|
||||
.depth-stack .hex-avatar:first-child { margin-left: 0; }
|
||||
.sheet-sub { margin: 4px 0 -6px 4px; color: var(--ink-3); }
|
||||
.seg-depth {
|
||||
|
||||
@@ -100,4 +100,4 @@
|
||||
}
|
||||
.signin-field input::placeholder { color: var(--ink-3); }
|
||||
.signin-form .signin-submit { margin-top: 18px; width: 100%; height: 54px; }
|
||||
.signin-error { margin: 0; }
|
||||
.signin-error { margin: 0; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 8px; color: var(--danger); background: var(--accent-soft); font-size: 14px; }
|
||||
|
||||
+12
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -34,11 +34,21 @@ describe("SparkleButton disabled", () => {
|
||||
expect(body).toContain("var(--tile-3)");
|
||||
expect(body).toContain("var(--ink-2)");
|
||||
expect(body).toContain("not-allowed");
|
||||
expect(readFileSync(path.resolve(__dirname, "../src/styles/legacy.css"), "utf8")).not.toMatch(/button:disabled\s*\{[^}]*opacity/);
|
||||
for (const f of readdirSync(path.resolve(__dirname, "../src/styles"))) {
|
||||
expect(readFileSync(path.resolve(__dirname, "../src/styles", f), "utf8")).not.toMatch(/(^|[\s,}])button:disabled\s*\{[^}]*opacity/);
|
||||
}
|
||||
expect(html(createElement(SparkleButton, { label: "Sign in", disabled: true }))).toContain("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy.css", () => {
|
||||
it("is deleted and no longer imported, and no global button margin rule is left", () => {
|
||||
expect(existsSync(path.resolve(__dirname, "../src/styles/legacy.css"))).toBe(false);
|
||||
expect(readFileSync(path.resolve(__dirname, "../src/app/layout.tsx"), "utf8")).not.toContain("legacy.css");
|
||||
expect(globals).not.toMatch(/(^|[\s,}])button\s*\{[^}]*margin/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FlipButton states", () => {
|
||||
it("ready: POST plus send icon, flips to PUBLISH with the icon", () => {
|
||||
expect(flipLabels("ready")).toEqual({ front: "Post", back: "Publish", icon: true });
|
||||
|
||||
@@ -57,7 +57,9 @@ describe("TrendingMinnectsRail", () => {
|
||||
minnects: [minnect({ media: { kind: "video", url, duration: 9, waveform: null } })],
|
||||
}));
|
||||
expect(out).toContain("<video");
|
||||
expect(out).toContain("controls");
|
||||
// Before the first play only our play button shows, not the native controls too.
|
||||
expect(out).not.toMatch(/<video[^>]*controls/);
|
||||
expect(out).toContain('class="trend-play"');
|
||||
expect(out).toContain("preload=\"none\"");
|
||||
expect(out).toContain("X-Amz-Signature=abc");
|
||||
expect(out).toContain('poster="https://x/t.jpg"');
|
||||
|
||||
Reference in New Issue
Block a user