Merge pull request 'Feed browser: Redesign mobilization' (#1) from feature/feed-browser-redesign-mobilization into develop
This commit was merged in pull request #1.
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
Minnect feed browser. A web app where each person signs in with their own Minnect email and password, then reads and writes the expert home feed. Minnect tokens stay on the server.
|
||||
|
||||
- CRM project: Minnect (`9aw9m1ka4`), module **Feed browser**.
|
||||
- Build contract: `docs/requirements.md` (from `docs/minnect-feed-browser.spec.md` in the Minnect workspace).
|
||||
- Original contract: `docs/minnect-feed-browser.spec.md`.
|
||||
- Redesign build contract: `docs/minnect-feed-redesign.spec.md` (copied from the Minnect workspace `docs/`). It covers the feed browser redesign and is the single source for the redesign tasks.
|
||||
- Stack: Next.js App Router. Route handlers are the same-origin server that holds sessions.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# ADR 0001: Styling approach for the feed browser redesign
|
||||
|
||||
- Status: Proposed (waiting for Cesar's sign-off on CRM task `1ublul6nq`)
|
||||
- Date: 2026-10-19
|
||||
- Source: `docs/minnect-feed-redesign.spec.md`, sections "Design tokens", "Typography", "Buttons", "Desktop and tablet layout", "Theme" and "Implementation notes". Where this ADR and the spec disagree, the spec wins and this ADR is fixed.
|
||||
|
||||
## Context
|
||||
|
||||
The redesign changes every screen, style and motion of the feed browser. The data layer (`src/lib/*`, `src/app/api/*`) does not change.
|
||||
|
||||
The app today:
|
||||
|
||||
- Next.js 15.5 App Router, React 19. No UI library, no CSS-in-JS, no Tailwind. Dependencies are `next`, `react`, `react-dom` and `hls.js`.
|
||||
- One stylesheet, `src/app/globals.css` (128 lines), imported once in `src/app/layout.tsx`. It uses a few light-only custom properties (`--red`, `--ink`, `--grey`, `--line`, `--bg`), viewport media queries at 959px, and broad element rules such as `button { background: var(--red); color: #fff; margin-top: 16px }` that leak into every button.
|
||||
- Roboto is loaded with `next/font/google` and applied as a class on `<html>`.
|
||||
- There is no theme and no dark mode.
|
||||
|
||||
The approved mockup (canvas file `Main.dc.html`) holds working CSS for every component. It also holds the canvas runtime that draws the boards, which is not part of the app.
|
||||
|
||||
Several build tasks (tokens and theme, typography, layout, buttons, each component) will write CSS in parallel. They need one set of rules so their work fits together.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Tokens: CSS custom properties with a dark set and a light set
|
||||
|
||||
- Every color in the spec's token table (`--bg`, `--tile`, `--tile-2`, `--tile-3`, `--line`, `--line-2`, `--ink`, `--ink-2`, `--ink-3`, `--accent`, `--danger`, `--glass`) is a custom property. Values are copied exactly from the spec.
|
||||
- The dark set is declared on `:root` and on `:root[data-theme="dark"]`. The light set is declared on `:root[data-theme="light"]`. Dark is therefore the default even if the theme attribute is missing.
|
||||
- Crimson shades are derived from `--accent` with `color-mix()` and declared once next to the tokens: `--accent-hi` (26% white), `--accent-lo` (30% black), `--accent-soft` (88% transparent).
|
||||
- Shared non-color values are tokens too: radii (`--r-panel: 18px`, `--r-card: 16px`, `--r-ctl: 12px`), the standard ease (`--ease: cubic-bezier(.2,.8,.2,1)`), and the font families (section 4).
|
||||
- Components use only tokens. A hex, `rgb()` or named color inside a component file is a review failure. The one exception is pure white or black inside crimson buttons, sparks and media fades, where the spec asks for white.
|
||||
- No gold or champagne value may appear anywhere.
|
||||
- The old tokens (`--red`, `--grey`) are removed, not kept as aliases.
|
||||
|
||||
### 2. Plain CSS, no UI library
|
||||
|
||||
- Styles are plain global CSS files. No component library, no Tailwind, no CSS-in-JS, no Sass, no CSS Modules.
|
||||
- Reason for global classes rather than CSS Modules: the mockup CSS is written as global class rules, and porting it unchanged (section 7) is the fastest and safest route. Collisions are avoided by naming instead: each component's classes start with its own prefix (for example `.post-*`, `.composer-*`, `.featured-*`, `.trending-*`, `.search-*`, `.dock-*`, `.sheet-*`, `.btn-*`).
|
||||
- No new npm dependency is added for styling.
|
||||
|
||||
### 3. Layout responds to container queries on the app root
|
||||
|
||||
- The signed-in app has one root element, `.app`, with `container: app / inline-size`. The bento grid is its child (`.bento`), because a container query cannot style the container itself.
|
||||
- Breakpoints are written as `@container app (min-width: 1181px)`, `(1001px to 1180px)`, `(761px to 1000px)` and `(max-width: 760px)`, matching the spec's four layouts.
|
||||
- Viewport width media queries (`@media (max-width: ...)`) are not used for layout. The existing 959px media queries are deleted with the old stylesheet.
|
||||
- Media queries stay allowed for things that are not width: `prefers-reduced-motion`, `hover` and `pointer`. `prefers-color-scheme` is not used, because dark is the default for a first visit regardless of the system setting (section 5).
|
||||
- The page height is fixed to the viewport (`height: 100vh`, with `100dvh` where supported so mobile browser bars do not cause a page scroll). `body` never scrolls. Only the feed list, and the panels the spec names, scroll.
|
||||
- The sign-in page is not inside `.app`. It is a single centered column and needs no breakpoints.
|
||||
|
||||
### 4. Fonts through `next/font/google`
|
||||
|
||||
- `src/app/layout.tsx` loads `Manrope` (weights 400 to 800) and `Cormorant_Garamond` (500, 600, and italic 500) from `next/font/google`, each with the `variable` option: `--font-sans` and `--font-serif`.
|
||||
- Both variable classes go on `<html>`. `body` uses `font-family: var(--font-sans), system-ui, sans-serif`. Title classes use `var(--font-serif)`.
|
||||
- Roboto is removed. No font is loaded from a `<link>` tag or a CDN, so fonts are self-hosted by Next and there is no layout shift from a late font.
|
||||
|
||||
### 5. Theme: stored in localStorage, applied before first paint
|
||||
|
||||
- The theme is the attribute `data-theme="dark" | "light"` on `<html>`.
|
||||
- A small inline script is the first child of `<head>` in `src/app/layout.tsx`, written with `dangerouslySetInnerHTML`. It runs before the body is parsed and before first paint:
|
||||
- read `localStorage.getItem("theme")` inside try/catch;
|
||||
- if the value is `"light"` or `"dark"`, use it, otherwise use `"dark"`;
|
||||
- set `document.documentElement.dataset.theme` to that value.
|
||||
- The script is a fixed string with no user input in it. It contains no token, session id or secret (CLAUDE.md rule 1).
|
||||
- `<html>` gets `suppressHydrationWarning`, because the server renders it without the attribute value the script sets.
|
||||
- The server renders `data-theme="dark"` on `<html>` so that a browser with scripts off still gets the default dark theme.
|
||||
- The three toggles (account panel, mobile top bar, Discover sheet switch) call one shared helper that sets the attribute and writes `localStorage.setItem("theme", value)` inside try/catch. A failed write still changes the theme for the session.
|
||||
- Changing theme only swaps token values. No component has theme-specific rules, except where the spec gives a component a different look per theme. Those rules are written as `:root[data-theme="light"] .component { ... }` inside that component's file.
|
||||
- `color-scheme` is set to `dark` or `light` with the theme, so native controls and scrollbars match.
|
||||
- Rejected: reading the theme in React state and rendering after mount (it flashes the dark theme to light users on every load), and a theme cookie read on the server (it adds server work and a cookie for a purely visual choice).
|
||||
|
||||
### 6. How `globals.css` is split by component
|
||||
|
||||
`src/app/globals.css` keeps only what is global. Component rules move to one file per component in `src/styles/`:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `src/app/globals.css` | `@layer` order, tokens (both themes), the reset and element defaults, focus ring, `prefers-reduced-motion` switch-offs that apply everywhere |
|
||||
| `src/styles/layout.css` | `.app`, `.bento`, panels, the four container-query layouts, the mobile full-screen scroller frame |
|
||||
| `src/styles/buttons.css` | The five button styles and the glossy crimson icon button |
|
||||
| `src/styles/avatar.css` | `HexAvatar` |
|
||||
| `src/styles/post.css` | Post card, menu and delete confirm, like, media, video play |
|
||||
| `src/styles/comments.css` | Comments list, empty and removed states, comment field |
|
||||
| `src/styles/composer.css` | Composer, toolbar, emoji popover, attachment row |
|
||||
| `src/styles/featured.css` | Featured experts carousel |
|
||||
| `src/styles/trending.css` | Trending minnects |
|
||||
| `src/styles/search.css` | Find experts, chips, results |
|
||||
| `src/styles/mobile.css` | Mobile slides, top bar, dock, sheets, heart burst |
|
||||
| `src/styles/signin.css` | Sign-in page |
|
||||
|
||||
- `src/app/layout.tsx` imports `globals.css` first and then each file in the table order. All of them are global stylesheets, which the App Router allows in the root layout.
|
||||
- A file that grows past about 600 lines is split again by sub-component. A new component gets its own file rather than growing an unrelated one.
|
||||
- A build task edits only the files for the components it owns, plus `globals.css` when it adds a token.
|
||||
|
||||
### 7. Porting the mockup CSS
|
||||
|
||||
- Copy the CSS rules for each component from the mockup source (`Main.dc.html`) into that component's file. Keep the selectors, values, keyframes and transitions as they are, then:
|
||||
- replace any literal color with the matching token;
|
||||
- rename classes to the component's prefix where they would collide;
|
||||
- turn any board-sized or fixed-width rule from the mockup into the container-query layout in section 3.
|
||||
- Do not copy the canvas runtime: no board frames, artboard positioning, canvas scripts, demo state switches, sample data or the canvas's own fonts and wrappers. Interactive behavior the mockup fakes with script (carousel rotation, double-tap like, sheets, emoji insertion, theme toggle) is written in React in the component, not lifted from the canvas.
|
||||
- The uiverse.io button styles come into the app through the mockup's already recolored rules, not from uiverse.io directly.
|
||||
|
||||
### 8. Cascade order and the `button { color: inherit }` rule
|
||||
|
||||
- `globals.css` starts with `@layer reset, base;`. The reset and every element rule (`button`, `input`, `textarea`, `a`, `h1` to `h3`, `ul`) live in those layers. Component files are not layered.
|
||||
- Unlayered rules always win over layered ones, whatever the specificity. So a component class always beats `button { color: inherit; background: none; border: 0; font: inherit }` from the reset.
|
||||
- In addition, every button class sets its own `color` explicitly for each state it styles (rest, hover, disabled, busy). No button style relies on inheriting its text color. This is the fix for the mockup bug where the Post button showed ivory text on an ivory face.
|
||||
- The element rules in the reset are neutral only: no background color, no margin, no padding beyond 0. The current global `button` rule (crimson background, white text, 16px top margin) is removed with the old stylesheet.
|
||||
- `!important` is not used, except inside the `prefers-reduced-motion` block that switches animations off.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Build tasks can work in parallel: each owns its own stylesheet and uses the shared tokens, so the two themes stay consistent without per-component theme rules.
|
||||
- The theme script is the only inline script in the app. If a Content Security Policy is added later, it needs a hash or nonce for this script.
|
||||
- Container queries need current browsers (Chrome 105+, Safari 16+, Firefox 110+). Older browsers get the mobile layout because no container rule applies. This is accepted for this app.
|
||||
- `color-mix()` needs Chrome 111+, Safari 16.2+, Firefox 113+. Accepted on the same terms.
|
||||
- Global classes need naming discipline. The prefix rule and the one-file-per-component rule are what keep it working, and reviewers check them.
|
||||
- The old stylesheet is replaced, not refactored. Until every component task is merged, unported screens may look unstyled on the milestone branch. This is expected and is not a regression to report.
|
||||
- Removing Roboto and the old tokens touches `layout.tsx` and every component's class names. That work belongs to the build tasks (`3p1idvga1` tokens and theme, `wy86rbj5i` typography, and the component tasks), not to this ADR.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Minnect feed browser
|
||||
|
||||
A browser client for the Minnect expert home feed. Anyone who receives the app signs in with their own Minnect email and password. The app then reads and writes the feed as that person.
|
||||
|
||||
This file is the build contract. An implementation is done when the acceptance checks at the end pass. Do not invent a second login, a shared account, or extra Minnect products.
|
||||
|
||||
## Product
|
||||
|
||||
Signed out, the only screen is email, password, and a sign-in button.
|
||||
|
||||
Signed in, the screen is a single column:
|
||||
|
||||
1. The signed-in person's name and a sign-out control.
|
||||
2. A composer for a new public text post, plus an optional image or video.
|
||||
3. The home feed, newest first. Each post is a card. A control at the bottom loads the next page.
|
||||
|
||||
A card shows the author photo, display name, relative time, the full post text, images, video, like state, like count, and comment count. Opening comments lists them and accepts a new text comment. The signed-in person can remove their own like, and can delete a post only when they are its author.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Do not build ticketing, wallet, circles chat, paid questions, or any admin screen.
|
||||
|
||||
Read-only expert and user search is in scope, but only if a public, normal-user API for it is confirmed by the Mobilization spike (task 8kr7vfa5n). If no such API exists, do not build a search box or any search control. Search never calls admin routes. (Amended 2026-10-19: search moved out of non-goals by Cesar.)
|
||||
|
||||
Do not call admin routes. Do not call a route whose path contains `/admin/`. Do not call `POST /api/v4/auth/external-services/auth-user`. Do not call feed `POST /api/v1/auth/login`. That feed route is an admin login, not the user login.
|
||||
|
||||
Do not put a token, password, or session in the repository, the frontend bundle, a query string, or a log line.
|
||||
|
||||
## Shape of the app
|
||||
|
||||
The browser talks only to this app. This app talks to Minnect.
|
||||
|
||||
```
|
||||
browser -- cookie session --> this app -- Bearer token --> Minnect
|
||||
```
|
||||
|
||||
The Minnect hosts do not allow this app's origin to call them from the browser. A same-origin server is required for that reason, and so the tokens never reach JavaScript.
|
||||
|
||||
Session storage holds, per signed-in person:
|
||||
|
||||
- profile access token and its expiry
|
||||
- profile refresh token
|
||||
- feed access token and its expiry
|
||||
|
||||
The browser receives one httpOnly, Secure, SameSite=Lax cookie. The cookie is an opaque session id. It is not a Minnect token.
|
||||
|
||||
## Hosts
|
||||
|
||||
| Name | Base URL |
|
||||
|---|---|
|
||||
| Profiles | `https://profiles.minnect.com` |
|
||||
| Feed | `https://feed.sharedservices.vt.com` |
|
||||
| Image CDN | `https://cdn.feed.sharedservices.vt.com` |
|
||||
| Video | `https://stream.mux.com` |
|
||||
|
||||
Send JSON as `Content-Type: application/json` and `Accept: application/json`. Send the service token as `Authorization: Bearer <token>`.
|
||||
|
||||
## Login
|
||||
|
||||
### Sign in
|
||||
|
||||
`POST https://profiles.minnect.com/api/v4/auth/login`
|
||||
|
||||
```json
|
||||
{ "email": "person@example.com", "password": "their-password" }
|
||||
```
|
||||
|
||||
`email` and `password` are required. Do not send a `device` object.
|
||||
|
||||
Success is HTTP 200. The saved OpenAPI document does not describe that 200 body. Read the tokens with this order and stop at the first match:
|
||||
|
||||
1. `data.accessToken` and `data.refreshToken`
|
||||
2. `accessToken` and `refreshToken`
|
||||
|
||||
Also read `data.expiresIn` or `expiresIn` when present. It is seconds.
|
||||
|
||||
A failed sign-in is HTTP 401 with this shape:
|
||||
|
||||
```json
|
||||
{ "data": null, "errors": [{ "message": "...", "code": "UnauthorizedException" }] }
|
||||
```
|
||||
|
||||
Show one message: "Email or password is incorrect." Do not show the server message. The server message differs for an unknown email and a wrong password. The screen must not.
|
||||
|
||||
### Who the person is
|
||||
|
||||
`GET https://profiles.minnect.com/api/v4/auth/me` with the profile access token.
|
||||
|
||||
Use `data.userId`, `data.email`, `data.firstName`, `data.lastName`, and `data.profilePictureUrl`. Required fields on the user object are `userId`, `email`, and `role`.
|
||||
|
||||
### Feed token
|
||||
|
||||
`POST https://profiles.minnect.com/api/v4/auth/external-services/feed`
|
||||
|
||||
Header: profile access token. Body: `{}`.
|
||||
|
||||
Success is HTTP 200. Read the feed token in this order:
|
||||
|
||||
1. `accessToken`
|
||||
2. `data.accessToken`
|
||||
3. `token`
|
||||
4. `data.token`
|
||||
|
||||
If none of those fields exist, sign-in fails with "Could not open the feed." Do not show the raw body.
|
||||
|
||||
Confirm the feed token with `GET https://feed.sharedservices.vt.com/api/v1/auth/me`. Keep `data.id`. That id is the feed author id used later to decide whether a post belongs to this person.
|
||||
|
||||
### Refresh
|
||||
|
||||
Profile and feed access tokens last about one hour. Refresh when either token expires within two minutes, and once after a feed call returns 401.
|
||||
|
||||
1. `POST https://profiles.minnect.com/api/v4/auth/refresh-token` with `{ "refreshToken": "<profile refresh token>" }`.
|
||||
2. Replace the stored profile access token and refresh token from that response, using the same field order as sign-in.
|
||||
3. Exchange a new feed token with the call in "Feed token".
|
||||
|
||||
If refresh fails, delete the session and return the browser to the sign-in screen.
|
||||
|
||||
### Sign out
|
||||
|
||||
`POST https://profiles.minnect.com/api/v4/auth/revoke-token` with `{ "refreshToken": "<profile refresh token>" }`. Then delete the server session and clear the cookie. Sign-out still succeeds if revoke returns an error.
|
||||
|
||||
## Feed
|
||||
|
||||
### Read the home feed
|
||||
|
||||
`GET https://feed.sharedservices.vt.com/api/v1/feeds/home/items?limit=20`
|
||||
|
||||
Pass `cursor` with the previous page's `metadata.nextCursor`. Omit `cursor` on the first page.
|
||||
|
||||
The response is:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [],
|
||||
"metadata": { "limit": 20, "cursor": null, "nextCursor": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
Stop paging when `metadata.nextCursor` is null. Ask for 20 items. Do not ask for more than 50.
|
||||
|
||||
Render an item only when `type` is `"post"` and `post` is an object. Other types, including `trending-conversations`, are skipped. They must not break the page.
|
||||
|
||||
### Post card fields
|
||||
|
||||
From the feed item: `publishedAt`, `likesCount`, `commentsCount`.
|
||||
|
||||
From `post`:
|
||||
|
||||
| Field | Use |
|
||||
|---|---|
|
||||
| `id` | Post id for like, comment, and delete |
|
||||
| `content` | Full text. Keep line breaks |
|
||||
| `type` | `text`, `image`, or `video` |
|
||||
| `isLiked` | Initial like state |
|
||||
| `author.id` | Compare with the feed user id from auth/me |
|
||||
| `author.displayName` | Name on the card. Fall back to first + last name |
|
||||
| `author.profilePictureUrl` | Avatar. It may be a redirect URL. Use it as the image source |
|
||||
| `author.slug` | Optional |
|
||||
| `mediaItems` | Images and video, in `order` |
|
||||
|
||||
Image media uses `imageMediumWebpUrl`, then `imageMediumUrl`, then `imageUrl`.
|
||||
|
||||
Video media has `mediaType: "video"` and `hlsUrl` on `stream.mux.com`. Play it with an HLS player (hls.js or the platform equivalent). Use `thumbnailUrl` as the poster. A missing `hlsUrl` shows the thumbnail only.
|
||||
|
||||
### Like and unlike
|
||||
|
||||
`POST https://feed.sharedservices.vt.com/api/v1/posts/{postId}/like`
|
||||
|
||||
`DELETE https://feed.sharedservices.vt.com/api/v1/posts/{postId}/like`
|
||||
|
||||
No body. Success is HTTP 200 and `data.success: true`. A second like does not increase the count. Update the card from the response, or flip `isLiked` and adjust the count by one when the response has no count.
|
||||
|
||||
### Comments
|
||||
|
||||
`GET https://feed.sharedservices.vt.com/api/v1/posts/{postId}/comments?limit=20&page=1`
|
||||
|
||||
`POST https://feed.sharedservices.vt.com/api/v1/posts/{postId}/comments`
|
||||
|
||||
```json
|
||||
{ "content": "comment text", "type": "text", "status": "published" }
|
||||
```
|
||||
|
||||
`content` is required. Show `content`, the author name, and `createdAt`. A comment with `deletedAt` set is shown as removed, without the old text.
|
||||
|
||||
### Create a text post
|
||||
|
||||
1. `POST https://feed.sharedservices.vt.com/api/v1/posts`
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "post text",
|
||||
"type": "text",
|
||||
"status": "draft",
|
||||
"visibility": "public"
|
||||
}
|
||||
```
|
||||
|
||||
2. `POST https://feed.sharedservices.vt.com/api/v1/posts/{id}/publish` with `{ "content": "post text" }`.
|
||||
|
||||
The create call can return `status: "draft"` after the post is already on the home feed. Reload the first page of the home feed and treat the post as published when its id is there.
|
||||
|
||||
### Create a post with an image or video
|
||||
|
||||
1. Create a draft as above, with `type` set to `image` or `video`.
|
||||
2. `POST https://feed.sharedservices.vt.com/api/v1/attachments/presigned-urls`
|
||||
|
||||
```json
|
||||
{
|
||||
"postId": "<draft id>",
|
||||
"files": [{ "name": "photo.jpg", "type": "image/jpeg", "size": 12345 }]
|
||||
}
|
||||
```
|
||||
|
||||
`files` is 1 to 10 items. `type` must match `image/jpeg`, `image/jpg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `video/mp4`, `video/quicktime`, or `video/webm`.
|
||||
|
||||
3. Upload each file with HTTP PUT to the returned presigned URL. The server does this. The browser does not receive the feed token in order to do it.
|
||||
4. `POST https://feed.sharedservices.vt.com/api/v1/attachments/confirm-uploads`
|
||||
|
||||
```json
|
||||
{
|
||||
"postId": "<draft id>",
|
||||
"uploads": [{
|
||||
"attachmentId": "<from step 2>",
|
||||
"s3Key": "<from step 2>",
|
||||
"mediaType": "image",
|
||||
"filename": "photo.jpg"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
`mediaType` is `image` or `video`. Image confirmation blocks until processing finishes. Video confirmation returns while transcoding continues. Publish anyway. The card shows the thumbnail until `hlsUrl` exists, then the player.
|
||||
5. Publish with the same publish call as a text post.
|
||||
|
||||
### Delete
|
||||
|
||||
`DELETE https://feed.sharedservices.vt.com/api/v1/posts/{id}`
|
||||
|
||||
Offer this only when `post.author.id` equals the signed-in feed user id. Remove the card after HTTP 200.
|
||||
|
||||
## App routes
|
||||
|
||||
These are this app's routes, not Minnect's.
|
||||
|
||||
| Method | Path | Behavior |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/session` | Body `{ email, password }`. Run sign-in, profile me, and the feed-token exchange. Set the cookie. Return `{ name, email, pictureUrl }` |
|
||||
| `DELETE` | `/api/session` | Revoke, delete the session, clear the cookie |
|
||||
| `GET` | `/api/session` | Return the signed-in person, or 401 |
|
||||
| `GET` | `/api/feed?cursor=` | One page of post cards plus `nextCursor` |
|
||||
| `POST` | `/api/posts` | JSON text post, or multipart with `content` and one file |
|
||||
| `POST` | `/api/posts/:id/like` | Like |
|
||||
| `DELETE` | `/api/posts/:id/like` | Unlike |
|
||||
| `GET` | `/api/posts/:id/comments` | Comment list |
|
||||
| `POST` | `/api/posts/:id/comments` | Body `{ content }` |
|
||||
| `DELETE` | `/api/posts/:id` | Delete when the caller is the author |
|
||||
|
||||
Every route except `POST /api/session` requires the session cookie. A missing or dead session is HTTP 401 with `{ "error": "sign_in" }`. The page then shows the sign-in screen.
|
||||
|
||||
## Screen behavior
|
||||
|
||||
- Empty feed: "No posts yet." The composer stays usable.
|
||||
- Feed request failed: "The feed did not load." with a retry control. Keep the person signed in.
|
||||
- Like, comment, and publish failures name the action and leave the previous card in place.
|
||||
- The composer clears only after the new post is on the reloaded first page.
|
||||
- Long text is fully visible. Do not truncate a post.
|
||||
- The layout is one column, readable on a phone and on a desktop window.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Run these with two real Minnect accounts, A and B.
|
||||
|
||||
1. Signed out, opening the feed route returns 401 and the sign-in screen.
|
||||
2. A wrong password shows "Email or password is incorrect." and creates no session.
|
||||
3. Account A signs in and sees the home feed, including author, text, and a working image or video on a card that has one.
|
||||
4. Account A publishes a short public text post. After reload, that post is the newest card and its author is A.
|
||||
5. Account B signs in on the same deployment, sees A's post, likes it, and comments. Account A sees the like and the comment.
|
||||
6. Account A cannot see a delete control on B's posts. Account A can delete the post from step 4, and it leaves the feed.
|
||||
7. Sign-out clears the cookie. The next feed request returns 401.
|
||||
8. A search of the built frontend assets finds no access token, refresh token, or password.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The home-feed path that is known to work is `/api/v1/feeds/home/items`. The OpenAPI form `/api/v1/feeds/{feedKey}/items` with `feedKey=home` is the same resource. Use the first path.
|
||||
- Like is idempotent. Do not retry a like in a loop.
|
||||
- Publish can report `draft` while the post is already public. The home feed is the check that matters.
|
||||
- Saved feed JSON in this repository is an old sample. Do not use it as the data source. The live calls above are the source.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Minnect feed browser: luxury redesign
|
||||
|
||||
A visual and interaction redesign of the feed browser (`frontend-helper`). The functions do not change: sign in, read the home feed, like, comment, publish text, image and video posts, delete your own post, find experts, featured experts, trending minnects. Every screen, style and motion changes.
|
||||
|
||||
This file is the build contract for the redesign. The approved mockup is the visual reference: https://claude.ai/artifact/GQFEUeXcNuC2f4k8XcjwuE (boards: Desktop feed, Mobile feed dark, Mobile feed light, Sign in, Color and components, Button styles). Where this file and the mockup disagree, this file wins. The original contract, `docs/minnect-feed-browser.spec.md`, still governs data, sessions, hosts and security.
|
||||
|
||||
Approved by Cesar on 2026-10-19.
|
||||
|
||||
## Direction
|
||||
|
||||
Minnect sells human answers from experts to people making business decisions ("Human Answers for Human Decisions"). The app must look like a luxury business product, not a social network and not a WordPress page.
|
||||
|
||||
- Monochrome luxury. Obsidian, graphite and ivory carry the screen. Minnect crimson is the only color.
|
||||
- Crimson appears only where the person acts or where something is live: Post, like, the new-posts pill, the live dot, the selected featured expert.
|
||||
- No gold or champagne anywhere. It was tried and rejected as gimmicky.
|
||||
- Dark mode is the default. Light mode is required and equal in quality.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new Minnect features. No share, no "who liked", no follow, no booking, no profile pages. The Minnect API does not support them (spike `8kr7vfa5n`).
|
||||
- No general user search. Expert search only (the Finding-9 rule in the original contract).
|
||||
- No UI framework or component library. Plain CSS in the existing app.
|
||||
- No change to the session, token or proxy design.
|
||||
|
||||
## Design tokens
|
||||
|
||||
Define every color as a CSS custom property on the app root, with a light set and a dark set. Components use only the tokens.
|
||||
|
||||
| Token | Dark (default) | Light | Use |
|
||||
|---|---|---|---|
|
||||
| `--bg` | `#09090B` | `#EFEDE9` | Page canvas |
|
||||
| `--tile` | `#121215` | `#FBFAF8` | Panels, post cards |
|
||||
| `--tile-2` | `#19191D` | `#F3F1EE` | Insets, fields, comment bubbles |
|
||||
| `--tile-3` | `#232328` | `#E8E5E0` | Disabled buttons, skeletons |
|
||||
| `--line` | `rgba(255,255,255,.07)` | `rgba(26,28,31,.08)` | Hairlines |
|
||||
| `--line-2` | `rgba(255,255,255,.15)` | `rgba(26,28,31,.16)` | Stronger outlines |
|
||||
| `--ink` | `#F4F2EE` | `#1A1C1F` | Body text, names |
|
||||
| `--ink-2` | `#BFBDB8` | `#46484D` | Actions, labels |
|
||||
| `--ink-3` | `#95938E` | `#686A70` | Time, meta |
|
||||
| `--accent` | `#C8141C` | `#C8141C` | Crimson, derived from the app's `#EC1C24` |
|
||||
| `--danger` | `#FF6B70` | `#B3121A` | Delete text |
|
||||
| `--glass` | `rgba(18,18,21,.72)` | `rgba(251,250,248,.74)` | Frosted surfaces, with `backdrop-filter: blur()` |
|
||||
|
||||
Derive the crimson shades with `color-mix()`: highlight 26% white, shadow 30% black, soft fill 88% transparent.
|
||||
|
||||
Text contrast: `--ink-3` on `--tile` is at least 4.5:1 in both themes. Do not use a lighter meta color.
|
||||
|
||||
## Typography
|
||||
|
||||
- Manrope (400 to 800) for everything except titles. Load with `next/font/google`.
|
||||
- Cormorant Garamond (500, 600, italic 500) for titles only: the feed title, panel titles, featured expert names, sheet titles, and text-only post slides on mobile.
|
||||
- The wordmark is `MINNECT`, Manrope 800, letter spacing 0.26em.
|
||||
- Post body: 15px, line height 1.7. Meta: 12.5px.
|
||||
- Eyebrow labels: 11px, 700, uppercase, letter spacing 0.18em, `--ink-3`.
|
||||
|
||||
## Shape
|
||||
|
||||
- Panels 18px radius. Post cards 16px. Media and controls 12px. Chips fully round.
|
||||
- Avatars are hexagons, the same pointy-top shape as the logo: `clip-path: polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%)`, width to height ratio 0.866.
|
||||
- Avatar sizes: 46 by 53 (posts), 38 by 44 (lists), 30 by 35 (comments). A photo fills the hexagon. Without a photo, initials sit on a dark muted gradient (oxblood, navy, forest, slate, plum, graphite).
|
||||
|
||||
## Buttons
|
||||
|
||||
Five styles, each with one job. They come from uiverse.io picks and are recolored to the tokens.
|
||||
|
||||
| Style | Source | Used for | Behavior |
|
||||
|---|---|---|---|
|
||||
| Flip reveal | gharsh11032000 / friendly-turkey-54 | Post (composer) | Face is `--ink` with `--bg` text: "POST" plus a send icon. Hover slides up a crimson panel reading "PUBLISH" plus the send icon. Disabled (nothing to post): solid `--tile-3` face, `--ink-2` text, hover shows "WRITE FIRST". While posting: shows "POSTING", no flip. Never lower its opacity. |
|
||||
| Circle expand | cssbuttons-io / massive-mayfly-74 | New-posts pill | Glass pill with a crimson circle and up arrow on the left. Hover grows the circle across the pill and turns the text white. |
|
||||
| Outline fill sweep | yaasiinaxmed / serious-stingray-77 | Category chips, Cancel | 1.5px outline at rest. Hover sweeps an `--ink` fill in from the bottom-right corner and turns the text `--bg`. A selected chip stays filled. |
|
||||
| Soft depth, rising liquid | shah1345 / spicy-liger-32 | Mobile More sheet rows, Dark/Light switch | Neumorphic raised surface on `--bg`. Hover raises crimson liquid from below and turns the text white. Press shows an inset shadow. |
|
||||
| Sparkle glow | ilkhoeri / foolish-shrimp-59 | Sign in, mobile new-post button | Crimson radial glow, ten white sparks rising. Sign in only: a folded top-right corner that unfolds on hover, and a hexagon icon that draws its outline and then fills. Mobile new-post: no fold, the plus turns 90 degrees on hover. |
|
||||
|
||||
Small icon actions (send comment, delete confirm, play) use the glossy crimson button: a three-stop crimson gradient, a white top highlight, and a light sweep on hover.
|
||||
|
||||
Rejected and not to be used: liquid waves (SmookyDev / sharp-stingray-58), generic 3D drop-shadow buttons.
|
||||
|
||||
Set `color` on button classes with enough specificity to beat any global `button { color: inherit }` rule. A Post button with ivory text on an ivory face was a real bug in the mockup.
|
||||
|
||||
## Desktop and tablet layout
|
||||
|
||||
The page is a bento grid that fits the viewport (`height: 100vh`). The page itself never scrolls. Only the feed column scrolls, so the rest of the screen stays in view.
|
||||
|
||||
Desktop, 1181px and wider: three columns, `272px | 1fr | 324px`, 14px gaps and padding.
|
||||
|
||||
- Left column: an account panel (logo, wordmark, theme toggle, the signed-in person's hex avatar, name and email, "Feed updates live" with a pulsing crimson dot). Below it, the Find experts panel fills the rest of the height and scrolls on its own.
|
||||
- Center column: the feed panel. Header ("Home feed" in serif, a refresh button), then the composer, then the scrolling list.
|
||||
- Right column: the Featured experts panel (fixed height), then the Trending minnects panel, which fills the rest and scrolls on its own.
|
||||
|
||||
1001 to 1180px: same layout with columns `244px | 1fr | 292px`.
|
||||
|
||||
761 to 1000px (tablet): two columns, `1fr | 300px`. The feed spans the full height on the left. The right column stacks the account and search panels (search capped at 250px tall) over the featured and trending panels.
|
||||
|
||||
760px and narrower: the mobile layout below.
|
||||
|
||||
Use container queries on the app root, not viewport media queries, so the layout follows the space the app actually has.
|
||||
|
||||
### Feed list
|
||||
|
||||
- Posts are cards in the scrolling list, newest first, with `scroll-snap-type: y proximity`.
|
||||
- The list fades out at its top and bottom edges with a mask gradient.
|
||||
- Cards rise in on load (12px, 550ms, staggered 70ms per card).
|
||||
- A skeleton card with a shimmer sits at the bottom while the next page loads.
|
||||
- The new-posts pill floats at the top center of the list. Clicking it shows the new posts and scrolls the list to the top smoothly.
|
||||
|
||||
### Post card
|
||||
|
||||
- Header: hex avatar, name, relative time. The person's own posts show a "YOU" outline badge and a "..." menu: Delete post, then an inline confirm ("Delete this post?", Cancel, Delete).
|
||||
- Text, then media at 16:9 with a 12px radius. Video shows a frosted round play button and the duration.
|
||||
- Footer: like (heart and count, crimson when liked, a beat animation on toggle) and comments (icon and count).
|
||||
- Comments open inside the card: list, "No comments yet" empty state, removed-comment state, and a field with a send button.
|
||||
|
||||
### Composer
|
||||
|
||||
- Collapsed: a 50px field with the person's avatar and "Share an insight with the feed".
|
||||
- Focused, or holding text or a file: it expands to about 92px and gains a shadow.
|
||||
- Toolbar: photo, video, emoji, then a divider, then a bulleted-list button, then the Ctrl + Enter hint and the flip Post button.
|
||||
- Emoji opens a frosted popover with a grid of 32 frequent emoji. Clicking one inserts it at the end of the text.
|
||||
- An attached file shows as a row: thumbnail (with a play mark for video), file name, type and size, a crimson upload progress bar, and a remove button. One file per post, as today.
|
||||
- Ctrl + Enter or Cmd + Enter publishes.
|
||||
- Formatting (decided by Cesar on 2026-10-19, spike `6lbkotok9` skipped): Minnect posts are plain text, so there is no bold or italic. The bulleted-list button inserts the plain character "• " at the start of the current line (or of each selected line). Nothing else in the post text is changed.
|
||||
|
||||
### Featured experts carousel
|
||||
|
||||
A compact spotlight that moves on its own. Nobody has to click or swipe. These are celebrity hooks, meant to draw signed-in people into talking to experts.
|
||||
|
||||
- Data: `featuredExperts` from `/api/discover`, with photo (`pictureUrl`), display name, byline, rating and review count.
|
||||
- The center expert is a large hex photo (96px wide on desktop) in a crimson hex frame, over a soft breathing crimson glow.
|
||||
- Neighbors sit at 70% scale in grayscale. Outer neighbors sit at 48% scale and 45% opacity. Items further out are hidden.
|
||||
- Every 2.8 seconds the next expert moves to the center with a 1.1s ease.
|
||||
- Below: the name in serif, the byline, the rating and review count. The caption fades up on each change.
|
||||
- Progress dots: the active one is a crimson bar. A counter ("3 / 10") sits in the panel header.
|
||||
- Hover pauses the rotation. `prefers-reduced-motion` stops autoplay.
|
||||
|
||||
### Trending minnects
|
||||
|
||||
- Cards: 16:9 thumbnail with a dark fade at the bottom, a glass chip with the expert's avatar and name, a small play button, a view count, and an "AUDIO" or "VIDEO" label.
|
||||
- Under each card, the question, at most two lines.
|
||||
- Video and audio playback stay as they work today.
|
||||
|
||||
### Find experts
|
||||
|
||||
- Search field with a focus ring, then category chips (outline fill sweep). The results list shows hex avatars, name, byline and rating.
|
||||
- Empty state, "no results" state and error state, worded as today.
|
||||
|
||||
## Mobile layout
|
||||
|
||||
Width 760px and below. One post fills the whole screen at a time, like Instagram.
|
||||
|
||||
### Full-screen feed
|
||||
|
||||
- A vertical scroller with `scroll-snap-type: y mandatory` and `scroll-snap-stop: always`. Each slide is exactly the viewport height.
|
||||
- Image and video posts: media fills the slide edge to edge, with dark fades at the top and bottom. Author (hex avatar, name, time, YOU badge) and text (at most three lines) sit over the bottom fade, in white.
|
||||
- Text-only posts: the text is the hero, in serif at 31px with a crimson opening quote mark, on the page background with a faint crimson glow in the top-left corner. Author sits at the bottom.
|
||||
- The current slide's content rises in when it snaps into place. Media eases from 104% to 100% scale.
|
||||
- Double-tapping a slide likes the post (it never unlikes) and plays a large crimson heart burst.
|
||||
- The person's own posts show the "..." menu (frosted) at the top right, with the same delete confirm.
|
||||
- After the second post, a featured experts slide: the eyebrow "FEATURED EXPERTS", the title "Ask the people everyone is talking about", the carousel at 156px wide, the caption, dots, and a "Keep scrolling" hint with a nudging chevron.
|
||||
- The last slide is a loading skeleton while the next page loads.
|
||||
- A thin crimson progress bar on the right edge shows the position in the loaded feed.
|
||||
|
||||
### Top bar
|
||||
|
||||
Frosted pills over the feed: logo and wordmark on the left, theme toggle on the right.
|
||||
|
||||
### Bottom dock
|
||||
|
||||
A frosted bar 16px above the bottom edge. The like and comment buttons act on the post currently on screen.
|
||||
|
||||
1. Like: heart and count for the current post. Crimson when liked.
|
||||
2. Comments: count for the current post. Opens the comments sheet.
|
||||
3. New post: the sparkle glow button. Opens the composer sheet.
|
||||
4. More: opens the Discover sheet.
|
||||
|
||||
On the featured and loading slides, like and comment are disabled.
|
||||
|
||||
### Sheets
|
||||
|
||||
All sheets slide up from the bottom (450ms) over a dimmed, slightly blurred backdrop. Tapping the backdrop closes them. Each has a grab handle and a close button.
|
||||
|
||||
- Comments: the current post's comments and a field with a send button.
|
||||
- Composer: the desktop composer as a sheet, titled "New post", with a 150px text area.
|
||||
- Discover (More): three rows with the soft depth style. Featured experts (a stack of three hex photos and "Robert O'Neill, Mark Moss and 8 more"), Trending minnects, Find experts. Then the Appearance switch: Dark, Light.
|
||||
- Featured experts, Trending minnects and Find experts each open their panel as an 80% height sheet. The featured carousel grows to 150px wide there.
|
||||
|
||||
## Theme
|
||||
|
||||
- Dark is the default for a first visit.
|
||||
- Toggles: the account panel (desktop), the top bar (mobile), the Discover sheet switch.
|
||||
- Store the choice in `localStorage` (inside try/catch) and apply it before first paint, so there is no flash of the wrong theme.
|
||||
|
||||
## Motion
|
||||
|
||||
- Standard ease `cubic-bezier(.2,.8,.2,1)`. Menus pop in over 240ms. Sheets slide over 450ms.
|
||||
- `prefers-reduced-motion: reduce` turns off carousel autoplay, card rise-in, sparks, heart bursts and the light sweep. State changes still happen instantly.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Every control is a real `<button>`, `<a>` or `<input>` with a label. Icon-only buttons have `aria-label`.
|
||||
- The like button uses `aria-pressed` and labels its count. Comments toggles use `aria-expanded`.
|
||||
- The carousel region is `aria-live="polite"`. The non-center items are `aria-hidden`.
|
||||
- Visible focus ring on every control, `--ink` colored.
|
||||
- Touch targets at least 44px on mobile.
|
||||
|
||||
## Sign in
|
||||
|
||||
- Obsidian page with a faint crimson glow at the top.
|
||||
- Logo and wordmark, then the eyebrow "MEMBERS", then "Human answers for *human decisions*" in serif, then a short ivory hairline.
|
||||
- Uppercase labels, 52px fields with a focus ring, and the sparkle glow Sign in button.
|
||||
- Error text as today.
|
||||
|
||||
## Acceptance
|
||||
|
||||
The redesign is done when all of these pass, in both themes, at 1440, 1024, 820 and 390px wide:
|
||||
|
||||
1. The page never scrolls. Only the feed scrolls on desktop and tablet. Mobile scrolls one post per screen and snaps.
|
||||
2. Every function in the original contract still works: sign in, read, paging, like and unlike, comments, text, image and video posts, delete own post, expert search and categories, featured experts, trending playback.
|
||||
3. The featured carousel rotates on its own, pauses on hover, and stops under reduced motion.
|
||||
4. The mobile dock's like and comment act on the post on screen. Double-tap likes.
|
||||
5. Each of the five button styles behaves as specified. The Post label is readable in every state.
|
||||
6. No gold or champagne color is used anywhere.
|
||||
7. Text contrast meets 4.5:1, checked with the browser's accessibility inspector or Lighthouse on each screen.
|
||||
8. Typecheck, lint, vitest and `next build` pass.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- Keep the data layer (`src/lib/*`, `src/app/api/*`) unchanged. This is a presentation change.
|
||||
- Replace `src/app/globals.css` with token-based styles. Split it by component if it grows past about 600 lines.
|
||||
- Add a `HexAvatar` component and replace every avatar with it.
|
||||
- The mockup source (`Main.dc.html` on the canvas) holds working CSS for every component. Port it, do not copy the canvas runtime.
|
||||
Reference in New Issue
Block a user