Merge pull request 'Promote to main: Feed browser luxury redesign' (#7) from develop into main

Reviewed-on: minnect/frontend-helper#7
This commit was merged in pull request #7.
This commit is contained in:
2026-10-20 10:33:00 -06:00
73 changed files with 8074 additions and 501 deletions
+2 -1
View File
@@ -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
+30
View File
@@ -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).
+118
View File
@@ -0,0 +1,118 @@
# ADR 0001: Styling approach for the feed browser redesign
- Status: Accepted (Cesar, 2026-10-19)
- 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.
+50
View File
@@ -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.
+286
View File
@@ -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.
+231
View File
@@ -0,0 +1,231 @@
# 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, or keyboard focus inside the carousel, pauses the rotation. `prefers-reduced-motion` stops autoplay.
- Live region (WAI-ARIA carousel pattern): `aria-live="off"` while autoplay is rotating, so slides are not announced every 2.8 seconds; `aria-live="polite"` when rotation is paused (hover or focus) or stopped (reduced motion, or a single expert).
### 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 follows the WAI-ARIA carousel pattern: `aria-live="off"` while autoplay rotates, `aria-live="polite"` while rotation is paused by hover or keyboard focus, or stopped by reduced motion. Keyboard focus inside the carousel pauses it. 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.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
// Dev-only story page for the button system (task jhxk68cdy). Not linked from
// the app and returns 404 in production builds. Shows every style in both themes.
import type { CSSProperties } from "react";
import { notFound } from "next/navigation";
import { ButtonGallery } from "@/components/ButtonGallery";
export const metadata = { title: "Buttons (dev)" };
const grid: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))", minHeight: "100vh" };
export default function ButtonsDevPage() {
if (process.env.NODE_ENV === "production") notFound();
return (
<main style={grid}>
<ButtonGallery theme="dark" />
<ButtonGallery theme="light" />
</main>
);
}
+11
View File
@@ -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 <FeedPreview />;
}
+138 -124
View File
@@ -1,128 +1,142 @@
:root { --red: #E8202A; --ink: #111; --grey: #6b6b6b; --line: #d9d9d9; --bg: #f5f5f5; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Roboto, system-ui, sans-serif; color: var(--ink); background: var(--bg); }
.center { min-height: 100vh; display: grid; place-items: center; padding: 16px; }
.card { width: 100%; max-width: 400px; background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 32px 24px; display: flex; flex-direction: column; gap: 8px; }
.card h1 { font-size: 22px; margin: 12px 0 8px; }
label { font-size: 14px; font-weight: 500; margin-top: 8px; }
input { font: inherit; padding: 12px; border: 1px solid #999; border-radius: 8px; background: #fff; 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(--red); color: #fff; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
.error { margin: 0 0 4px; padding: 10px 12px; border: 1px solid var(--red); border-radius: 8px; color: #a8141b; background: #fff3f3; font-size: 14px; }
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #fff; border-bottom: 1px solid var(--line); }
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
.shell header span { color: var(--grey); }
.shell header { position: sticky; top: 0; z-index: 5; }
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: #fff; 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: #fff; 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; }
.avatar { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--line); flex: none; }
.avatar-initials { display: grid; place-items: center; background: var(--ink); color: #fff; font-weight: 500; font-size: 14px; }
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
.post-who time { color: var(--grey); font-size: 13px; }
.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; }
.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; }
.post-video { background: #000; border-radius: 8px; overflow: hidden; }
.post-video-pending { color: #fff; padding: 48px 12px; text-align: center; }
.post-video video { display: block; width: 100%; max-height: 520px; background: #000; }
.post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; }
.heart { color: var(--red); }
.feed-message { text-align: center; color: var(--grey); padding: 32px 16px; }
.feed-message button { margin-top: 8px; }
.feed-center { display: grid; place-items: center; padding: 16px; }
.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--red); 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; }
.sentinel { height: 1px; }
.like-btn { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 4px 8px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 14px; font-weight: 400; }
.like-btn:hover:not(:disabled) { color: var(--red); background: #fff3f3; }
.like-btn.liked { color: var(--red); }
.like-btn:disabled { opacity: 1; cursor: default; }
.like-btn:disabled:not(.liked) { color: var(--grey); }
.like-error { color: #a8141b; font-size: 13px; align-self: center; }
.post-stats { align-items: center; }
.composer { background: #fff; border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
.composer-label { margin: 0; }
.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid #999; border-radius: 0; resize: vertical; background: #fff; color: var(--ink); }
.composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; }
.composer-media { display: flex; flex-direction: column; gap: 8px; }
.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; }
.composer-image span { padding: 6px 12px; border: 1px solid #999; background: #fafafa; }
.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; }
.composer-preview { display: flex; align-items: center; gap: 10px; }
.composer-preview video { width: 96px; height: 64px; object-fit: cover; border: 1px solid var(--line); background: #000; }
.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); }
.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.composer-remove { margin: 0; padding: 6px 12px; border-radius: 0; }
/* Global styles only (ADR 0001, section 6): layer order, tokens, reset, focus ring. */
@layer reset, base;
.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #eee; }
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.comment p { margin: 2px 0 0; font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
.comment-head { display: flex; gap: 8px; align-items: baseline; font-size: 13px; }
.comment-head time { color: var(--grey); font-size: 12px; }
.comment.removed .comment-removed { color: var(--grey); font-style: italic; }
.comments-empty { margin: 0; color: var(--grey); font-size: 14px; }
.comment-error { margin: 0; color: #a8141b; font-size: 13px; }
.comment-form { display: flex; gap: 8px; }
.comment-form input { flex: 1; margin: 0; }
.comment-form button { margin: 0; }
.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; }
.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
.post-menu { position: relative; margin-left: auto; }
.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; }
.menu { position: absolute; right: 0; top: 100%; z-index: 5; display: flex; flex-direction: column; gap: 2px; min-width: 150px; padding: 6px; background: #fff; border: 1px solid #e3e3e3; border-radius: 10px; box-shadow: 0 6px 18px rgba(0,0,0,.12); }
.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; }
.menu button:hover { background: #f4f4f4; }
.menu .menu-danger { color: #a8141b; }
.menu-ask { font-size: 13px; color: var(--grey); 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: #fff; }
.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: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; }
.expert-card { align-items: center; }
.minnect-card { flex-direction: column; }
.rail-text { display: flex; flex-direction: column; min-width: 0; font-size: 14px; }
.rail-name { font-size: 14px; }
.rail-byline { color: var(--grey); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-meta { color: var(--grey); font-size: 13px; }
.minnect-who { display: flex; align-items: center; gap: 8px; }
.avatar-sm { width: 28px; height: 28px; font-size: 11px; }
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.minnect-video { background: #000; 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; }
/* Tokens. Dark is the default, so it applies even without data-theme.
A nested [data-theme] element re-scopes the theme tokens (used by the dev button page). */
:root,
:root[data-theme="dark"],
[data-theme="dark"] {
--bg: #09090B;
--tile: #121215;
--tile-2: #19191D;
--tile-3: #232328;
--line: rgba(255,255,255,.07);
--line-2: rgba(255,255,255,.15);
--ink: #F4F2EE;
--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);
/* 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);
--neu-d: rgba(0,0,0,.8);
--neu-l: rgba(255,255,255,.05);
color-scheme: dark;
}
/* 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%; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: #fff; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
.chip[aria-pressed="true"] { background: var(--red); border-color: var(--red); color: #fff; }
.search-results { margin-top: 12px; }
.search-note { margin: 0; font-size: 14px; color: var(--grey); }
@media (max-width: 959px) {
.col-left .expert-search { flex-basis: 100%; }
:root[data-theme="light"],
[data-theme="light"] {
--bg: #EFEDE9;
--tile: #FBFAF8;
--tile-2: #F3F1EE;
--tile-3: #E8E5E0;
--line: rgba(26,28,31,.08);
--line-2: rgba(26,28,31,.16);
--ink: #1A1C1F;
--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);
--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);
--neu-l: rgba(255,255,255,.95);
color-scheme: light;
}
:root {
/* Crimson shades, derived from --accent (spec "Design tokens"). */
--accent-hi: color-mix(in srgb, var(--accent), white 26%);
--accent-lo: color-mix(in srgb, var(--accent), black 30%);
--accent-soft: color-mix(in srgb, var(--accent), transparent 88%);
--accent-deep: color-mix(in srgb, var(--accent), black 55%);
--accent-glow: rgba(255,110,110,.75);
--accent-glow-0: rgba(255,110,110,0);
/* Text and marks on a crimson face: white in both themes. */
--on-accent: #FFFFFF;
/* Over photos and video (mobile slides): white text, dark fades and dark glass, same in both themes. */
--on-media: #FFFFFF;
--on-media-line: rgba(255,255,255,.18);
--on-media-badge: rgba(255,255,255,.5);
--media-fade: rgba(0,0,0,.8);
--media-fade-top: rgba(0,0,0,.45);
--media-fade-0: rgba(0,0,0,0);
--media-glass: rgba(0,0,0,.28);
/* Behind an open caption: darker than the fade, so long text reads over any photo. */
--media-scrim: rgba(0,0,0,.72);
--media-shadow: rgba(0,0,0,.35);
/* HexAvatar initials: dark muted gradients, same in both themes. */
--hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12);
--hex-navy: linear-gradient(145deg, #243a63, #121d36);
--hex-forest: linear-gradient(145deg, #25573f, #112a1f);
--hex-slate: linear-gradient(145deg, #4a5663, #262d35);
--hex-plum: linear-gradient(145deg, #55294f, #2a1328);
--hex-graphite: linear-gradient(145deg, #3d3d42, #1c1c20);
--avatar-ink: #F4F2EE;
/* Shape and motion. */
--r-panel: 18px;
--r-card: 16px;
--r-ctl: 12px;
--ease: cubic-bezier(.2,.8,.2,1);
}
@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 {
body {
color: var(--ink);
background: var(--bg);
font-family: var(--font-sans), system-ui, sans-serif;
}
:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
}
/* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off
card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and
the light sweep on buttons, the featured glow, caption fade and slide (its
autoplay is stopped in FeaturedCarousel via matchMedia), and stops snap and smooth scrolling. State
changes still happen instantly. The loading spinner is kept, only slowed,
because it is the only sign that something is loading. */
@media (prefers-reduced-motion: reduce) {
.post,
.post-cm,
.post-pop,
.feed-pill,
.feed-skel::after,
.post-like-beat .post-heart,
.btn-spark *,
.btn-spark::before,
.btn-spark::after,
.btn-gloss::before,
.btn-gloss::after,
.btn-sweep::before,
.account-dot,
.featured-cf::before,
.featured-cap,
.minnect-card { animation: none !important; }
.featured-item,
.featured-ring,
.featured-dot { transition: none !important; }
.btn-sweep::before,
.btn-gloss::after { transition: none !important; }
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
.spinner { animation-duration: 2.4s !important; }
}
+38 -4
View File
@@ -1,9 +1,39 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { Roboto } from "next/font/google";
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";
import "../styles/post.css";
import "../styles/composer.css";
import "../styles/featured.css";
import "../styles/feed.css";
import "../styles/trending.css";
import "../styles/search.css";
import "../styles/mobile.css";
import "../styles/sheet.css";
const roboto = Roboto({ subsets: ["latin"], weight: ["400", "500", "700"] });
const manrope = Manrope({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-sans",
display: "swap",
});
const cormorant = Cormorant_Garamond({
subsets: ["latin"],
weight: ["500", "600"],
style: ["normal", "italic"],
variable: "--font-serif",
display: "swap",
});
// viewport-fit=cover makes env(safe-area-inset-*) report the iPhone home indicator.
export const viewport: Viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" };
export const metadata: Metadata = {
title: "Minnect feed browser",
@@ -12,7 +42,11 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={roboto.className}>
<html lang="en" className={`${manrope.variable} ${cormorant.variable}`} data-theme={DEFAULT_THEME} suppressHydrationWarning>
<head>
{/* Applies the stored theme before first paint (ADR 0001, section 5). */}
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
<body>{children}</body>
</html>
);
+14 -26
View File
@@ -1,12 +1,11 @@
"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 { DiscoverRailsView, useDiscover } from "./DiscoverRails";
import { ExpertSearch } from "./ExpertSearch";
type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person };
@@ -40,29 +39,18 @@ export function App() {
});
}} />;
}
return <SignedInHome person={state.person} onSignedOut={() => setState({ kind: "out" })} />;
}
/** The signed-in shell. Discover loads once and feeds both the right rail and the phone feed. */
function SignedInHome({ person, onSignedOut }: { person: Person; onSignedOut: () => void }) {
const discover = useDiscover();
return (
<main className="shell">
<header>
<Logo size={32} />
<div>
<strong>{state.person.name}</strong>
<span>{state.person.email}</span>
</div>
</header>
<div className="columns">
<nav className="col-left" aria-label="Main">
<ul>
<li><Link href="/" aria-current="page">Feed</Link></li>
</ul>
<ExpertSearch />
</nav>
<div className="col-center">
<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />
</div>
<aside className="col-right" aria-label="Discover">
<DiscoverRails />
</aside>
</div>
</main>
<AppShell
person={person}
search={<ExpertSearch />}
feed={<Feed feedUserId={person.feedUserId} person={person} onSignedOut={onSignedOut} featuredExperts={discover?.featuredExperts} trendingMinnects={discover?.trendingMinnects} searchPanel={<ExpertSearch />} />}
discover={<DiscoverRailsView data={discover} />}
/>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useLayoutEffect, useRef, useState, 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";
import { CompactLayoutContext, isCompactWidth } from "./layout-context";
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 (
<svg {...iconProps}>
<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>
);
}
function MoonIcon() {
return (
<svg {...iconProps}>
<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>
);
}
/** 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 (
<section className="tile account" aria-label="Account">
<div className="account-brand">
<Logo size={32} />
<span className="account-word type-wordmark">MINNECT</span>
<button type="button" className="account-theme" aria-label={`Switch to ${next} theme`} title={`Switch to ${next} theme`} onClick={toggleTheme}>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
</div>
<div className="account-me">
<HexAvatar name={person.name || person.email} pictureUrl={person.pictureUrl} size="lg" />
<div className="account-me-text">
<strong>{person.name}</strong>
<span>{person.email}</span>
</div>
</div>
<div className="account-live">
<span className="account-dot" aria-hidden="true" />
Feed updates live
</div>
</section>
);
}
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) {
const root = useRef<HTMLDivElement>(null);
const [compact, setCompact] = useState(false);
// The CSS switches layout with a container query on .app; the feed also
// needs to know, because the phone feed is different markup (full-screen slides).
useLayoutEffect(() => {
const el = root.current;
if (!el) return;
const measure = () => setCompact(isCompactWidth(el.getBoundingClientRect().width));
measure();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<CompactLayoutContext.Provider value={compact}>
<div className="app" ref={root} data-layout={compact ? "compact" : "wide"}>
<main className="bento">
<div className="bento-col bento-left">
<AccountPanel person={person} />
{compact ? null : search}
</div>
<div className="bento-col bento-center">{feed}</div>
<aside className="bento-col bento-right" aria-label="Discover">
{compact ? null : discover}
</aside>
</main>
</div>
</CompactLayoutContext.Provider>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
// Bottom sheet (spec "Sheets"): slides up over a dimmed, blurred backdrop with a
// grab handle, a serif title and a close button. Reused by the comments,
// composer and Discover sheets. Styles live in src/styles/sheet.css.
import { useEffect, useId, useRef, type ReactNode } from "react";
const FOCUSABLE = 'button:not(:disabled), a[href], input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])';
export interface BottomSheetProps {
title: string;
onClose: () => void;
children: ReactNode;
/** Extra class on the sheet, for per-sheet sizing. */
className?: string;
}
export function BottomSheet({ title, onClose, children, className }: BottomSheetProps) {
const sheet = useRef<HTMLElement | null>(null);
const titleId = useId();
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
const el = sheet.current;
if (!el) return;
const opener = document.activeElement as HTMLElement | null;
// Focus moves into the sheet: its first field, else the sheet itself.
const first = el.querySelector<HTMLElement>("input, textarea") ?? el.querySelector<HTMLElement>(FOCUSABLE);
(first ?? el).focus({ preventScroll: true });
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
// An open emoji popover closes first; it handles Escape itself.
if (el?.querySelector('[aria-haspopup="dialog"][aria-expanded="true"]')) return;
e.preventDefault();
closeRef.current();
return;
}
if (e.key !== "Tab" || !el) return;
const items = [...el.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((n) => n.offsetParent !== null || n === document.activeElement);
if (!items.length) return;
const a = items[0];
const z = items[items.length - 1];
if (e.shiftKey && (document.activeElement === a || document.activeElement === el)) {
e.preventDefault();
z.focus();
} else if (!e.shiftKey && document.activeElement === z) {
e.preventDefault();
a.focus();
}
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
// Focus returns to the opener (when it is still on the page).
if (opener && opener.isConnected) opener.focus({ preventScroll: true });
};
}, []);
return (
<>
<button type="button" className="sheet-bg" aria-label="Close" tabIndex={-1} onClick={onClose} data-testid="sheet-backdrop" />
<section
ref={sheet}
className={`sheet${className ? ` ${className}` : ""}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
data-testid="bottom-sheet"
>
<span className="sheet-grab" aria-hidden="true" />
<div className="sheet-h">
<h2 id={titleId} className="type-title type-title-strong">{title}</h2>
<button type="button" className="sheet-x" aria-label="Close" onClick={onClose}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
<div className="sheet-body">{children}</div>
</section>
</>
);
}
+79
View File
@@ -0,0 +1,79 @@
// Every button style and state, in one theme scope. Used by the dev story page
// (src/app/dev/buttons) and the button tests.
import type { CSSProperties, ReactNode } from "react";
import type { Theme } from "@/lib/theme";
import { DepthButton, FlipButton, GrowButton, IconButton, SendIcon, SparkleButton, SweepButton } from "./Buttons";
const scope: CSSProperties = {
background: "var(--bg)",
color: "var(--ink)",
padding: "32px 28px",
display: "flex",
flexDirection: "column",
gap: 28,
};
const row: CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 14 };
const heading: CSSProperties = { margin: "0 0 10px", fontSize: 12, fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--ink-3)" };
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2 style={heading}>{title}</h2>
<div style={row}>{children}</div>
</section>
);
}
const FlameIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 21c4 0 7-2.8 7-6.8 0-4.5-4-6.7-5-11.2-2.3 1.7-3.5 4-3.2 6.5C9.5 9 8.6 8 8.3 6.6 6.3 8.5 5 10.9 5 14.2 5 18.2 8 21 12 21z" />
</svg>
);
const TrashIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13" />
</svg>
);
const PlayIcon = (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5.5v13l11-6.5z" fill="currentColor" />
</svg>
);
export function ButtonGallery({ theme }: { theme: Theme }) {
return (
<div data-theme={theme} style={scope} aria-label={`Buttons, ${theme} theme`} role="region">
<h1 className="type-title type-title-strong" style={{ margin: 0 }}>
{theme === "dark" ? "Dark" : "Light"} theme
</h1>
<Section title="Flip reveal: ready, disabled, posting">
<FlipButton state="ready" type="button" />
<FlipButton state="disabled" type="button" />
<FlipButton state="posting" type="button" />
</Section>
<Section title="Circle expand">
<GrowButton label="3 new posts" />
</Section>
<Section title="Outline fill sweep">
<SweepButton label="Business" selected />
<SweepButton label="Media" selected={false} />
<SweepButton label="Cancel" />
</Section>
<Section title="Soft depth, rising liquid">
<div style={{ width: 280 }}>
<DepthButton label="Trending minnects" sublabel="What people are watching" icon={FlameIcon} />
</div>
</Section>
<Section title="Sparkle glow: sign in, mobile new post">
<SparkleButton label="Sign in" />
<SparkleButton variant="fab" label="New post" />
</Section>
<Section title="Glossy crimson icon button">
<IconButton label="Send comment" icon={<SendIcon />} />
<IconButton label="Delete post" icon={TrashIcon} size="sm" />
<IconButton label="Play video" icon={PlayIcon} shape="round" />
<IconButton label="Send comment (disabled)" icon={<SendIcon />} disabled />
</Section>
</div>
);
}
+242
View File
@@ -0,0 +1,242 @@
// Button system (spec "Buttons", ADR 0001). Styles live in src/styles/buttons.css.
// Each component renders a native <button>; extra button props pass through.
import type { ButtonHTMLAttributes, ReactNode } from "react";
type NativeButton = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children">;
const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(" ");
const svgProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
export function SendIcon() {
return (
<svg {...svgProps} strokeWidth={2}>
<path d="M21 3 10.5 13.5" />
<path d="M21 3 14.5 21l-4-7.5L3 9.5z" />
</svg>
);
}
export function ArrowUpIcon() {
return (
<svg {...svgProps} strokeWidth={2.4}>
<path d="M12 19V5M6 11l6-6 6 6" />
</svg>
);
}
export function PlusIcon() {
return (
<svg {...svgProps} strokeWidth={2.2}>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
/* 1. Flip reveal ------------------------------------------------------- */
export type FlipState = "ready" | "disabled" | "posting";
export interface FlipButtonProps extends NativeButton {
state?: FlipState;
/** Face label in the ready and disabled states. */
label?: string;
/** Panel that slides up on hover when ready. */
hoverLabel?: string;
/** Panel that slides up on hover when there is nothing to post. */
disabledLabel?: string;
/** Face label while posting. */
busyLabel?: string;
}
/** The labels each state shows: face text, hover panel text, and whether the send icon shows. */
export function flipLabels(
state: FlipState,
labels: { label?: string; hoverLabel?: string; disabledLabel?: string; busyLabel?: string } = {},
) {
const { label = "Post", hoverLabel = "Publish", disabledLabel = "Write first", busyLabel = "Posting" } = labels;
if (state === "posting") return { front: busyLabel, back: null, icon: false };
if (state === "disabled") return { front: label, back: disabledLabel, icon: false };
return { front: label, back: hoverLabel, icon: true };
}
export function FlipButton({
state = "ready",
label,
hoverLabel,
disabledLabel,
busyLabel,
className,
type = "submit",
disabled,
...rest
}: FlipButtonProps) {
const { front, back, icon } = flipLabels(state, { label, hoverLabel, disabledLabel, busyLabel });
const busy = state === "posting";
return (
<button
{...rest}
type={type}
className={cx("btn-flip", busy && "btn-flip-busy", className)}
disabled={disabled || state !== "ready"}
aria-busy={busy || undefined}
>
<span className="btn-flip-front">
{front}
{icon ? <SendIcon /> : null}
</span>
{back ? (
<span className="btn-flip-back" aria-hidden="true">
{back}
{icon ? <SendIcon /> : null}
</span>
) : null}
</button>
);
}
/* 2. Circle expand ----------------------------------------------------- */
export interface GrowButtonProps extends NativeButton {
label: string;
icon?: ReactNode;
}
export function GrowButton({ label, icon, className, type = "button", ...rest }: GrowButtonProps) {
return (
<button {...rest} type={type} className={cx("btn-grow", className)}>
<span className="btn-grow-circle" aria-hidden="true">
{icon ?? <ArrowUpIcon />}
</span>
<span className="btn-grow-text">{label}</span>
</button>
);
}
/* 3. Outline fill sweep ------------------------------------------------ */
export interface SweepButtonProps extends NativeButton {
label: string;
/** Selected chip: stays filled. Sets aria-pressed when given. */
selected?: boolean;
}
export function SweepButton({ label, selected, className, type = "button", ...rest }: SweepButtonProps) {
return (
<button
{...rest}
type={type}
className={cx("btn-sweep", selected && "btn-sweep-on", className)}
aria-pressed={selected === undefined ? undefined : selected}
>
{label}
</button>
);
}
/* 4. Soft depth, rising liquid ----------------------------------------- */
export interface DepthButtonProps extends NativeButton {
label: string;
sublabel?: string;
icon?: ReactNode;
/** Raw leading content in place of the icon tile, for example a stack of avatars. */
leading?: ReactNode;
/** Trailing content, for example a chevron or the current theme name. */
trailing?: ReactNode;
}
export function DepthButton({ label, sublabel, icon, leading, trailing, className, type = "button", ...rest }: DepthButtonProps) {
return (
<button {...rest} type={type} className={cx("btn-depth", className)}>
{leading ?? null}
{!leading && icon ? (
<span className="btn-depth-icon" aria-hidden="true">
{icon}
</span>
) : null}
<span className="btn-depth-text">
<strong className="btn-depth-label">{label}</strong>
{sublabel ? <span className="btn-depth-sub">{sublabel}</span> : null}
</span>
{trailing}
</button>
);
}
/* 5. Sparkle glow ------------------------------------------------------ */
const SPARKS = Array.from({ length: 10 }, (_, i) => i);
function Sparks() {
return (
<span className="btn-spark-pts" aria-hidden="true">
{SPARKS.map((i) => (
<i key={i} />
))}
</span>
);
}
export interface SparkleButtonProps extends NativeButton {
/** "signin": folded corner and hexagon icon. "fab": mobile new-post, plus icon only. */
variant?: "signin" | "fab";
/** Visible text for "signin"; accessible name for "fab". */
label: string;
}
export function SparkleButton({ variant = "signin", label, className, type = "button", ...rest }: SparkleButtonProps) {
if (variant === "fab") {
return (
<button {...rest} type={type} aria-label={label} className={cx("btn-spark", "btn-spark-fab", className)}>
<Sparks />
<span className="btn-spark-plus">
<PlusIcon />
</span>
</button>
);
}
return (
<button {...rest} type={type} className={cx("btn-spark", className)}>
<span className="btn-spark-fold" aria-hidden="true" />
<Sparks />
<span className="btn-spark-inner">
<svg className="btn-spark-hex" viewBox="0 0 24 24" aria-hidden="true">
<polygon points="12 2 20.66 7 20.66 17 12 22 3.34 17 3.34 7" />
</svg>
{label}
</span>
</button>
);
}
/* Glossy crimson icon button ------------------------------------------- */
export interface IconButtonProps extends NativeButton {
/** Accessible name; the button shows only the icon. */
label: string;
icon: ReactNode;
shape?: "square" | "round";
size?: "md" | "sm";
}
export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) {
return (
<button
type={type}
{...rest}
aria-label={label}
title={rest.title ?? label}
className={cx("btn-gloss", shape === "round" && "btn-gloss-round", size === "sm" && "btn-gloss-sm", className)}
>
{icon}
</button>
);
}
+17 -12
View File
@@ -1,4 +1,6 @@
import type { CommentItem } from "@/lib/comment-model";
import { HexAvatar } from "@/components/HexAvatar";
import { IconButton, SendIcon } from "@/components/Buttons";
import { relativeTime } from "@/lib/feed-model";
export interface CommentsPanelProps {
@@ -17,30 +19,33 @@ export interface CommentsPanelProps {
export function CommentsPanel(p: CommentsPanelProps) {
const empty = !p.value.trim();
return (
<div className="comments" data-testid="comments-panel">
<div className="post-cm" data-testid="comments-panel">
{p.loading ? <span className="spinner" role="status" aria-label="Loading comments" /> : null}
{!p.loading && p.comments.length === 0 && !p.error ? (
<p className="comments-empty">No comments yet.</p>
<p className="post-cm-empty">No comments yet.</p>
) : null}
<ul className="comment-list">
<ul className="post-cm-list">
{p.comments.map((c) => (
<li key={c.key} className={c.removed ? "comment removed" : "comment"} data-testid="comment">
<div className="comment-head">
<strong>{c.authorName}</strong>
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
<li key={c.key} className="post-c" data-testid="comment">
<HexAvatar name={c.authorName} size="sm" />
<div className={c.removed ? "post-c-body post-c-removed" : "post-c-body"}>
<div className="post-c-head">
<strong>{c.authorName}</strong>
<time dateTime={c.createdAt ?? undefined}>{relativeTime(c.createdAt, p.now)}</time>
</div>
{c.removed ? <p>This comment was removed.</p> : <p>{c.content}</p>}
</div>
{c.removed ? <p className="comment-removed">This comment was removed.</p> : <p>{c.content}</p>}
</li>
))}
</ul>
{p.error ? (
<p className="comment-error" role="alert">
<p className="post-error" role="alert">
{p.error}{" "}
{p.onRetry ? <button type="button" className="link-btn" onClick={p.onRetry}>Try again</button> : null}
{p.onRetry ? <button type="button" className="post-link" onClick={p.onRetry}>Try again</button> : null}
</p>
) : null}
<form
className="comment-form"
className="post-cm-form"
onSubmit={(e) => {
e.preventDefault();
if (!empty && !p.busy) p.onSubmit?.();
@@ -54,7 +59,7 @@ export function CommentsPanel(p: CommentsPanelProps) {
maxLength={5000}
onChange={(e) => p.onChange?.(e.target.value)}
/>
<button type="submit" disabled={empty || p.busy}>{p.busy ? "Posting" : "Post"}</button>
<IconButton type="submit" disabled={empty || p.busy} label={p.busy ? "Posting" : "Post"} icon={<SendIcon />} />
</form>
</div>
);
+261 -44
View File
@@ -1,6 +1,18 @@
"use client";
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
// Composer (spec "Composer", ADR 0001). Styles live in src/styles/composer.css.
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type FocusEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import { FlipButton, type FlipState } from "./Buttons";
import { HexAvatar } from "./HexAvatar";
import { attachmentMeta, COMPOSER_EMOJI, insertBullets } from "./composer-text";
export interface ComposerProps {
value: string;
@@ -12,17 +24,42 @@ export interface ComposerProps {
/** When present, the composer shows image and video pickers and reports the choice. */
onFile?: (file: File | null) => void;
onSubmit: () => void;
/** The signed-in person, shown as a hex avatar beside the field. */
person?: { name: string; email?: string; pictureUrl?: string | null } | null;
}
// Content types the picker offers (spec: allowed image types).
const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic";
const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm";
export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit }: ComposerProps) {
const iconProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
/** True for Ctrl + Enter or Cmd + Enter. */
export function isPublishShortcut(e: { key: string; ctrlKey: boolean; metaKey: boolean }): boolean {
return e.key === "Enter" && (e.ctrlKey || e.metaKey);
}
export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit, person }: ComposerProps) {
const empty = value.trim().length === 0;
// A post needs either text or a file.
const canSubmit = (!empty || !!file) && !busy;
const [preview, setPreview] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const [emojiOpen, setEmojiOpen] = useState(false);
const text = useRef<HTMLTextAreaElement | null>(null);
const imageInput = useRef<HTMLInputElement | null>(null);
const videoInput = useRef<HTMLInputElement | null>(null);
const emojiWrap = useRef<HTMLDivElement | null>(null);
// Caret to restore after a toolbar edit re-renders the text.
const pendingSelection = useRef<[number, number] | null>(null);
// Build a preview URL client-side only, and revoke it when the file changes.
useEffect(() => {
@@ -35,10 +72,44 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
return () => URL.revokeObjectURL(url);
}, [file]);
useEffect(() => {
const sel = pendingSelection.current;
if (!sel || !text.current) return;
pendingSelection.current = null;
text.current.focus();
text.current.setSelectionRange(sel[0], sel[1]);
}, [value]);
// Close the emoji popover on an outside click or Escape.
useEffect(() => {
if (!emojiOpen) return;
function onDown(e: MouseEvent) {
if (emojiWrap.current && !emojiWrap.current.contains(e.target as Node)) setEmojiOpen(false);
}
function onKey(e: globalThis.KeyboardEvent) {
if (e.key === "Escape") setEmojiOpen(false);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [emojiOpen]);
useEffect(() => {
if (busy) setEmojiOpen(false);
}, [busy]);
function submit(e: FormEvent) {
e.preventDefault();
if (canSubmit) onSubmit();
}
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (!isPublishShortcut(e)) return;
e.preventDefault();
if (canSubmit) onSubmit();
}
function pick(e: ChangeEvent<HTMLInputElement>) {
const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null;
onFile?.(chosen);
@@ -48,56 +119,202 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
function remove() {
onFile?.(null);
}
function addEmoji(emoji: string) {
// Spec: the emoji goes at the end of the text.
const next = value + emoji;
pendingSelection.current = [next.length, next.length];
onChange(next);
setEmojiOpen(false);
}
function addBullets() {
const el = text.current;
const start = el ? el.selectionStart : value.length;
const end = el ? el.selectionEnd : value.length;
const edit = insertBullets(value, start, end);
pendingSelection.current = [edit.selectionStart, edit.selectionEnd];
if (edit.text !== value) onChange(edit.text);
else el?.focus();
}
function onFocus() {
setFocused(true);
}
function onBlur(e: FocusEvent<HTMLFormElement>) {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
}
const isVideo = !!file && file.type.startsWith("video/");
const open = focused || !empty || !!file;
const flip: FlipState = busy ? "posting" : canSubmit ? "ready" : "disabled";
return (
<form className="composer" onSubmit={submit}>
<label htmlFor="composer-text" className="composer-label">New post</label>
<textarea
id="composer-text"
value={value}
rows={3}
placeholder="Share something with the feed"
disabled={busy}
onChange={(e) => onChange(e.target.value)}
/>
{onFile ? (
<div className="composer-media">
<label className="composer-image">
<span>Add image</span>
<input
type="file"
accept={IMAGE_ACCEPT}
disabled={busy}
onChange={pick}
/>
</label>
<label className="composer-image composer-video">
<span>Add video</span>
<input type="file" accept={VIDEO_ACCEPT} disabled={busy} onChange={pick} />
</label>
{file ? (
<div className="composer-preview">
{preview && isVideo ? (
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
) : preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt={`Selected image: ${file.name}`} />
) : null}
<span className="composer-filename">{file.name}</span>
<button type="button" className="composer-remove" onClick={remove} disabled={busy}>
Remove
</button>
<form
className={`composer${open ? " composer-open" : ""}`}
onSubmit={submit}
onFocus={onFocus}
onBlur={onBlur}
>
<div className="composer-top">
{person ? <HexAvatar name={person.name || person.email || ""} pictureUrl={person.pictureUrl} size="lg" /> : null}
<label htmlFor="composer-text" className="composer-label">New post</label>
<textarea
id="composer-text"
ref={text}
value={value}
placeholder="Share an insight with the feed"
disabled={busy}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
</div>
{onFile && file ? (
<div className={`composer-att${person ? " composer-att-indent" : ""}`}>
<span className="composer-thumb">
{preview && isVideo ? (
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
) : preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt={`Selected image: ${file.name}`} />
) : null}
{isVideo ? (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5.5v13l11-6.5z" fill="currentColor" />
</svg>
) : null}
</span>
<div className="composer-att-text">
<strong className="composer-filename">{file.name}</strong>
<span>{attachmentMeta(file)}{busy ? ", uploading" : ""}</span>
<div
className={`composer-prog${busy ? " composer-prog-busy" : ""}`}
role="progressbar"
aria-label={busy ? "Uploading" : "Attached"}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={busy ? undefined : 100}
>
<i />
</div>
) : null}
</div>
<button
type="button"
className="composer-tool composer-remove"
aria-label="Remove attachment"
title="Remove attachment"
onClick={remove}
disabled={busy}
>
<svg {...iconProps}>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</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}
<button type="submit" className="composer-post" disabled={!canSubmit}>
{busy ? "Posting" : "Post"}
</button>
<div className="composer-bar">
{onFile ? (
<>
<button
type="button"
className="composer-tool"
aria-label="Add image"
title="Add image"
disabled={busy}
onClick={() => imageInput.current?.click()}
>
<svg {...iconProps}>
<rect x="3" y="4" width="18" height="16" rx="3" />
<circle cx="9" cy="10" r="1.8" />
<path d="m21 16-5-5-9 9" />
</svg>
</button>
<input
ref={imageInput}
className="composer-file"
type="file"
accept={IMAGE_ACCEPT}
disabled={busy}
tabIndex={-1}
aria-hidden="true"
onChange={pick}
/>
<button
type="button"
className="composer-tool"
aria-label="Add video"
title="Add video"
disabled={busy}
onClick={() => videoInput.current?.click()}
>
<svg {...iconProps}>
<rect x="2.5" y="5.5" width="13" height="13" rx="3" />
<path d="m15.5 10 6-3.5v11l-6-3.5" />
</svg>
</button>
<input
ref={videoInput}
className="composer-file"
type="file"
accept={VIDEO_ACCEPT}
disabled={busy}
tabIndex={-1}
aria-hidden="true"
onChange={pick}
/>
</>
) : null}
<div className="composer-emoji-wrap" ref={emojiWrap}>
<button
type="button"
className={`composer-tool${emojiOpen ? " composer-tool-on" : ""}`}
aria-label="Add emoji"
title="Emoji"
aria-expanded={emojiOpen}
aria-haspopup="dialog"
disabled={busy}
onClick={() => setEmojiOpen((o) => !o)}
>
<svg {...iconProps}>
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
<path d="M9 9.5h.01M15 9.5h.01" strokeWidth={2.8} />
</svg>
</button>
{emojiOpen ? (
<div className="composer-emoji" role="dialog" aria-label="Emoji">
<span className="composer-eyebrow">Frequently used</span>
<div className="composer-emoji-grid">
{COMPOSER_EMOJI.map((m) => (
<button type="button" key={m} aria-label={`Insert ${m}`} onClick={() => addEmoji(m)}>
{m}
</button>
))}
</div>
</div>
) : null}
</div>
<span className="composer-sep" aria-hidden="true" />
<button
type="button"
className="composer-tool"
aria-label="Bulleted list"
title="Bulleted list"
disabled={busy}
onMouseDown={(e) => e.preventDefault()}
onClick={addBullets}
>
<svg {...iconProps}>
<path d="M9 6h11M9 12h11M9 18h11" />
<path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" strokeWidth={2.8} />
</svg>
</button>
<span className="composer-spacer" />
<span className="composer-kbd" aria-hidden="true">
<b>Ctrl</b> + <b>Enter</b>
</span>
<FlipButton state={flip} className="composer-post" type="submit" aria-keyshortcuts="Control+Enter Meta+Enter" />
</div>
</form>
);
}
+9 -5
View File
@@ -3,26 +3,30 @@
import { useEffect, useState } from "react";
import type { DiscoverData } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
if (!data) return null;
return (
<>
<FeaturedExpertsRail experts={data.featuredExperts} />
<FeaturedCarousel experts={data.featuredExperts} />
<TrendingMinnectsRail minnects={data.trendingMinnects} />
</>
);
}
/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */
export function DiscoverRails() {
/** Loads /api/discover once. Failure leaves it null; the feed is unaffected. */
export function useDiscover(): DiscoverData | null {
const [data, setData] = useState<DiscoverData | null>(null);
useEffect(() => {
let live = true;
fetchDiscover().then((d) => { if (live) setData(d); }, () => {});
return () => { live = false; };
}, []);
return <DiscoverRailsView data={data} />;
return data;
}
export function DiscoverRails() {
return <DiscoverRailsView data={useDiscover()} />;
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
// Mobile Discover sheet (spec "Sheets"): three soft depth rows that open the
// Featured experts, Trending minnects and Find experts panels as 80% height
// sheets, then the Dark/Light appearance switch. Styles: src/styles/sheet.css.
import type { ReactNode } from "react";
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import { useTheme } from "@/lib/use-theme";
import type { Theme } from "@/lib/theme";
import { BottomSheet } from "./BottomSheet";
import { DepthButton } from "./Buttons";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { HexAvatar } from "./HexAvatar";
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
export type DiscoverPanel = "featured" | "trending" | "search";
/** Width of the center photo in the featured panel sheet. */
export const PANEL_CAROUSEL_SIZE = 150;
/** "Name1, Name2 and N more" for the Featured experts row; shorter lists read plainly. */
export function featuredSubtitle(experts: Pick<DiscoverExpert, "displayName">[]): string {
const names = experts.map((e) => e.displayName || "Expert");
if (names.length === 0) return "";
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and ${names.length - 2} more`;
}
function Chevron() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="btn-depth-chev">
<path d="m9 6 6 6-6 6" />
</svg>
);
}
const icon = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round" } as const;
function FlameIcon() {
return (
<svg {...icon}>
<path d="M12 21c4 0 7-2.8 7-6.8 0-4.5-4-6.7-5-11.2-2.3 1.7-3.5 4-3.2 6.5C9.5 9 8.6 8 8.3 6.6 6.3 8.5 5 10.9 5 14.2 5 18.2 8 21 12 21z" />
</svg>
);
}
function SearchIcon() {
return (
<svg {...icon}>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
function MoonIcon() {
return (
<svg {...icon} aria-hidden="true">
<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>
);
}
function SunIcon() {
return (
<svg {...icon} aria-hidden="true">
<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>
);
}
/** The Dark/Light switch in the soft depth style; the choice persists through useTheme. */
export function AppearanceSwitch() {
const { theme, setTheme } = useTheme();
const options: { value: Theme; label: string; icon: ReactNode }[] = [
{ value: "dark", label: "Dark", icon: <MoonIcon /> },
{ value: "light", label: "Light", icon: <SunIcon /> },
];
return (
<div className="seg-depth" role="group" aria-label="Appearance">
{options.map((o) => (
<button key={o.value} type="button" className={theme === o.value ? "on" : undefined} aria-pressed={theme === o.value} onClick={() => setTheme(o.value)}>
{o.icon}
{o.label}
</button>
))}
</div>
);
}
export interface DiscoverSheetProps {
experts: DiscoverExpert[];
onOpen: (panel: DiscoverPanel) => void;
onClose: () => void;
}
export function DiscoverSheet({ experts, onOpen, onClose }: DiscoverSheetProps) {
return (
<BottomSheet title="Discover" onClose={onClose} className="sheet-discover">
{experts.length > 0 ? (
<DepthButton
label="Featured experts"
sublabel={featuredSubtitle(experts)}
onClick={() => onOpen("featured")}
leading={
<span className="depth-stack" aria-hidden="true">
{experts.slice(0, 3).map((e) => (
<HexAvatar key={String(e.id)} name={e.displayName || "Expert"} pictureUrl={e.pictureUrl} size="sm" />
))}
</span>
}
trailing={<Chevron />}
/>
) : null}
<DepthButton label="Trending minnects" sublabel="Answers people are watching" icon={<FlameIcon />} trailing={<Chevron />} onClick={() => onOpen("trending")} />
<DepthButton label="Find experts" sublabel="Search by name, topic or category" icon={<SearchIcon />} trailing={<Chevron />} onClick={() => onOpen("search")} />
<span className="type-eyebrow sheet-sub">Appearance</span>
<AppearanceSwitch />
</BottomSheet>
);
}
export interface DiscoverPanelSheetProps {
panel: DiscoverPanel;
experts: DiscoverExpert[];
trending: DiscoverMinnect[];
search?: ReactNode;
onClose: () => void;
}
const TITLES: Record<DiscoverPanel, string> = {
featured: "Featured experts",
trending: "Trending minnects",
search: "Find experts",
};
/** One Discover panel as an 80% height sheet with a close button; reuses the desktop components. */
export function DiscoverPanelSheet({ panel, experts, trending, search, onClose }: DiscoverPanelSheetProps) {
return (
<BottomSheet title={TITLES[panel]} onClose={onClose} className={`sheet-panel sheet-panel-${panel}`}>
{panel === "featured" ? <FeaturedCarousel experts={experts} size={PANEL_CAROUSEL_SIZE} variant="slide" /> : null}
{panel === "trending" ? <TrendingMinnectsRail minnects={trending} /> : null}
{panel === "search" ? search : null}
</BottomSheet>
);
}
+39 -39
View File
@@ -1,11 +1,12 @@
"use client";
import { SweepButton } from "@/components/Buttons";
import { HexAvatar } from "@/components/HexAvatar";
import { useEffect, useRef, useState } from "react";
import type { DiscoverCategory } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import type { ExpertResult } from "@/lib/experts-search";
import { fetchExperts } from "@/lib/experts-search-client";
import { initials } from "@/lib/feed-model";
export type SearchStatus = "idle" | "loading" | "ready" | "error";
@@ -17,67 +18,70 @@ export interface ExpertSearchViewProps {
onCategory: (name: string | null) => void;
status: SearchStatus;
results: ExpertResult[];
defaultOpen?: boolean;
}
export function ExpertSearchView(p: ExpertSearchViewProps) {
return (
<details className="expert-search" open={p.defaultOpen}>
<summary>Find experts</summary>
<form role="search" onSubmit={(e) => e.preventDefault()}>
<section className="tile panel expert-search" aria-labelledby="h-find">
<div className="panel-head">
<h2 id="h-find" className="type-title type-title-strong">Find experts</h2>
</div>
<form role="search" className="search-form" onSubmit={(e) => e.preventDefault()}>
<label htmlFor="expert-q">Search experts</label>
<input
id="expert-q"
type="search"
value={p.query}
placeholder="Name or topic"
autoComplete="off"
maxLength={80}
onChange={(e) => p.onQuery(e.target.value)}
/>
<div className="search-field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
<input
id="expert-q"
type="search"
value={p.query}
placeholder="Name or topic"
autoComplete="off"
maxLength={80}
onChange={(e) => p.onQuery(e.target.value)}
/>
</div>
</form>
{p.categories.length > 0 ? (
<div role="group" aria-label="Browse experts by category" className="chips">
{p.categories.map((c) => (
<button
<SweepButton
key={String(c.id)}
type="button"
className="chip"
aria-pressed={p.activeCategory === c.name}
label={c.name}
selected={p.activeCategory === c.name}
onClick={() => p.onCategory(p.activeCategory === c.name ? null : c.name)}
>
{c.name}
</button>
/>
))}
</div>
) : null}
<div aria-live="polite" className="search-results">
<div aria-live="polite" className="search-results panel-scroll fade-edges">
{p.status === "loading" ? <p className="search-note">Searching...</p> : null}
{p.status === "error" ? <p className="search-note" role="alert">Search is unavailable. Try again.</p> : null}
{p.status === "ready" && p.results.length === 0 ? (
<p className="search-note">No experts found.</p>
) : null}
{p.status === "ready" && p.results.length > 0 ? (
<ul className="rail-list">
<ul className="rail-list search-rows">
{p.results.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
{e.rating !== null ? <span className="rail-meta">★ {e.rating.toFixed(1)}</span> : null}
<li key={String(e.id)} className="expert-row" data-testid="search-result">
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="expert-text">
<strong>{e.displayName || "Expert"}</strong>
{e.byline ? <span>{e.byline}</span> : null}
</div>
{e.rating !== null ? (
<span className="expert-rate" aria-label={`Rating ${e.rating.toFixed(1)}`}>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 2.8 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.6l-5.8 3.1 1.1-6.5L2.6 9.6l6.5-.9L12 2.8Z" /></svg>
{e.rating.toFixed(1)}
</span>
) : null}
</li>
))}
</ul>
) : null}
</div>
</details>
</section>
);
}
@@ -89,11 +93,9 @@ export function ExpertSearch() {
const [categories, setCategories] = useState<DiscoverCategory[]>([]);
const [status, setStatus] = useState<SearchStatus>("idle");
const [results, setResults] = useState<ExpertResult[]>([]);
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); },
@@ -122,8 +124,6 @@ export function ExpertSearch() {
return (
<ExpertSearchView
key={open ? "open" : "closed"}
defaultOpen={open}
query={query}
onQuery={setQuery}
categories={categories}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
import { toneFor } from "./HexAvatar";
import { featuredLiveMode, featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia(REDUCED_MOTION);
setReduced(mq.matches);
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
function Star() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" fill="currentColor" />
</svg>
);
}
function Photo({ expert, center }: { expert: DiscoverExpert; center: boolean }) {
const name = expert.displayName || "Expert";
if (expert.pictureUrl) {
// eslint-disable-next-line @next/next/no-img-element
return <img className="featured-photo" src={expert.pictureUrl} alt={center ? name : ""} />;
}
return (
<span className={`featured-photo featured-initials hex-avatar-${toneFor(name)}`} role="img" aria-label={center ? name : undefined}>
<span aria-hidden="true">{initials(name)}</span>
</span>
);
}
export interface FeaturedCarouselProps {
experts: DiscoverExpert[];
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
size?: number;
/** "slide": no tile or heading, for the phone featured slide that brings its own title. */
variant?: "panel" | "slide";
}
export function FeaturedCarousel({ experts, size = 96, variant = "panel" }: FeaturedCarouselProps) {
const count = experts.length;
const [index, setIndex] = useState(0);
const reduced = useReducedMotion();
const autoplay = useRef<FeaturedAutoplay | null>(null);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const paused = hovered || focused;
const pausedRef = useRef(false);
pausedRef.current = paused;
const rotating = count > 1 && !reduced;
useEffect(() => {
if (paused) autoplay.current?.pause();
else autoplay.current?.resume();
}, [paused]);
useEffect(() => {
if (count < 2 || reduced) return;
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
if (pausedRef.current) ctl.pause();
autoplay.current = ctl;
return () => { ctl.stop(); autoplay.current = null; };
// The index only seeds a restart; ticking must not recreate the timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, reduced]);
if (count === 0) return null;
const current = index % count;
const cap = experts[current];
const style = { "--cfw": `${size}px` } as CSSProperties;
const bare = variant === "slide";
return (
<section
className={bare ? "featured featured-bare" : "tile panel rail rail-featured featured"}
aria-labelledby={bare ? undefined : "featured-title"}
aria-label={bare ? "Featured experts" : undefined}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
data-paused={paused ? "true" : undefined}
>
{bare ? null : (
<div className="panel-head">
<h2 id="featured-title" className="type-title type-title-strong featured-title">
<Star />Featured experts
</h2>
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
</div>
)}
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
{experts.map((e, i) => {
const slot = featuredSlot(i, current, count);
return (
<div
key={String(e.id)}
className={`featured-item featured-${slot}`}
aria-hidden={slot === "c0" ? undefined : true}
data-testid="featured-item"
>
<span className="featured-ring"><Photo expert={e} center={slot === "c0"} /></span>
</div>
);
})}
</div>
<div className="featured-cap" key={current}>
<strong className="type-title type-title-strong featured-name">{cap.displayName || "Expert"}</strong>
{cap.byline ? <span className="featured-by">{cap.byline}</span> : null}
{cap.rating !== null || cap.reviewsCount !== null ? (
<span className="featured-rate">
{cap.rating !== null ? (
<span aria-label={`Rated ${cap.rating.toFixed(1)} out of 5`}><Star />{cap.rating.toFixed(1)}</span>
) : null}
{cap.reviewsCount !== null ? (
<em>({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})</em>
) : null}
</span>
) : null}
</div>
{count > 1 ? (
<div className="featured-dots" aria-hidden="true">
{experts.map((e, i) => (
<span key={String(e.id)} className={i === current ? "featured-dot featured-dot-on" : "featured-dot"} />
))}
</div>
) : <div className="featured-dots-gap" />}
</section>
);
}
-38
View File
@@ -1,38 +0,0 @@
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
if (rating === null && reviews === null) return null;
return (
<span className="rail-meta">
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
</span>
);
}
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
if (experts.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-experts">
<h2 id="rail-experts">Featured experts</h2>
<ul className="rail-list">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
<Rating rating={e.rating} reviews={e.reviewsCount} />
</div>
</li>
))}
</ul>
</section>
);
}
+59 -9
View File
@@ -3,18 +3,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
import { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
import { firstLoadFailure, mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
import { PostError, publishAndConfirm } from "@/lib/post-client";
import { DeleteError, sendDelete } from "@/lib/delete-client";
import type { ReactNode } from "react";
import type { DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import type { ComposerProps } from "./Composer";
import { FeedView, type FeedStatus } from "./FeedView";
import { useCompactLayout } from "./layout-context";
const POLL_MS = 45_000;
export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
export function Feed({
onSignedOut,
feedUserId,
person,
featuredExperts,
trendingMinnects,
searchPanel,
}: {
onSignedOut: () => void;
feedUserId?: string | null;
/** The signed-in person, shown beside the composer. */
person?: ComposerProps["person"];
/** Shown as a slide after the second post on phones. */
featuredExperts?: DiscoverExpert[];
/** Phone Discover sheet: trending minnects and the Find experts panel. */
trendingMinnects?: DiscoverMinnect[];
searchPanel?: ReactNode;
}) {
const [posts, setPosts] = useState<CardPost[]>([]);
const [status, setStatus] = useState<FeedStatus>("loading");
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [refreshFailed, setRefreshFailed] = useState(false);
const [loadMoreFailed, setLoadMoreFailed] = useState(false);
const [pending, setPending] = useState<CardPost[]>([]);
const [likeBusy, setLikeBusy] = useState<ReadonlySet<string>>(new Set());
@@ -29,8 +52,12 @@ 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<HTMLDivElement | null>(null);
const list = useRef<HTMLDivElement | null>(null);
const busy = useRef(false);
const shown = useRef<Set<string>>(new Set());
// The list element changes between the panel and the phone reel; the
// infinite-scroll observer re-attaches to the new one.
const compact = useCompactLayout();
useEffect(() => {
shown.current = new Set(posts.map((p) => p.key));
@@ -43,10 +70,13 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
[onSignedOut],
);
const loadFirst = useCallback(async () => {
// quiet: a refresh keeps the list on screen instead of swapping in the spinner.
const loadFirst = useCallback(async (quiet = false) => {
if (busy.current) return;
busy.current = true;
setStatus("loading");
setRefreshFailed(false);
if (quiet) setRefreshing(true);
else setStatus("loading");
setLoadMoreFailed(false);
try {
const page = await fetchFeedPage(null);
@@ -56,9 +86,12 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
setStatus("ready");
} catch (e) {
fail(e);
setStatus("error");
const out = firstLoadFailure(quiet);
if (out.fullError) setStatus("error");
setRefreshFailed(out.refreshFailed);
} finally {
busy.current = false;
setRefreshing(false);
}
}, [fail]);
@@ -93,11 +126,12 @@ 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();
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length]);
}, [nextCursor, status, loadMoreFailed, loadMore, posts.length, compact]);
// Real-time: poll the first page while the tab is visible.
useEffect(() => {
@@ -224,7 +258,15 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
function showNew() {
setPosts((cur) => mergePosts(cur, pending));
setPending([]);
window.scrollTo({ top: 0, behavior: "smooth" });
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
}
function refresh() {
if (busy.current) return;
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
list.current?.scrollTo({ top: 0, behavior: calm ? "auto" : "smooth" });
void loadFirst(true);
}
return (
@@ -236,6 +278,9 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
newCount={pending.length}
onRetry={() => (status === "error" ? void loadFirst() : void loadMore())}
onShowNew={showNew}
onRefresh={refresh}
refreshing={refreshing}
refreshFailed={refreshFailed}
onToggleLike={(p) => void toggleLike(p)}
likeBusy={likeBusy}
likeErrors={likeErrors}
@@ -244,8 +289,13 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
deleteBusy={deleteBusy}
deleteErrors={deleteErrors}
onSignedOut={onSignedOut}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
featuredExperts={featuredExperts}
trendingMinnects={trendingMinnects}
searchPanel={searchPanel}
hasMore={!!nextCursor}
sentinelRef={sentinel}
listRef={list}
/>
);
}
+137 -43
View File
@@ -1,7 +1,13 @@
import type { Ref } from "react";
"use client";
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, SweepButton } from "./Buttons";
import { PostCard } from "./PostCard";
import { useCompactLayout } from "./layout-context";
import { MobileFeed, type CurrentSlide } from "./MobileFeed";
export type FeedStatus = "loading" | "ready" | "error";
@@ -13,6 +19,11 @@ export interface FeedViewProps {
newCount: number;
onRetry: () => void;
onShowNew: () => void;
/** Reloads the first page; the header shows the button when it is given. */
onRefresh?: () => void;
refreshing?: boolean;
/** The last refresh failed; the posts stay and a small message shows in the header. */
refreshFailed?: boolean;
onToggleLike?: (post: CardPost) => void;
likeBusy?: ReadonlySet<string>;
likeErrors?: ReadonlySet<string>;
@@ -24,70 +35,153 @@ export interface FeedViewProps {
deleteErrors?: ReadonlySet<string>;
composer?: ComposerProps;
sentinelRef?: Ref<HTMLDivElement>;
/** The scrolling feed list; the page itself never scrolls. */
listRef?: Ref<HTMLDivElement>;
now?: number;
/** Another page can load; the phone feed ends with a loading slide. */
hasMore?: boolean;
/** Phone layout only: experts for the featured slide after the second post. */
featuredExperts?: DiscoverExpert[];
/** Phone layout only: trending minnects for the Discover sheet. */
trendingMinnects?: DiscoverMinnect[];
/** Phone layout only: the Find experts panel shown in the Discover sheet. */
searchPanel?: ReactNode;
/** Phone layout only: the slide on screen changed (for the dock). */
onCurrentChange?: (slide: CurrentSlide) => void;
/** Phone layout only: the dock's comments button, for the post on screen. */
onOpenComments?: (postId: string) => void;
/** Phone layout only: the dock's new-post button. */
onOpenComposer?: () => void;
/** Phone layout only: the dock's More button (the Discover sheet). */
onOpenMore?: () => void;
}
function Spinner() {
return <span className="spinner" role="status" aria-label="Loading" />;
}
/** Shimmer placeholder card shown while the next page loads. */
function SkeletonCard() {
return (
<div className="feed-skel" role="status" aria-label="Loading more posts" data-testid="feed-skeleton">
<div className="feed-skel-head">
<span className="feed-skel-dot" />
<span className="feed-skel-lines">
<span className="feed-skel-line feed-skel-w40" />
<span className="feed-skel-line feed-skel-w25" />
</span>
</div>
<span className="feed-skel-line feed-skel-w100" />
<span className="feed-skel-line feed-skel-w80" />
<span className="feed-skel-media" />
</div>
);
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={spinning ? "refresh-icon refresh-icon-spin" : "refresh-icon"}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
);
}
export function RefreshButton({ onRefresh, busy }: { onRefresh: () => void; busy?: boolean }) {
return (
<IconButton
label="Refresh feed"
shape="round"
size="sm"
icon={<RefreshIcon spinning={!!busy} />}
disabled={busy}
aria-busy={busy || undefined}
onClick={onRefresh}
/>
);
}
function FeedPanel({ children, composer, onRefresh, busy, refreshFailed }: { children: ReactNode; composer?: ReactNode; onRefresh?: () => void; busy?: boolean; refreshFailed?: boolean }) {
return (
<section className="tile panel feed" aria-labelledby="h-feed">
<div className="panel-head feed-head">
<h2 id="h-feed" className="type-title type-title-strong">Home feed</h2>
{refreshFailed ? <span className="feed-refresh-error" role="alert">Could not refresh. Try again.</span> : null}
{onRefresh ? <RefreshButton onRefresh={onRefresh} busy={busy} /> : null}
</div>
{composer}
{children}
</section>
);
}
export function FeedView(p: FeedViewProps) {
// At 760px and below the feed is the full-screen snap reel instead of the panel.
const compact = useCompactLayout();
if (compact) return <MobileFeed {...p} />;
if (p.status === "loading") {
return (
<section className="feed">
<FeedPanel onRefresh={p.onRefresh} busy>
<div className="feed-center">
<Spinner />
</div>
</section>
</FeedPanel>
);
}
if (p.status === "error") {
return (
<section className="feed">
<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>
</section>
</FeedPanel>
);
}
return (
<section className="feed">
{p.composer ? <Composer {...p.composer} /> : null}
{p.newCount > 0 ? (
<button type="button" className="new-posts" onClick={p.onShowNew}>
{p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`}
</button>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post) => <PostCard
key={post.key}
post={post}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<button type="button" onClick={p.onRetry}>Try again</button>
</div>
) : null}
{p.loadingMore ? (
<div className="feed-center">
<Spinner />
</div>
) : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</section>
<FeedPanel composer={p.composer ? <Composer {...p.composer} /> : null} onRefresh={p.onRefresh} busy={p.refreshing} refreshFailed={p.refreshFailed}>
<div ref={p.listRef} className="feed-list panel-scroll fade-edges">
{p.newCount > 0 ? (
<div className="feed-pill">
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
</div>
) : null}
{p.posts.length === 0 ? (
<p className="feed-message">No posts yet.</p>
) : (
p.posts.map((post, i) => <PostCard
key={post.key}
post={post}
index={i}
now={p.now}
onToggleLike={p.onToggleLike}
likeBusy={p.likeBusy?.has(post.key)}
likeError={p.likeErrors?.has(post.key)}
onSignedOut={p.onSignedOut}
canDelete={canDelete(post, p.feedUserId)}
onDelete={p.onDelete}
deleteBusy={p.deleteBusy?.has(post.key)}
deleteError={p.deleteErrors?.has(post.key)}
/>)
)}
{p.loadMoreFailed ? (
<div className="feed-message" role="alert">
<p>The feed did not load.</p>
<SweepButton label="Try again" onClick={p.onRetry} />
</div>
) : null}
{p.loadingMore ? <SkeletonCard /> : null}
<div ref={p.sentinelRef} className="sentinel" aria-hidden="true" />
</div>
</FeedPanel>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { initials } from "@/lib/feed-model";
export type HexAvatarSize = "lg" | "md" | "sm";
/** Width by height in px for each spec size (ratio 0.866). */
export const HEX_SIZES: Record<HexAvatarSize, { width: number; height: number }> = {
lg: { width: 46, height: 53 },
md: { width: 38, height: 44 },
sm: { width: 30, height: 35 },
};
export const HEX_TONES = ["oxblood", "navy", "forest", "slate", "plum", "graphite"] as const;
export type HexTone = (typeof HEX_TONES)[number];
/** Same name always gives the same tone. */
export function toneFor(name: string): HexTone {
let sum = 0;
for (const ch of name.trim().toLowerCase()) sum = (sum * 31 + ch.codePointAt(0)!) >>> 0;
return HEX_TONES[sum % HEX_TONES.length];
}
export interface HexAvatarProps {
name: string;
pictureUrl?: string | null;
size?: HexAvatarSize;
}
export function HexAvatar({ name, pictureUrl, size = "md" }: HexAvatarProps) {
const { width, height } = HEX_SIZES[size];
if (pictureUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img className={`hex-avatar hex-avatar-${size}`} src={pictureUrl} alt="" width={width} height={height} />
);
}
return (
<span className={`hex-avatar hex-avatar-${size} hex-avatar-initials hex-avatar-${toneFor(name)}`} aria-hidden="true">
{initials(name)}
</span>
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
export function Logo({ size = 44 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 48 48" role="img" aria-label="Minnect">
<polygon points="24,2 43,13 43,35 24,46 5,35 5,13" fill="#E8202A" />
<path d="M14 33V15l10 11 10-11v18" fill="none" stroke="#fff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
<polygon points="24,2 43,13 43,35 24,46 5,35 5,13" style={{ fill: "var(--accent)" }} />
<path d="M14 33V15l10 11 10-11v18" fill="none" stroke="white" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
+70 -24
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverMedia } from "@/lib/discover";
interface Props {
@@ -8,36 +9,81 @@ interface Props {
thumbnailUrl: string | null;
question: string | null;
who: string;
pictureUrl?: string | null;
views?: number | null;
}
/** Video answers play inline; audio keeps the audio control; no url shows the thumbnail only. */
export function MinnectMedia({ media, thumbnailUrl, question, who }: 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 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);
// 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);
const thumb = thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
) : null;
const isVideo = media?.kind === "video" && !videoFailed;
const isAudio = media?.kind === "audio";
const playable = isVideo || isAudio;
const play = () => {
const el = isVideo ? video.current : audio.current;
void el?.play().catch(() => {});
};
if (media?.kind === "video" && !videoFailed) {
return (
<video
className="minnect-thumb minnect-video"
controls
playsInline
preload="none"
src={media.url}
poster={thumbnailUrl ?? undefined}
aria-label={`Play answer from ${who}`}
onError={() => setVideoFailed(true)}
/>
);
}
return (
<>
{thumb}
{media?.kind === "audio" ? (
<audio className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
<div className={`trend-media${started ? " trend-media-playing" : ""}`}>
{isVideo ? (
<video
ref={video}
className="minnect-thumb minnect-video"
controls={started}
playsInline
preload="none"
src={media.url}
poster={thumbnailUrl ?? undefined}
aria-label={`Play answer from ${who}`}
onClick={started ? undefined : play}
onPlay={() => setStarted(true)}
onError={() => setVideoFailed(true)}
/>
) : thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
) : null}
<span className="trend-scrim" aria-hidden="true" />
<span className="trend-who">
<HexAvatar name={who} pictureUrl={pictureUrl} size="sm" />
<strong>{who}</strong>
</span>
{playable ? (
<button type="button" className="trend-play" aria-label={`Play answer from ${who}`} onClick={play}>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5Z" /></svg>
</button>
) : null}
</div>
{views !== null || playable ? (
<div className="trend-meta">
{views !== null ? (
<span className="trend-views">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" />
</svg>
{views.toLocaleString("en-US")} {views === 1 ? "view" : "views"}
</span>
) : <span />}
{playable ? <span className="trend-kind">{isVideo ? "Video" : "Audio"}</span> : null}
</div>
) : null}
{isAudio ? (
<audio ref={audio} className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
) : null}
</>
);
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { commentsLabel } from "@/lib/feed-model";
import { SparkleButton } from "./Buttons";
import type { CurrentSlide } from "./MobileFeed";
import type { CardPost } from "@/lib/feed-model";
const icon = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
export interface MobileDockProps {
/** The slide on screen; like and comments act on its post. */
slide: CurrentSlide;
onToggleLike?: (post: CardPost) => void;
likeBusy?: boolean;
onOpenComments?: (postId: string) => void;
onOpenComposer?: () => void;
onOpenMore?: () => void;
}
/** What the like and comment buttons show and whether they work, for the slide on screen. */
export function dockState(slide: CurrentSlide, opts: { canLike?: boolean; likeBusy?: boolean } = {}) {
const post = slide.kind === "post" ? slide.post : null;
if (!post) {
return { post: null, liked: false, likes: null, comments: null, likeLabel: "Like", commentsLabel: "Comments", likeDisabled: true, commentsDisabled: true };
}
return {
post,
liked: post.isLiked,
likes: post.likesCount,
comments: post.commentsCount,
likeLabel: post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`,
commentsLabel: `Open comments, ${commentsLabel(post.commentsCount).toLowerCase()}`,
likeDisabled: !post.id || !opts.canLike || !!opts.likeBusy,
commentsDisabled: !post.id,
};
}
/**
* Phone dock (spec "Mobile layout" > "Bottom dock"): a frosted bar 16px above the
* bottom. Like and comments act on the post on screen and are disabled on the
* featured and loading slides. Liked state and counts come from the post itself,
* the same source as the desktop like button and double-tap.
*/
export function MobileDock({ slide, onToggleLike, likeBusy, onOpenComments, onOpenComposer, onOpenMore }: MobileDockProps) {
const s = dockState(slide, { canLike: !!onToggleLike, likeBusy });
const [beat, setBeat] = useState(false);
return (
<nav className="dock" aria-label="Post actions" data-testid="feed-dock">
<button
type="button"
className={`dk dk-like${s.liked ? " dk-like-on" : ""}${beat && s.liked ? " dk-beat" : ""}`}
aria-pressed={s.liked}
aria-label={s.likeLabel}
disabled={s.likeDisabled}
onClick={() => {
if (!s.post) return;
setBeat(!s.post.isLiked);
onToggleLike?.(s.post);
}}
onAnimationEnd={() => setBeat(false)}
>
<svg {...icon} fill={s.liked ? "currentColor" : "none"}>
<path d="M12 20.5s-7.5-4.4-9.4-9.1C1.3 8 3.1 4.5 6.6 4.5c2 0 3.5 1.1 4.3 2.4.5.7 1.7.7 2.2 0 .8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.3 3.5 4 6.9-1.9 4.7-9.4 9.1-9.4 9.1z" />
</svg>
{s.likes !== null ? <span className="dk-n" data-testid="dock-likes">{s.likes}</span> : null}
</button>
<button
type="button"
className="dk dk-comments"
aria-label={s.commentsLabel}
disabled={s.commentsDisabled}
onClick={() => {
if (s.post?.id) onOpenComments?.(s.post.id);
}}
>
<svg {...icon}>
<path d="M21 12a8.5 8.5 0 0 1-12.4 7.6L3.5 21l1.4-4.6A8.5 8.5 0 1 1 21 12z" />
</svg>
{s.comments !== null ? <span className="dk-n" data-testid="dock-comments">{s.comments}</span> : null}
</button>
<SparkleButton variant="fab" label="New post" className="dk-plus" onClick={() => onOpenComposer?.()} />
<button type="button" className="dk dk-more" aria-label="View more" onClick={() => onOpenMore?.()}>
<svg {...icon}>
<rect x="3.5" y="3.5" width="7" height="7" rx="2" />
<rect x="13.5" y="3.5" width="7" height="7" rx="2" />
<rect x="3.5" y="13.5" width="7" height="7" rx="2" />
<rect x="13.5" y="13.5" width="7" height="7" rx="2" />
</svg>
More
</button>
</nav>
);
}
+708
View File
@@ -0,0 +1,708 @@
"use client";
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, SweepButton } from "./Buttons";
import { HexAvatar } from "./HexAvatar";
import { PlayIcon, PostMenu } from "./PostCard";
import { VideoPlayer } from "./VideoPlayer";
import { CaptionText, QuoteText } from "./SlideText";
import { FeaturedCarousel } from "./FeaturedCarousel";
import { Logo } from "./Logo";
import { useTheme } from "@/lib/use-theme";
import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
import { MobileDock } from "./MobileDock";
import { BottomSheet } from "./BottomSheet";
import { CommentsSection } from "./CommentsSection";
import { Composer } from "./Composer";
import { DiscoverPanelSheet, DiscoverSheet, type DiscoverPanel } from "./DiscoverSheet";
import type { DiscoverExpert } from "@/lib/discover";
import type { FeedViewProps } from "./FeedView";
/** The slide on screen: its index in the reel and its post (null on the loading slide). */
export interface CurrentSlide {
index: number;
/** "post" is a real post; "featured" and "load" are not posts (the dock disables like and comment on them). */
kind: "post" | "featured" | "load";
post: CardPost | null;
}
/** The featured experts slide comes after this many posts. */
export const FEATURED_AFTER = 2;
/** Two taps this close together (ms) make a double-tap. */
export const DOUBLE_TAP_MS = 300;
/** How long the heart burst stays on screen (ms). */
export const BURST_MS = 950;
/** True when a tap at `now` follows a tap at `last` closely enough to be a double-tap. */
export function isDoubleTap(last: number | null, now: number, windowMs = DOUBLE_TAP_MS): boolean {
return last !== null && now - last >= 0 && now - last <= windowMs;
}
/** Taps on controls (buttons, links, video, menus, fields) never count toward a double-tap. */
export function isTapTarget(target: EventTarget | null): boolean {
const el = target as Element | null;
if (!el || typeof el.closest !== "function") return true;
return !el.closest("button, a, video, input, textarea, select, [role='menu'], [role='menuitem'], [data-expanded-text]");
}
/** Index of the featured experts slide among `postCount` posts, or -1 when it is not shown. */
export function featuredSlideIndex(postCount: number, expertCount: number): number {
return expertCount > 0 && postCount >= FEATURED_AFTER ? FEATURED_AFTER : -1;
}
/** Share of a slide that must be on screen before it counts as the current one. */
const CURRENT_RATIO = 0.6;
/** Smallest finger travel, in px, that counts as a swipe to the next or previous slide. */
const SWIPE_MIN = 40;
/** Wheel steps closer together than this belong to one gesture (trackpad inertia). */
const WHEEL_QUIET_MS = 220;
/** A wheel gesture moves at most one slide in this time. */
const WHEEL_STEP_MS = 520;
/** The slide one step away from `from` in the direction of `delta`, kept inside the reel. */
export function stepTarget(from: number, delta: number, count: number): number {
if (!delta || count <= 0) return Math.min(Math.max(from, 0), Math.max(count - 1, 0));
return Math.min(Math.max(from + Math.sign(delta), 0), count - 1);
}
/** Index of the slide on screen, given which slides are now mostly visible. Keeps the old one when none is. */
export function pickCurrent(previous: number, visible: number[]): number {
if (!visible.length) return previous;
return visible.includes(previous) ? previous : Math.min(...visible);
}
function setRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value);
else if (ref) (ref as { current: T | null }).current = value;
}
/** Aspect ratio (width / height) at or below which a video fills the slide (4:5 or taller). */
export const COVER_MAX_RATIO = 0.8;
/** "cover" for portrait and near-portrait video, "contain" for wider, "cover" while the size is unknown. */
export function videoFit(width: number, height: number): "cover" | "contain" {
if (!(width > 0) || !(height > 0)) return "cover";
return width / height <= COVER_MAX_RATIO ? "cover" : "contain";
}
/** True when the person asked for less motion. Updates when the setting changes; false on the server. */
function useReducedMotion(): boolean | null {
const [reduced, setReduced] = useState<boolean | null>(null);
useEffect(() => {
if (typeof window.matchMedia !== "function") return setReduced(false);
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const read = () => setReduced(mq.matches);
read();
mq.addEventListener?.("change", read);
return () => mq.removeEventListener?.("change", read);
}, []);
return reduced;
}
function SoundIcon({ muted }: { muted: boolean }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4V5z" />
{muted ? <path d="m16 9 5 6M21 9l-5 6" /> : <path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" />}
</svg>
);
}
/**
* One media item of a slide. A video plays by itself, muted and inline, while
* `active` (its slide and pane are on screen) unless the person asked for less
* motion or the browser refuses: then the play button starts it as before.
* `hold` (a sheet is open) pauses a playing video and resumes it after.
*/
function SlideMedia({ media, author, active = false, hold = false }: { media: CardMedia; author: string; active?: boolean; hold?: boolean }) {
const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
// Real video size once metadata loads; until then the thumbnail's size decides.
const [videoSize, setVideoSize] = useState<[number, number] | null>(null);
const [thumbSize, setThumbSize] = useState<[number, number] | null>(null);
const playable = isPlayableVideo(media);
const reduced = useReducedMotion();
// The browser refused autoplay: fall back to the play button.
const [blocked, setBlocked] = useState(false);
const auto = reduced === false && !blocked;
const [muted, setMuted] = useState(getSessionMuted);
// Paused by a tap: it stays paused until tapped again or the slide leaves.
const [userPaused, setUserPaused] = useState(false);
const userPausedRef = useRef(false);
const resumeAfterHold = useRef(false);
const video = () => box.current?.querySelector("video") ?? null;
useEffect(() => {
const v = video();
if (!v) return;
const read = () => {
if (v.videoWidth > 0 && v.videoHeight > 0) setVideoSize([v.videoWidth, v.videoHeight]);
};
read();
v.addEventListener("loadedmetadata", read);
return () => v.removeEventListener("loadedmetadata", read);
}, [playable, media.mp4Url, media.hlsUrl]);
const autoplay = useCallback((v: HTMLVideoElement) => {
v.muted = getSessionMuted();
// Like a reel, an autoplayed video loops while it is on screen.
v.loop = true;
setMuted(v.muted);
v.play().catch((err: unknown) => {
if (!isAutoplayBlocked(err)) return;
// Sound not allowed yet: play muted this time, keeping the session's choice.
if (!v.muted) {
v.muted = true;
setMuted(true);
v.play().catch((again: unknown) => {
if (isAutoplayBlocked(again)) setBlocked(true);
});
} else setBlocked(true);
});
}, []);
useEffect(() => {
const v = video();
if (!v || !playable) return;
if (!active) {
userPausedRef.current = false;
setUserPaused(false);
resumeAfterHold.current = false;
v.pause();
return;
}
if (hold) {
if (!v.paused) {
resumeAfterHold.current = true;
v.pause();
}
return;
}
if (resumeAfterHold.current) {
resumeAfterHold.current = false;
void v.play().catch(() => {});
return;
}
if (auto && !userPausedRef.current) autoplay(v);
}, [active, hold, auto, playable, autoplay]);
if (playable) {
const size = videoSize ?? thumbSize;
const fit = size ? videoFit(size[0], size[1]) : "cover";
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
const v = video();
if (!v) return;
v.muted = false;
void v.play().catch(() => {});
};
// Autoplay: a tap on the video pauses or resumes it (no native controls).
const togglePause = () => {
const v = video();
if (!v) return;
const pause = !v.paused;
userPausedRef.current = pause;
setUserPaused(pause);
if (pause) v.pause();
else void v.play().catch(() => {});
};
const toggleMute = () => {
const v = video();
const next = !muted;
if (v) v.muted = next;
setSessionMuted(next);
setMuted(next);
};
const showPlay = auto ? userPaused : !started;
return (
<div className="slide-media slide-video" data-fit={fit} data-autoplay={auto ? "true" : undefined} ref={box}>
{media.src ? (
// The thumbnail doubles as the blurred backdrop behind a contained video, and as the size fallback.
// eslint-disable-next-line @next/next/no-img-element
<img
className="slide-backdrop"
src={media.src}
alt=""
aria-hidden="true"
onLoad={(e) => setThumbSize([e.currentTarget.naturalWidth, e.currentTarget.naturalHeight])}
/>
) : null}
<div className="slide-art" onClick={auto ? togglePause : undefined}>
<VideoPlayer
mp4Url={media.mp4Url}
hlsUrl={media.hlsUrl}
poster={media.src}
label={`Video from ${author}`}
controls={!auto && started}
muted={auto ? muted : undefined}
/>
</div>
{auto ? (
<button
type="button"
className="slide-mute"
data-testid="slide-mute"
aria-label={muted ? "Unmute video" : "Mute video"}
aria-pressed={!muted}
onClick={toggleMute}
>
<SoundIcon muted={muted} />
</button>
) : null}
{showPlay ? (
<>
<button type="button" className="post-play slide-play" aria-label="Play video" onClick={auto ? togglePause : start}>
<PlayIcon />
</button>
{duration && !auto ? <span className="post-dur slide-dur">{duration}</span> : null}
</>
) : null}
</div>
);
}
if (media.src) {
return (
<div className="slide-media">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="slide-art" src={media.src} alt={media.kind === "video" ? "Video thumbnail" : ""} />
</div>
);
}
return (
<div className="slide-media slide-pending" role="img" aria-label="Video is processing">
<span>Video is processing</span>
</div>
);
}
/** Several media items in one slide: a sideways scroll-snap track with a dot per item. */
function MediaTrack({ items, author, current, hold }: { items: CardMedia[]; author: string; current: boolean; hold: boolean }) {
const track = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
const onScroll = () => {
const el = track.current;
if (!el) return;
const next = Math.min(items.length - 1, Math.max(0, Math.round(el.scrollLeft / Math.max(el.clientWidth, 1))));
if (next === active) return;
setActive(next);
// A video stops when it is swiped away.
el.querySelectorAll<HTMLElement>(":scope > .slide-pane").forEach((pane, i) => {
if (i !== next) pane.querySelectorAll("video").forEach((v) => v.pause());
});
};
return (
<>
<div className="slide-track" data-testid="slide-track" ref={track} onScroll={onScroll}>
{items.map((m, i) => (
<div className="slide-pane" key={i} aria-label={`Media ${i + 1} of ${items.length}`}>
<SlideMedia media={m} author={author} active={current && i === active} hold={hold} />
</div>
))}
</div>
<div className="slide-dots" data-testid="slide-dots" aria-hidden="true">
{items.map((_, i) => (
<span key={i} className={`slide-dot${i === active ? " slide-dot-on" : ""}`} />
))}
</div>
</>
);
}
function PostSlide({
post,
index,
current,
hold,
p,
onExpandedChange,
}: {
post: CardPost;
index: number;
current: boolean;
/** A sheet is open over the feed: a playing video pauses until it closes. */
hold: boolean;
p: FeedViewProps;
onExpandedChange?: (index: number, open: boolean) => void;
}) {
const items = post.media.filter((m) => m.kind === "video" || m.src);
const media = items[0] ?? null;
const own = canDelete(post, p.feedUserId) && !!p.onDelete;
// While a video plays with native controls (play button, not autoplay), the author and text step aside.
const [playing, setPlaying] = useState(false);
// Long text opened with "Read more" or "more". It closes when the slide leaves the screen.
const [textOpen, setTextOpen] = useState(false);
useEffect(() => {
if (!current) setTextOpen(false);
}, [current]);
useEffect(() => {
onExpandedChange?.(index, textOpen);
}, [index, textOpen, onExpandedChange]);
useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]);
const cls =
`slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` +
`${own ? " slide-own" : ""}${textOpen ? " slide-text-open" : ""}`;
const lastTap = useRef<number | null>(null);
const [burst, setBurst] = useState(0);
useEffect(() => {
if (!burst) return;
const t = window.setTimeout(() => setBurst(0), BURST_MS);
return () => window.clearTimeout(t);
}, [burst]);
// Double-tap likes (never unlikes) and plays the burst. A tap that scrolls or swipes never reaches click.
const onTap = (e: MouseEvent<HTMLElement>) => {
if (!isTapTarget(e.target)) { lastTap.current = null; return; }
const now = performance.now();
if (!isDoubleTap(lastTap.current, now)) { lastTap.current = now; return; }
lastTap.current = null;
if (!post.isLiked && post.id && p.onToggleLike && !p.likeBusy?.has(post.key)) p.onToggleLike(post);
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) setBurst((n) => n + 1);
};
return (
<section
className={cls}
data-index={index}
data-testid="feed-slide"
data-liked={post.isLiked ? "true" : "false"}
onClick={onTap}
aria-label={`Post by ${post.authorName}`}
onPlayCapture={(e) => {
if ((e.target as HTMLVideoElement).controls) setPlaying(true);
}}
onPauseCapture={() => setPlaying(false)}
onEndedCapture={() => setPlaying(false)}
>
{media ? (
<>
{items.length > 1 ? (
<MediaTrack items={items} author={post.authorName} current={current} hold={hold} />
) : (
<SlideMedia media={media} author={post.authorName} active={current} hold={hold} />
)}
<div className="slide-scrim-top" aria-hidden="true" />
<div className="slide-scrim" aria-hidden="true" />
</>
) : (
<QuoteText text={post.text} open={textOpen} onOpenChange={setTextOpen} />
)}
{burst ? (
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 20.5s-7.5-4.4-9.4-9.1C1.3 8 3.1 4.5 6.6 4.5c2 0 3.5 1.1 4.3 2.4.5.7 1.7.7 2.2 0 .8-1.3 2.3-2.4 4.3-2.4 3.5 0 5.3 3.5 4 6.9-1.9 4.7-9.4 9.1-9.4 9.1z" fill="currentColor" />
</svg>
) : null}
{own ? (
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
) : null}
<div className="slide-meta">
{p.deleteErrors?.has(post.key) ? <p className="slide-error" role="alert">Could not delete the post.</p> : null}
<div className="slide-who">
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div>
<strong>
{post.authorName}
{own ? <span className="post-you slide-you">You</span> : null}
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, p.now)}</time>
</div>
</div>
{media && post.text ? <CaptionText text={post.text} open={textOpen} onOpenChange={setTextOpen} /> : null}
</div>
</section>
);
}
/** 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() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/** Not a post: the dock disables like and comment here (data-slide-kind="featured"). */
function FeaturedSlide({ index, current, experts }: { index: number; current: boolean; experts: DiscoverExpert[] }) {
return (
<section
className={`slide slide-featured${current ? " is-cur" : ""}`}
data-index={index}
data-slide-kind="featured"
data-testid="feed-slide-featured"
aria-label="Featured experts"
>
<span className="type-eyebrow slide-feat-eyebrow">Featured experts</span>
<h2 className="type-title type-title-strong slide-feat-title">Ask the people everyone is talking about</h2>
<div className="slide-feat-cf">
<FeaturedCarousel experts={experts} size={156} variant="slide" />
</div>
<span className="slide-hint">
<ChevronDown />
Keep scrolling
</span>
</section>
);
}
function LoadSlide({ index, current, sentinelRef }: { index: number; current: boolean; sentinelRef?: Ref<HTMLDivElement> }) {
return (
<section className={`slide slide-load${current ? " is-cur" : ""}`} data-index={index} data-testid="feed-slide-load" aria-label="Loading more posts">
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
<div className="slide-load-body" role="status" aria-label="Loading more posts">
<div className="slide-skel">
<div className="slide-skel-head">
<i className="slide-skel-hex" />
<div className="slide-skel-lines">
<i style={{ width: "40%" }} />
<i style={{ width: "24%" }} />
</div>
</div>
<i style={{ width: "92%" }} />
<i style={{ width: "76%" }} />
<i style={{ width: "54%" }} />
</div>
</div>
</section>
);
}
/**
* Phone feed (spec "Mobile layout" > "Full-screen feed"): one post per screen,
* snapping. Uses the same posts, delete and paging as the desktop list: the
* reel is the list ref (the infinite-scroll root) and the loading slide holds
* the sentinel. Reports the slide on screen through onCurrentChange.
*/
export function MobileFeed(p: FeedViewProps) {
const reel = useRef<HTMLDivElement | null>(null);
const [current, setCurrent] = useState(0);
// The slide whose long text is open: the feed holds still so swipes scroll the text.
const [expandedAt, setExpandedAt] = useState<number | null>(null);
const locked = useRef(false);
locked.current = expandedAt !== null;
const onExpandedChange = useCallback((at: number, open: boolean) => {
setExpandedAt((cur) => (open ? at : cur === at ? null : cur));
}, []);
// The open sheet: the comments of one post, the composer, Discover (more), one of its panels, or none.
const [sheet, setSheet] = useState<{ kind: "comments"; postId: string } | { kind: "composer" } | { kind: "more" } | { kind: "panel"; panel: DiscoverPanel } | null>(null);
const closeSheet = useCallback(() => setSheet(null), []);
// A successful post closes the composer: publishing ends with no error and an emptied draft.
const wasBusy = useRef(false);
const composerBusy = !!p.composer?.busy;
const composerClear = !!p.composer && !p.composer.error && !p.composer.value && !p.composer.file;
useEffect(() => {
if (wasBusy.current && !composerBusy && composerClear) setSheet((s) => (s?.kind === "composer" ? null : s));
wasBusy.current = composerBusy;
}, [composerBusy, composerClear]);
const onCurrentChange = p.onCurrentChange;
const showLoad = p.status === "loading" || (p.status === "ready" && !p.loadMoreFailed && (p.loadingMore || !!p.hasMore));
const experts = p.featuredExperts ?? [];
const postCount = p.status === "ready" ? p.posts.length : 0;
const featuredAt = p.status === "ready" ? featuredSlideIndex(postCount, experts.length) : -1;
const slideCount = postCount + (featuredAt >= 0 ? 1 : 0) + (showLoad ? 1 : 0);
/** Slide index of post `i`: posts after the featured slide shift down by one. */
const slideOf = (i: number) => (featuredAt >= 0 && i >= featuredAt ? i + 1 : i);
const postAt = (slide: number) => (featuredAt >= 0 && slide === featuredAt ? -1 : featuredAt >= 0 && slide > featuredAt ? slide - 1 : slide);
// Track the slide on screen. A fresh observer per change picks up new slides.
useEffect(() => {
const root = reel.current;
if (!root || typeof IntersectionObserver === "undefined") return;
const ratios = new Map<number, number>();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) ratios.set(Number((e.target as HTMLElement).dataset.index), e.intersectionRatio);
const visible = [...ratios].filter(([, r]) => r >= CURRENT_RATIO).map(([i]) => i);
setCurrent((prev) => pickCurrent(prev, visible));
},
{ root, threshold: [0, CURRENT_RATIO, 1] },
);
root.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => io.observe(el));
return () => io.disconnect();
}, [slideCount]);
// One wheel notch or one swipe moves exactly one slide. Native snapping alone
// sends a short or slow gesture back to the slide it started on.
useEffect(() => {
const root = reel.current;
if (!root) return;
const count = () => root.querySelectorAll(":scope > .slide").length;
const at = () => Math.round(root.scrollTop / Math.max(root.clientHeight, 1));
const go = (to: number) => {
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
root.scrollTo({ top: to * root.clientHeight, behavior: calm ? "auto" : "smooth" });
};
let lastWheel = 0;
let lastStep = 0;
let from = 0;
const onWheel = (e: WheelEvent) => {
// Open text scrolls natively; the reel is overflow-hidden meanwhile.
if (locked.current) return;
if (e.ctrlKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
e.preventDefault();
const now = performance.now();
const fresh = now - lastWheel > WHEEL_QUIET_MS && now - lastStep > WHEEL_STEP_MS;
lastWheel = now;
if (!fresh || !e.deltaY) return;
lastStep = now;
go(stepTarget(at(), e.deltaY, count()));
};
let startY: number | null = null;
const onTouchStart = (e: TouchEvent) => {
startY = e.touches.length === 1 && !locked.current ? e.touches[0].clientY : null;
from = at();
};
const onTouchEnd = (e: TouchEvent) => {
if (startY === null || e.touches.length || locked.current) return;
const dy = startY - (e.changedTouches[0]?.clientY ?? startY);
startY = null;
if (Math.abs(dy) >= SWIPE_MIN) go(stepTarget(from, dy, count()));
};
root.addEventListener("wheel", onWheel, { passive: false });
root.addEventListener("touchstart", onTouchStart, { passive: true });
root.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
root.removeEventListener("wheel", onWheel);
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchend", onTouchEnd);
};
}, []);
// Keep the index inside the list when posts are removed.
const index = Math.min(current, Math.max(0, slideCount - 1));
const currentPostIndex = postAt(index);
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 onMedia = !!currentPost && currentPost.media.some((m) => m.kind === "video" || m.src);
useEffect(() => {
onCurrentChange?.({ index, kind, post: currentPost });
}, [index, kind, currentPost, onCurrentChange]);
// A video keeps playing only while its slide is on screen.
useEffect(() => {
reel.current?.querySelectorAll<HTMLElement>(":scope > .slide").forEach((el) => {
if (Number(el.dataset.index) !== index) el.querySelectorAll("video").forEach((v) => v.pause());
});
}, [index]);
return (
<section className="reel-feed" aria-label="Home feed" data-on-media={onMedia ? "true" : "false"}>
{p.newCount > 0 && p.status === "ready" ? (
<div className="reel-pill">
<GrowButton label={p.newCount === 1 ? "1 new post" : `${p.newCount} new posts`} onClick={p.onShowNew} />
</div>
) : null}
<TopBar />
<div className="rail-v" data-testid="feed-rail" aria-hidden="true">
<i style={{ height: `${railPercent(index, slideCount)}%` }} />
</div>
<div
className="reel"
data-testid="feed-reel"
data-current={index}
data-locked={expandedAt !== null ? "true" : undefined}
ref={(el) => {
reel.current = el;
setRef(p.listRef, el);
}}
>
{p.status === "error" ? (
<section className="slide slide-message is-cur" data-index={0} role="alert">
<p>The feed did not load.</p>
<SweepButton label="Try again" onClick={p.onRetry} />
</section>
) : null}
{p.status === "ready" && p.posts.length === 0 && !showLoad ? (
<section className="slide slide-message is-cur" data-index={0}>
<p>No posts yet.</p>
</section>
) : null}
{p.status === "ready"
? p.posts.flatMap((post, i) => {
const node = <PostSlide key={post.key} post={post} index={slideOf(i)} current={slideOf(i) === index} hold={sheet !== null} p={p} onExpandedChange={onExpandedChange} />;
return i === featuredAt - 1
? [node, <FeaturedSlide key="featured" index={featuredAt} current={index === featuredAt} experts={experts} />]
: [node];
})
: null}
{p.status === "ready" && p.loadMoreFailed ? (
<section className="slide slide-message" data-index={slideCount} role="alert">
<p>The feed did not load.</p>
<SweepButton label="Try again" onClick={p.onRetry} />
</section>
) : null}
{showLoad ? (
<LoadSlide
index={slideCount - 1}
current={index === slideCount - 1}
sentinelRef={p.status === "ready" ? p.sentinelRef : undefined}
/>
) : null}
</div>
{p.status !== "error" ? (
<MobileDock
slide={{ index, kind, post: currentPost }}
onToggleLike={p.onToggleLike}
likeBusy={currentPost ? p.likeBusy?.has(currentPost.key) : false}
onOpenComments={(postId) => {
setSheet({ kind: "comments", postId });
p.onOpenComments?.(postId);
}}
onOpenComposer={() => {
setSheet({ kind: "composer" });
p.onOpenComposer?.();
}}
onOpenMore={() => {
setSheet({ kind: "more" });
p.onOpenMore?.();
}}
/>
) : null}
{sheet?.kind === "comments" ? (
<BottomSheet title="Comments" onClose={closeSheet} className="sheet-comments">
<CommentsSection postId={sheet.postId} onSignedOut={p.onSignedOut} />
</BottomSheet>
) : null}
{sheet?.kind === "composer" && p.composer ? (
<BottomSheet title="New post" onClose={closeSheet} className="sheet-composer">
<Composer {...p.composer} />
</BottomSheet>
) : null}
{sheet?.kind === "more" ? (
<DiscoverSheet experts={experts} onOpen={(panel) => setSheet({ kind: "panel", panel })} onClose={closeSheet} />
) : null}
{sheet?.kind === "panel" ? (
<DiscoverPanelSheet panel={sheet.panel} experts={experts} trending={p.trendingMinnects ?? []} search={p.searchPanel} onClose={closeSheet} />
) : null}
</section>
);
}
+163 -61
View File
@@ -1,13 +1,17 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { HexAvatar } from "@/components/HexAvatar";
import { CommentsSection } from "@/components/CommentsSection";
import { VideoPlayer } from "@/components/VideoPlayer";
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
import { IconButton, SweepButton } from "@/components/Buttons";
import { commentsLabel, formatDuration, relativeTime, type CardMedia, type CardPost } from "@/lib/feed-model";
export interface PostCardProps {
post: CardPost;
now?: number;
/** Position in the list; the first cards rise in one after another. */
index?: number;
onToggleLike?: (post: CardPost) => void;
likeBusy?: boolean;
likeError?: boolean;
@@ -19,9 +23,19 @@ export interface PostCardProps {
deleteError?: boolean;
}
const icon = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
function Heart({ filled }: { filled: boolean }) {
return (
<svg className="heart-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"
<svg className="post-heart" viewBox="0 0 24 24" aria-hidden="true"
fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2"
strokeLinejoin="round">
<path d="M12 21s-7.5-4.6-9.6-9.3C.9 8.4 2.7 4.5 6.4 4.5c2.1 0 3.6 1.2 4.4 2.5.4.6 1.4.6 1.8 0 .8-1.3 2.3-2.5 4.4-2.5 3.7 0 5.5 3.9 4 7.2C19.5 16.4 12 21 12 21z" />
@@ -29,68 +43,155 @@ function Heart({ filled }: { filled: boolean }) {
);
}
export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
function CommentIcon() {
return (
<svg {...icon} className="post-act-icon">
<path d="M21 11.5a8.5 8.5 0 0 1-12.6 7.4L3 20l1.2-4.6A8.5 8.5 0 1 1 21 11.5z" />
</svg>
);
}
function TrashIcon() {
return (
<svg {...icon}>
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />
</svg>
);
}
export function PlayIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 4.5v15a1 1 0 0 0 1.5.86l12.5-7.5a1 1 0 0 0 0-1.72L8.5 3.64A1 1 0 0 0 7 4.5z" />
</svg>
);
}
/** A playable video: the real player, with a frosted play button and the length over it until it starts. */
function PostVideo({ media, author }: { media: CardMedia; author: string }) {
const box = useRef<HTMLDivElement>(null);
const [started, setStarted] = useState(false);
const duration = formatDuration(media.durationSeconds);
const start = () => {
setStarted(true);
void box.current?.querySelector("video")?.play().catch(() => {});
};
return (
<div className="post-media post-video" ref={box}>
<VideoPlayer mp4Url={media.mp4Url} hlsUrl={media.hlsUrl} poster={media.src} label={`Video from ${author}`} />
{started ? null : (
<>
<button type="button" className="post-play" aria-label="Play video" onClick={start}>
<PlayIcon />
</button>
{duration ? <span className="post-dur">{duration}</span> : null}
</>
)}
</div>
);
}
export interface PostMenuProps {
post: CardPost;
onDelete?: (post: CardPost) => void;
deleteBusy?: boolean;
/** Extra class on the wrapper, e.g. the mobile slide's frosted position. */
className?: string;
/** Class for the "..." button; defaults to the card's button. */
buttonClassName?: string;
}
/** The own-post "..." menu with the delete confirm. Shared by the card and the mobile slide. */
export function PostMenu({ post, onDelete, deleteBusy, className, buttonClassName = "post-menu-btn" }: PostMenuProps) {
const [menu, setMenu] = useState(false);
const [confirming, setConfirming] = useState(false);
const menuBox = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menu) return;
const close = () => { setMenu(false); setConfirming(false); };
const onDown = (e: MouseEvent) => { if (!menuBox.current?.contains(e.target as Node)) close(); };
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [menu]);
return (
<article className="post" data-testid="post-card">
<div className={className ? `post-menu ${className}` : "post-menu"} ref={menuBox}>
<button
type="button"
className={menu ? `${buttonClassName} ${buttonClassName}-open` : buttonClassName}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
</svg>
</button>
{menu ? (
<div className="post-pop" role="menu">
{confirming ? (
<div className="post-ask">
<p className="post-ask-title">Delete this post?</p>
<p className="post-ask-sub">This cannot be undone.</p>
<div className="post-ask-row">
<SweepButton
label="Cancel"
role="menuitem"
onClick={() => { setConfirming(false); setMenu(false); }}
/>
<IconButton
label="Delete"
role="menuitem"
icon={<TrashIcon />}
size="sm"
disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete?.(post); }}
/>
</div>
</div>
) : (
<button type="button" role="menuitem" className="post-mi post-mi-danger" onClick={() => setConfirming(true)}>
<TrashIcon />
Delete post
</button>
)}
</div>
) : null}
</div>
);
}
export function PostCard({ post, now, index = 0, onToggleLike, likeBusy, likeError, onSignedOut, canDelete, onDelete, deleteBusy, deleteError }: PostCardProps) {
const [open, setOpen] = useState(false);
const [beat, setBeat] = useState(false);
const own = !!(canDelete && onDelete);
return (
<article className="post" data-testid="post-card" style={{ "--i": index < 8 ? index : 0 } as CSSProperties}>
<header className="post-head">
{post.authorPictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={post.authorPictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">
{initials(post.authorName)}
</span>
)}
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div className="post-who">
<strong>{post.authorName}</strong>
<strong>
{post.authorName}
{own ? <span className="post-you">You</span> : null}
</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
</div>
{canDelete && onDelete ? (
<div className="post-menu">
<button
type="button"
className="menu-btn"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menu}
disabled={deleteBusy}
onClick={() => { setMenu((m) => !m); setConfirming(false); }}
>
...
</button>
{menu ? (
<div className="menu" role="menu">
{confirming ? (
<>
<span className="menu-ask">Delete this post?</span>
<button type="button" role="menuitem" className="menu-danger" disabled={deleteBusy}
onClick={() => { setMenu(false); setConfirming(false); onDelete(post); }}>
Confirm delete
</button>
<button type="button" role="menuitem" onClick={() => { setConfirming(false); setMenu(false); }}>
Cancel
</button>
</>
) : (
<button type="button" role="menuitem" className="menu-danger" onClick={() => setConfirming(true)}>
Delete post
</button>
)}
</div>
) : null}
</div>
) : null}
{own ? <PostMenu post={post} onDelete={onDelete} deleteBusy={deleteBusy} /> : null}
</header>
{deleteError ? <p className="like-error" role="alert">Could not delete the post.</p> : null}
{deleteError ? <p className="post-error" role="alert">Could not delete the post.</p> : null}
{post.text ? <p className="post-text">{post.text}</p> : null}
{post.media.map((m, i) =>
m.kind === "video" && (m.mp4Url || m.hlsUrl) ? (
<div className="post-media post-video" key={i}>
<VideoPlayer mp4Url={m.mp4Url} hlsUrl={m.hlsUrl} poster={m.src} label={`Video from ${post.authorName}`} />
</div>
<PostVideo key={i} media={m} author={post.authorName} />
) : m.src ? (
<div className={m.kind === "video" ? "post-media post-video" : "post-media"} key={i}>
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -102,28 +203,29 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
</div>
) : null,
)}
<footer className="post-stats">
<footer className="post-acts">
<button
type="button"
className={post.isLiked ? "like-btn liked" : "like-btn"}
className={post.isLiked ? (beat ? "post-act post-like post-like-on post-like-beat" : "post-act post-like post-like-on") : "post-act post-like"}
aria-pressed={post.isLiked}
aria-label={post.isLiked ? `Unlike, ${post.likesCount} likes` : `Like, ${post.likesCount} likes`}
disabled={likeBusy || !post.id || !onToggleLike}
onClick={() => onToggleLike?.(post)}
onClick={() => { setBeat(!post.isLiked); onToggleLike?.(post); }}
>
<Heart filled={post.isLiked} /> <span className="like-count">{post.likesCount}</span>
<Heart filled={post.isLiked} /> <span className="post-count">{post.likesCount}</span>
</button>
<button
type="button"
className="comments-toggle"
className={open ? "post-act post-comments-toggle post-act-open" : "post-act post-comments-toggle"}
aria-expanded={open}
disabled={!post.id}
onClick={() => setOpen((o) => !o)}
>
{post.commentsCount} comments
<CommentIcon />
{commentsLabel(post.commentsCount)}
</button>
{likeError ? (
<span className="like-error" role="alert">Could not update like.</span>
<span className="post-error" role="alert">Could not update like.</span>
) : null}
</footer>
{open && post.id ? <CommentsSection postId={post.id} onSignedOut={onSignedOut} /> : null}
+67 -33
View File
@@ -3,6 +3,7 @@
import { useState, type FormEvent } from "react";
import { signIn, type Person } from "@/lib/auth-client";
import { Logo } from "./Logo";
import { SparkleButton } from "./Buttons";
export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void }) {
const [email, setEmail] = useState("");
@@ -27,39 +28,72 @@ export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void })
}
return (
<main className="center">
<form className="card" onSubmit={submit} noValidate>
<Logo />
<h1>Sign in to Minnect</h1>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="username"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit" disabled={busy || !email || !password}>
{busy ? "Signing in..." : "Sign in"}
</button>
</form>
<main className="signin">
<div className="signin-glow" aria-hidden="true" />
<div className="signin-col">
<div className="signin-brand">
<Logo size={30} />
<span className="type-wordmark">MINNECT</span>
</div>
<div className="signin-head">
<span className="type-eyebrow">Members</span>
<h1 className="signin-title">
Human answers for <em>human decisions</em>
</h1>
<div className="signin-rule" aria-hidden="true" />
</div>
<form className="signin-form" onSubmit={submit} noValidate>
{error && (
<p role="alert" className="signin-error">
{error}
</p>
)}
<label className="signin-label" htmlFor="email">
Email
</label>
<div className="signin-field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="m4 7 8 6 8-6" />
</svg>
<input
id="email"
name="email"
type="email"
autoComplete="username"
placeholder="you@company.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<label className="signin-label signin-label-next" htmlFor="password">
Password
</label>
<div className="signin-field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="4.5" y="10.5" width="15" height="10" rx="2.5" />
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
</svg>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Your password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<SparkleButton
type="submit"
className="signin-submit"
label={busy ? "Signing in..." : "Sign in"}
disabled={busy || !email || !password}
/>
</form>
</div>
</main>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
/** Quote sizes for a text-only post, largest first. The smallest one clamps and offers "Read more". */
export const QUOTE_SIZES = [31, 26, 22, 19] as const;
/** Space kept under a clamped quote for the "Read more" button (44px tall plus its gap). */
export const QUOTE_MORE_RESERVE = 52;
export interface QuoteFit {
size: number;
/** Lines shown before "Read more"; 0 when the whole text fits. */
lines: number;
}
/**
* The first quote size whose rendered text fits, or the smallest size with as
* many whole lines as fit above the "Read more" button. `fits` measures the
* rendered text at a size; `linesAt` counts the lines that fit at the smallest one.
*/
export function pickQuoteFit(fits: (size: number) => boolean, linesAt: (size: number) => number): QuoteFit {
for (const size of QUOTE_SIZES) if (fits(size)) return { size, lines: 0 };
const size = QUOTE_SIZES[QUOTE_SIZES.length - 1];
return { size, lines: Math.max(1, Math.floor(linesAt(size))) };
}
/** Re-runs `measure` when the box resizes, web fonts finish loading, or the theme changes. */
function useRemeasure(box: React.RefObject<HTMLElement | null>, measure: () => void, deps: unknown[]) {
useLayoutEffect(() => {
measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
const el = box.current;
if (!el) return;
let frame = 0;
const later = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(later) : null;
ro?.observe(el);
const fonts = typeof document !== "undefined" ? document.fonts : undefined;
void fonts?.ready.then(later);
fonts?.addEventListener?.("loadingdone", later);
const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(later) : null;
mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] });
return () => {
cancelAnimationFrame(frame);
ro?.disconnect();
mo?.disconnect();
fonts?.removeEventListener?.("loadingdone", later);
};
}, [box, measure]);
}
/**
* Text-only post: the serif quote steps down 31 → 26 → 22 → 19px until it fits
* between the top bar and the author block. When it still does not fit, it
* clamps and ends with "Read more", which opens the full text over the slide.
*/
export function QuoteText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const box = useRef<HTMLDivElement>(null);
const inner = useRef<HTMLDivElement>(null);
const quote = useRef<HTMLParagraphElement>(null);
const moreBtn = useRef<HTMLButtonElement>(null);
const closeBtn = useRef<HTMLButtonElement>(null);
const [fit, setFit] = useState<QuoteFit>({ size: QUOTE_SIZES[0], lines: 0 });
const measure = useCallback(() => {
const b = box.current;
const q = quote.current;
const w = inner.current;
if (!b || !q || !w || !b.clientHeight) return;
const apply = (f: QuoteFit) => {
q.style.fontSize = `${f.size}px`;
q.style.webkitLineClamp = f.lines ? String(f.lines) : "";
q.classList.toggle("is-clamped", f.lines > 0);
b.classList.toggle("is-clamped", f.lines > 0);
};
const result = pickQuoteFit(
(size) => {
apply({ size, lines: 0 });
return w.offsetHeight <= b.clientHeight + 0.5;
},
() => {
// Height left for the text: the box minus the quote mark, its gap and the button.
const other = w.offsetHeight - q.offsetHeight;
const lineHeight = parseFloat(getComputedStyle(q).lineHeight) || 19 * 1.2;
return (b.clientHeight - other - QUOTE_MORE_RESERVE) / lineHeight;
},
);
apply(result);
setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result));
}, []);
useRemeasure(box, measure, [text]);
// Focus follows the reader: into it on open, back to "Read more" on close.
const wasOpen = useRef(false);
useEffect(() => {
if (open) closeBtn.current?.focus({ preventScroll: true });
else if (wasOpen.current) moreBtn.current?.focus({ preventScroll: true });
wasOpen.current = open;
}, [open]);
return (
<>
<div className={`slide-quote-w${fit.lines ? " is-clamped" : ""}`} ref={box} data-testid="slide-quote-box">
<div className="slide-quote-in" ref={inner}>
<span className="slide-quote-mark" aria-hidden="true">{"“"}</span>
<p
className={`slide-quote${fit.lines ? " is-clamped" : ""}`}
ref={quote}
data-fit-size={fit.size}
style={{ fontSize: `${fit.size}px`, WebkitLineClamp: fit.lines || undefined }}
>
{text}
</p>
</div>
{fit.lines ? (
<button
type="button"
className="slide-more slide-more-quote"
ref={moreBtn}
aria-expanded={open}
onClick={() => onOpenChange(true)}
>
Read more
</button>
) : null}
</div>
{open ? (
<div
className="slide-reader"
data-testid="slide-reader"
data-expanded-text="true"
role="region"
aria-label="Full post"
onKeyDown={(e) => {
if (e.key === "Escape") onOpenChange(false);
}}
>
<div className="slide-reader-scroll" data-testid="slide-reader-scroll" tabIndex={0}>
<p className="slide-reader-text">{text}</p>
</div>
<button type="button" className="slide-more slide-reader-close" ref={closeBtn} aria-expanded="true" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null}
</>
);
}
/**
* Caption over a photo or video: three lines, then "more" when it is longer.
* Open, the whole caption shows over the media on a darker scrim, scrolling
* inside the slide when it is very long, with "less" to collapse it.
*/
export function CaptionText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
const caption = useRef<HTMLParagraphElement>(null);
const [long, setLong] = useState(false);
const measure = useCallback(() => {
const c = caption.current;
// Measured only while clamped: an open caption is long by definition.
if (!c || c.classList.contains("is-open") || !c.clientHeight) return;
setLong(c.scrollHeight > c.clientHeight + 1);
}, []);
useRemeasure(caption, measure, [text]);
useEffect(() => {
if (!open && caption.current) caption.current.scrollTop = 0;
}, [open]);
return (
<>
<p
className={`slide-text${open ? " is-open" : ""}`}
ref={caption}
data-expanded-text={open ? "true" : undefined}
data-testid="slide-caption"
tabIndex={open ? 0 : undefined}
>
{text}
</p>
{long || open ? (
<button type="button" className="slide-more slide-more-caption" aria-expanded={open} onClick={() => onOpenChange(!open)}>
{open ? "less" : "more"}
</button>
) : null}
</>
);
}
+15 -19
View File
@@ -1,31 +1,27 @@
import type { DiscoverMinnect } from "@/lib/discover";
import { MinnectMedia } from "./MinnectMedia";
import { initials } from "@/lib/feed-model";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
return (
<section className="rail" aria-labelledby="rail-trending">
<h2 id="rail-trending">Trending minnects</h2>
<ul className="rail-list">
<section className="tile panel rail rail-trending" aria-labelledby="rail-trending">
<div className="panel-head">
<h2 id="rail-trending" className="type-title type-title-strong">Trending minnects</h2>
</div>
<ul className="rail-list trend-list panel-scroll fade-edges">
{minnects.map((m) => {
const who = m.expert.displayName || "Expert";
return (
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
<div className="minnect-who">
{m.expert.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar avatar-sm" src={m.expert.pictureUrl} alt="" width={28} height={28} />
) : (
<span className="avatar avatar-sm avatar-initials" aria-hidden="true">{initials(who)}</span>
)}
<strong className="rail-name">{who}</strong>
</div>
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
{m.question ? <p className="minnect-question">{m.question}</p> : null}
{m.views !== null ? (
<span className="rail-meta">{m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}</span>
) : null}
<li key={String(m.id)} className="minnect-card" data-testid="minnect-card">
<MinnectMedia
media={m.media}
thumbnailUrl={m.thumbnailUrl}
question={m.question}
who={who}
pictureUrl={m.expert.pictureUrl}
views={m.views}
/>
{m.question ? <p className="trend-q">{m.question}</p> : null}
</li>
);
})}
+8 -3
View File
@@ -8,6 +8,10 @@ export interface VideoPlayerProps {
hlsUrl?: string | null;
poster: string | null;
label: string;
/** Native controls; off until the caller's own play button starts the video. Default on. */
controls?: boolean;
/** Start muted (the mobile feed's autoplay). Desktop leaves it unset. */
muted?: boolean;
}
const UNPLAYABLE = "Video can't be played here.";
@@ -16,9 +20,9 @@ const UNPLAYABLE = "Video can't be played here.";
* Real <video controls>. Order: direct mp4Url (plain progressive source), else
* hls.js (lazy-loaded, only for the HLS path; native HLS only where hls.js is
* unsupported), else thumbnail only. Never
* autoplays. Any failure falls back to the thumbnail with a short note.
* autoplays by itself (the mobile feed starts it). Any failure falls back to the thumbnail with a short note.
*/
export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps) {
export function VideoPlayer({ mp4Url, hlsUrl, poster, label, controls = true, muted }: VideoPlayerProps) {
const ref = useRef<HTMLVideoElement>(null);
const [failed, setFailed] = useState(false);
@@ -77,7 +81,8 @@ export function VideoPlayer({ mp4Url, hlsUrl, poster, label }: VideoPlayerProps)
<video
ref={ref}
src={mp4Url ?? undefined}
controls
controls={controls}
muted={muted}
playsInline
preload="none"
poster={poster ?? undefined}
+69
View File
@@ -0,0 +1,69 @@
// Plain-text helpers for the composer (spec "Composer", formatting decided by
// Cesar on 2026-10-19: posts are plain text, no bold or italic).
export const BULLET = "• ";
export interface TextEdit {
text: string;
selectionStart: number;
selectionEnd: number;
}
/**
* Insert the plain character "• " at the start of the line holding the caret,
* or of every line the selection touches. A line that already starts with
* "• " is left as it is. Nothing else in the text changes. The returned
* selection is shifted so it still covers the same characters.
*/
export function insertBullets(text: string, selectionStart: number, selectionEnd: number): TextEdit {
const start = Math.max(0, Math.min(selectionStart, selectionEnd, text.length));
let end = Math.max(0, Math.min(Math.max(selectionStart, selectionEnd), text.length));
// A selection that ends right after a line break does not include the next line.
if (end > start && text[end - 1] === "\n") end -= 1;
// Offsets of every line start from the caret's line to the selection's last line.
const firstLine = text.lastIndexOf("\n", start - 1) + 1;
const lineStarts: number[] = [];
for (let i = firstLine; i <= end; ) {
lineStarts.push(i);
const next = text.indexOf("\n", i);
if (next === -1 || next >= end) break;
i = next + 1;
}
let out = "";
let last = 0;
let newStart = selectionStart;
let newEnd = selectionEnd;
for (const at of lineStarts) {
out += text.slice(last, at);
last = at;
if (text.startsWith(BULLET, at)) continue;
out += BULLET;
if (at <= selectionStart) newStart += BULLET.length;
if (at <= selectionEnd) newEnd += BULLET.length;
}
out += text.slice(last);
return { text: out, selectionStart: newStart, selectionEnd: newEnd };
}
/** "2.4 MB", "820 KB" or "512 B". */
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
return `${bytes} B`;
}
/** The attachment row's meta line: type and size, for example "Image, 2.4 MB". */
export function attachmentMeta(file: { type: string; size: number }): string {
const kind = file.type.startsWith("video/") ? "Video" : "Image";
return `${kind}, ${formatBytes(file.size)}`;
}
/** The 32 frequent emoji in the composer's emoji popover (mockup order). */
export const COMPOSER_EMOJI = [
"😀", "😂", "😍", "🥳", "😎", "🤔", "🙌", "👏",
"🔥", "💡", "🚀", "🎯", "✅", "❤️", "💯", "✨",
"🙏", "👀", "📈", "🧠", "💬", "📣", "🎉", "☕",
"🌍", "📚", "🤝", "💪", "🌱", "⚡", "🏆", "👋",
] as const;
+68
View File
@@ -0,0 +1,68 @@
/** Rotation timing from the spec ("Featured experts carousel"). */
export const FEATURED_INTERVAL_MS = 2800;
export type FeaturedSlot = "c0" | "l1" | "r1" | "l2" | "r2" | "l3" | "r3";
/**
* Where item `i` sits when `current` is in the center: by its signed offset
* from the current index, wrapped so the shorter way round wins. With two
* items the other one is a single right neighbor, never an empty pair.
*/
export function featuredSlot(i: number, current: number, count: number): FeaturedSlot {
let off = (((i - current) % count) + count) % count;
if (off > count / 2) off -= count;
if (off === 0) return "c0";
if (off === 1) return "r1";
if (off === -1) return "l1";
if (off === 2) return "r2";
if (off === -2) return "l2";
return off > 0 ? "r3" : "l3";
}
export interface FeaturedAutoplay {
readonly index: number;
readonly paused: boolean;
pause(): void;
resume(): void;
stop(): void;
}
/**
* Advances the index every `intervalMs` while not paused. A single item never
* rotates. The caller stops it on unmount.
*/
export function startFeaturedAutoplay(opts: {
count: number;
onChange: (index: number) => void;
intervalMs?: number;
start?: number;
}): FeaturedAutoplay {
const { count, onChange, intervalMs = FEATURED_INTERVAL_MS } = opts;
let index = opts.start ?? 0;
let paused = false;
const timer =
count > 1
? setInterval(() => {
if (paused) return;
index = (index + 1) % count;
onChange(index);
}, intervalMs)
: null;
return {
get index() { return index; },
get paused() { return paused; },
pause() { paused = true; },
resume() { paused = false; },
stop() { if (timer) clearInterval(timer); },
};
}
/**
* WAI-ARIA carousel pattern: a rotating carousel must not announce every
* slide, so the region is `off` while autoplay runs and `polite` when the
* person has paused it (hover, focus) or it never rotates (reduced motion,
* a single expert).
*/
export function featuredLiveMode(opts: { rotating: boolean; paused: boolean }): "off" | "polite" {
return opts.rotating && !opts.paused ? "off" : "polite";
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { createContext, useContext } from "react";
/** Widest `.app` width that gets the phone layout (spec "Mobile layout", ADR 0001 section 3). */
export const COMPACT_MAX_WIDTH = 760;
/** True when `.app` is 760px wide or less. Matches `@container app (max-width: 760px)`. */
export function isCompactWidth(width: number): boolean {
return width > 0 && width <= COMPACT_MAX_WIDTH;
}
/**
* Whether the signed-in shell is in the phone layout. AppShell measures `.app`
* and provides it; outside the shell (tests, other pages) it is false, so the
* desktop markup renders.
*/
export const CompactLayoutContext = createContext(false);
export function useCompactLayout(): boolean {
return useContext(CompactLayoutContext);
}
+184
View File
@@ -0,0 +1,184 @@
"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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { ExpertSearchView } from "@/components/ExpertSearch";
import { FeedView } from "@/components/FeedView";
import { toComments } from "@/lib/comment-model";
import { type CardPost } from "@/lib/feed-model";
import {
FIXTURE_CATEGORIES,
FIXTURE_COMMENTS,
FIXTURE_DISCOVER,
FIXTURE_FEED_ITEMS,
fixtureCards,
FIXTURE_NOW,
FIXTURE_PERSON,
FIXTURE_SEARCH_RESULTS,
} from "./fixtures";
export function FeedPreview() {
const [posts, setPosts] = useState<CardPost[]>(() => fixtureCards(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
const [file, setFile] = useState<File | null>(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(null);
// Fixture switches for the new-posts pill and the loading-more skeleton.
const [newCount, setNewCount] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const list = useRef<HTMLDivElement | null>(null);
// The list element swaps between the panel and the phone reel; paging re-attaches to it.
const [listEl, setListEl] = useState<HTMLDivElement | null>(null);
const listRef = useCallback((el: HTMLDivElement | null) => {
list.current = el;
setListEl(el);
}, []);
// Paging: two more fixture pages load when the sentinel scrolls into view,
// the same way Feed pages (the observer root is the list or the phone reel).
const sentinel = useRef<HTMLDivElement | null>(null);
const [page, setPage] = useState(0);
const [paging, setPaging] = useState(false);
const hasMore = page < 2;
useEffect(() => {
const el = sentinel.current;
if (!el || !hasMore || paging) return;
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setPaging(true);
window.setTimeout(() => {
const next = page + 1;
const items = FIXTURE_FEED_ITEMS.map((raw) => {
const it = raw as { post: { id: string } };
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
});
setPosts((cur) => [...cur, ...fixtureCards(items)]);
setPage(next);
setPaging(false);
}, 900);
}, { root: listEl, rootMargin: "400px" });
io.observe(el);
return () => io.disconnect();
}, [hasMore, paging, page, posts.length, listEl]);
// The comments calls answer from the fixtures; the preview never reaches the network.
useEffect(() => {
const real = window.fetch;
const lists: Record<string, unknown[]> = { ...FIXTURE_COMMENTS };
window.fetch = async (input, init) => {
const m = /\/api\/posts\/([^/]+)\/comments/.exec(String(input));
if (!m) return real(input, init);
const id = decodeURIComponent(m[1]);
if (init?.method === "POST") {
const { content } = JSON.parse(String(init.body)) as { content: string };
const made = { id: `local-${Date.now()}`, content, createdAt: new Date(FIXTURE_NOW).toISOString(), author: { displayName: FIXTURE_PERSON.name } };
lists[id] = [...(lists[id] ?? []), made];
return Response.json({ comment: toComments([made])[0] });
}
return Response.json({ comments: toComments(lists[id] ?? []) });
};
return () => { window.fetch = real; };
}, []);
// With no query or category, show every fixture result so the list scrolls.
const results = useMemo(() => {
const q = query.trim().toLowerCase();
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 (
<>
<div style={{ position: "fixed", left: "50%", bottom: 8, zIndex: 50, display: "flex", gap: 12, padding: "6px 10px", font: "12px sans-serif", background: "var(--tile-2)", color: "var(--ink)", border: "1px solid var(--line-2)", borderRadius: 8 }}>
<label style={{ margin: 0, fontSize: 12 }}>
<input type="checkbox" data-testid="fx-new" checked={newCount > 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
</label>
<label style={{ margin: 0, fontSize: 12 }}>
<input type="checkbox" data-testid="fx-more" checked={loadingMore} onChange={(e) => setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
</label>
</div>
<AppShell
person={FIXTURE_PERSON}
search={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
feed={
<FeedView
posts={posts}
status="ready"
loadingMore={loadingMore || paging}
hasMore={hasMore}
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
trendingMinnects={FIXTURE_DISCOVER.trendingMinnects}
searchPanel={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
sentinelRef={sentinel}
listRef={listRef}
loadMoreFailed={false}
newCount={newCount}
onRetry={() => {}}
onShowNew={() => {
setNewCount(0);
list.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
refreshing={refreshing}
onRefresh={() => {
list.current?.scrollTo({ top: 0, behavior: "smooth" });
setRefreshing(true);
window.setTimeout(() => {
setPosts(fixtureCards(FIXTURE_FEED_ITEMS));
setRefreshing(false);
}, 600);
}}
onToggleLike={toggleLike}
feedUserId={FIXTURE_PERSON.feedUserId}
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
composer={{
value: draft,
busy: false,
error: false,
file,
onChange: setDraft,
onFile: setFile,
// No network here: "publishing" just clears the composer.
onSubmit: () => {
setDraft("");
setFile(null);
},
person: FIXTURE_PERSON,
}}
now={FIXTURE_NOW}
/>
}
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
/>
</>
);
}
+316
View File
@@ -0,0 +1,316 @@
// 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 { toCardPosts, type CardPost } from "@/lib/feed-model";
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 gradient placeholder image (16:9 unless a size is given), drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = "", width = 1280, height = 720): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
`</linearGradient></defs><rect width="${width}" height="${height}" fill="url(#g)"/>` +
(label
? `<text x="64" y="${height - 60}" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
: "") +
`</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/** Not a real file: preload is off, so the preview video shows only its poster, play button and length. */
const FIXTURE_VIDEO_URL = "https://cdn.feed.sharedservices.vt.com/dev-fixture.mp4";
/** Fixture videos with real (tiny) files in public/dev: the feed only accepts CDN-host mp4 URLs, so these are swapped to local paths after parsing. */
const FIXTURE_LANDSCAPE_URL = "https://cdn.feed.sharedservices.vt.com/dev-landscape.mp4";
const FIXTURE_PORTRAIT_URL = "https://cdn.feed.sharedservices.vt.com/dev-portrait.mp4";
const LOCAL_FIXTURE_VIDEOS: Record<string, string> = {
[FIXTURE_LANDSCAPE_URL]: "/dev/landscape.mp4",
[FIXTURE_PORTRAIT_URL]: "/dev/portrait.mp4",
};
/** toCardPosts for the preview, with the fixture video URLs pointed at the local files. */
export function fixtureCards(items: unknown[]): CardPost[] {
return toCardPosts(items).map((p) => ({
...p,
media: p.media.map((m) => (m.mp4Url && LOCAL_FIXTURE_VIDEOS[m.mp4Url] ? { ...m, mp4Url: LOCAL_FIXTURE_VIDEOS[m.mp4Url] } : m)),
}));
}
/** Comments for the preview, in the shape /api/posts/[id]/comments returns. p2 holds a removed one; p4 has none. */
export const FIXTURE_COMMENTS: Record<string, unknown[]> = {
p1: [{ id: "c1", content: "Congratulations, the flow reads well.", createdAt: "2026-10-19T11:50:00Z", author: { displayName: "Priya Natarajan" } }],
p2: [
{ id: "c2", content: "Number two changed how I price renewals.", createdAt: "2026-10-19T10:40:00Z", author: { displayName: "Marcus Bell" } },
{ id: "c3", content: "removed text", deletedAt: "2026-10-19T11:00:00Z", createdAt: "2026-10-19T10:55:00Z", author: { displayName: "Elena Kovac" } },
{ id: "c4", content: "Saving this for the next pricing review.", createdAt: "2026-10-19T11:30:00Z", author: { displayName: "Samuel Okafor" } },
],
};
export const FIXTURE_FEED_USER_ID = "dev-user-1";
export const FIXTURE_PERSON: Person = {
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: [],
},
},
{
// Medium text-only post: too long for 31px, fits at 26px or 22px with no "Read more".
type: "post",
publishedAt: "2026-10-19T11:30:00Z",
likesCount: 12,
commentsCount: 0,
post: {
id: "p10",
content: "The best advice I got as a new manager: your job is not to have the answers. It is to make sure the right question gets asked in the right room, by the person closest to the customer, early enough that the answer still changes what we build. Everything else, the rituals and the dashboards and the planning decks, exists to protect that one habit. When a meeting ends without a better question than it started with, it was a status update, and those can be an email.",
author: author("a10", "Grace Whitfield"),
mediaItems: [],
},
},
{
// Very long text-only post: does not fit at 19px, so it clamps and ends with "Read more".
type: "post",
publishedAt: "2026-10-19T11:10:00Z",
likesCount: 87,
commentsCount: 0,
post: {
id: "p11",
content: "I spent ten years building products before I learned the most useful habit of all: write down what you expect to happen before you ship, then check it a month later.\n\nMost teams skip the first half. They launch, watch a dashboard for a week and call whatever they see a win. Without the written guess there is nothing to be wrong about, so nobody learns.\n\nHere is the routine we use now. Every change gets one sentence: who it is for, what they will do differently, and the number that will move if we are right. The sentence goes in the ticket, not in a slide.\n\nThirty days later the person who wrote it reads it out loud in the weekly review and says what actually happened. No blame, no speeches. Just the guess, the result and one line on why they differ.\n\nAfter a quarter you get something no analytics tool gives you: a record of how good your team is at predicting its own customers. Ours was humbling. We were right about a third of the time.\n\nThat number changed how we plan. We now run smaller bets, we talk to five customers before any big build, and we kill features faster when the guess was wrong.\n\nIf you try one thing this month, try the sentence. It takes two minutes and it will teach you more than the next roadmap offsite.",
author: author("a11", "Daniel Moreau"),
mediaItems: [],
},
},
{
// Photo post with a caption longer than three lines: "more" / "less".
type: "post",
publishedAt: "2026-10-19T10:50:00Z",
likesCount: 64,
commentsCount: 0,
post: {
id: "p12",
content: "Notes from the panel on hiring a first sales lead, since a few people asked. Hire for the next eighteen months, not the next five years: the person who closes your first fifty customers is rarely the one who builds a team of twenty. Ask every candidate to walk you through a deal they lost and what they changed afterwards. Give them real numbers in the interview, including the bad ones, and watch how they react. Pay them on outcomes you can measure from day one, and write down what success looks like at thirty, sixty and ninety days. Last point: the founder keeps selling until the new lead has closed at least three deals alone. Handing over too early was the most common regret in the room.",
author: author("a12", "Aiko Tanaka"),
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(48, "Sales panel", 1080, 1350), order: 0 }],
},
},
{
// Video post with a long caption.
type: "post",
publishedAt: "2026-10-19T10:30:00Z",
likesCount: 39,
commentsCount: 0,
post: {
id: "p13",
content: "Full recording of the Thursday session on pricing experiments. Chapters: why we test price at all, how to pick the first segment, the three-week test plan, what to tell existing customers, reading the results without fooling yourself, and the questions from the audience at the end. Slides and the spreadsheet template are linked in the comments. If you only have five minutes, skip to the part on reading results: that is where most teams get it wrong, and where we lost two months last year before we noticed.",
author: author("a13", "Rafael Duarte"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(230, "Pricing session"), mp4Url: FIXTURE_LANDSCAPE_URL, videoDuration: 2, order: 0 }],
},
},
{
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"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 154, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T07:50:00Z",
likesCount: 31,
commentsCount: 4,
post: {
id: "p8",
content: "Landscape recording of the quarterly walkthrough. It should show whole, centered, over a blurred copy of its own thumbnail.",
author: author("a8", "Tomas Reyes"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(180, "Landscape 16:9"), mp4Url: FIXTURE_LANDSCAPE_URL, videoDuration: 2, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T07:20:00Z",
likesCount: 44,
commentsCount: 5,
post: {
id: "p9",
content: "Portrait clip shot on a phone. It should fill the whole slide.",
author: author("a9", "Ines Moreau"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(300, "Portrait 9:16", 720, 1280), mp4Url: FIXTURE_PORTRAIT_URL, videoDuration: 2, order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-18T21:20:00Z",
likesCount: 22,
commentsCount: 2,
post: {
id: "p7",
content: "Workshop recap: two slides and the demo recording. Swipe sideways for all three.",
author: author("a7", "Leila Haddad"),
mediaItems: [
{ mediaType: "image", imageUrl: placeholderImage(140, "Slide one"), order: 0 },
{ mediaType: "image", imageUrl: placeholderImage(260, "Slide two"), order: 1 },
{ mediaType: "video", thumbnailUrl: placeholderImage(20, "Demo recording"), mp4Url: FIXTURE_VIDEO_URL, videoDuration: 73, order: 2 },
],
},
},
{
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,
pictureUrl: string | null = null,
): DiscoverExpert => ({
id,
slug: displayName.replace(/\s+/g, ""),
displayName,
byline,
pictureUrl,
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),
// Photo placeholders exercise the image path of the featured carousel; the
// rest show initials.
expert("e9", "Leila Haddad", "Marketplace operations", 4.9, 181, placeholderImage(10)),
expert("e10", "Tomas Ferreira", "Hiring and team design", 4.7, 64, placeholderImage(190)),
expert("e11", "Grace Whitfield", "Brand and storytelling", 4.8, 102, placeholderImage(300)),
];
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
id,
question,
views,
thumbnailUrl: placeholderImage(hue),
media: id === "m2" ? { kind: "video", url: "/fixtures/answer.mp4", duration: 42, waveform: null } : id === "m1" || id === "m4" ? { kind: "audio", url: "/fixtures/answer.mp3", duration: 31, waveform: null } : 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: [],
}));
+24
View File
@@ -0,0 +1,24 @@
/**
* Mobile feed autoplay: the session's mute choice and how a failed play() is read.
* The choice lives in memory only, for this page session: videos start muted
* (browsers only autoplay muted video) until the person unmutes one.
*/
let sessionMuted = true;
/** True until the person unmutes a video in this session. */
export function getSessionMuted(): boolean {
return sessionMuted;
}
/** Remember the person's mute choice for the videos that play after this one. */
export function setSessionMuted(muted: boolean): void {
sessionMuted = muted;
}
/**
* True when play() was refused by the browser's autoplay policy. A play() that
* was cut short by pause() (AbortError) is not a refusal.
*/
export function isAutoplayBlocked(error: unknown): boolean {
return !!error && typeof error === "object" && (error as { name?: unknown }).name === "NotAllowedError";
}
+24 -1
View File
@@ -9,6 +9,18 @@ export interface CardMedia {
hlsUrl?: string | null;
/** Direct progressive MP4 on the feed CDN (videos only). Preferred for playback. */
mp4Url?: string | null;
/** Video length in whole seconds, when the feed reports it. */
durationSeconds?: number | null;
}
/** "m:ss" for a duration in seconds, or null when it is missing or not positive. */
export function formatDuration(seconds: number | null | undefined): string | null {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return null;
const total = Math.round(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, "0");
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
export interface CardPost {
@@ -92,7 +104,8 @@ function toMedia(raw: unknown): CardMedia[] {
const hls = muxHlsUrl(m.hlsUrl);
const mp4 = feedMp4Url(m.mp4Url);
// Keep a video even with no thumbnail or playable URL yet (just published).
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4 });
const dur = [m.videoDuration, m.durationSeconds, m.duration].find((v): v is number => typeof v === "number" && v > 0) ?? null;
out.push({ kind: "video", src: thumb, hlsUrl: hls, mp4Url: mp4, ...(dur ? { durationSeconds: dur } : {}) });
continue;
}
const src = str(m.imageMediumWebpUrl) ?? str(m.imageMediumUrl) ?? str(m.imageUrl);
@@ -181,6 +194,16 @@ export function upgradeVideos(current: CardPost[], fresh: CardPost[]): CardPost[
return changed ? next : current;
}
/** A failed first-page load: a refresh keeps the posts and flags an inline error; an initial load shows the full error state. */
export function firstLoadFailure(refresh: boolean): { fullError: boolean; refreshFailed: boolean } {
return { fullError: !refresh, refreshFailed: refresh };
}
/** "1 comment", "0 comments", "2 comments". */
export function commentsLabel(count: number): string {
return count === 1 ? "1 comment" : `${count} comments`;
}
export function relativeTime(publishedAt: string | null, now: number = Date.now()): string {
const t = publishedAt ? Date.parse(publishedAt) : NaN;
if (!Number.isFinite(t)) return "";
+51
View File
@@ -0,0 +1,51 @@
// Theme helper (ADR 0001, section 5). The theme is `data-theme` on <html>,
// stored in localStorage under THEME_KEY. Dark is the default.
export type Theme = "dark" | "light";
export const THEME_KEY = "theme";
export const DEFAULT_THEME: Theme = "dark";
type ReadableStorage = Pick<Storage, "getItem">;
type WritableStorage = Pick<Storage, "setItem">;
type ThemeRoot = { dataset: DOMStringMap; style: { colorScheme: string } };
export function isTheme(value: unknown): value is Theme {
return value === "dark" || value === "light";
}
/** The stored theme, or dark when nothing valid is stored or storage throws. */
export function readStoredTheme(storage: ReadableStorage | null | undefined): Theme {
try {
const value = storage?.getItem(THEME_KEY);
return isTheme(value) ? value : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
/** The theme currently on the root element, or dark when it is missing. */
export function currentTheme(root: ThemeRoot | null | undefined): Theme {
const value = root?.dataset.theme;
return isTheme(value) ? value : DEFAULT_THEME;
}
/**
* Set the theme on the root element and store it. A failed write still
* changes the theme for this session.
*/
export function applyTheme(theme: Theme, root: ThemeRoot, storage: WritableStorage | null | undefined): void {
root.dataset.theme = theme;
root.style.colorScheme = theme;
try {
storage?.setItem(THEME_KEY, theme);
} catch {
// Storage blocked or full: keep the theme for this session only.
}
}
/**
* Inline script for the first child of <head>. It runs before first paint, so
* a stored light theme never flashes dark. Fixed string, no user input.
*/
export const THEME_SCRIPT = `(function(){var t="${DEFAULT_THEME}";try{var s=localStorage.getItem("${THEME_KEY}");if(s==="light"||s==="dark")t=s;}catch(e){}var r=document.documentElement;r.dataset.theme=t;r.style.colorScheme=t;})();`;
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
import { applyTheme, currentTheme, DEFAULT_THEME, THEME_KEY, type Theme } from "./theme";
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
const onStorage = (event: StorageEvent) => {
// Another tab changed the theme: follow it.
if (event.key !== THEME_KEY) return;
const next = event.newValue === "light" || event.newValue === "dark" ? event.newValue : DEFAULT_THEME;
applyTheme(next, document.documentElement, null);
listeners.forEach((l) => l());
};
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", onStorage);
};
}
const getSnapshot = (): Theme => currentTheme(document.documentElement);
const getServerSnapshot = (): Theme => DEFAULT_THEME;
function safeStorage(): Storage | null {
try {
return window.localStorage;
} catch {
return null;
}
}
/** Shared theme state for every toggle: the current theme and a setter. */
export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void } {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setTheme = useCallback((next: Theme) => {
applyTheme(next, document.documentElement, safeStorage());
listeners.forEach((l) => l());
}, []);
const toggleTheme = useCallback(() => setTheme(getSnapshot() === "dark" ? "light" : "dark"), [setTheme]);
return { theme, setTheme, toggleTheme };
}
+28
View File
@@ -0,0 +1,28 @@
/* HexAvatar (ADR 0001, section 6). Pointy-top hexagon, width to height 0.866. */
.hex-avatar {
display: block;
flex: none;
object-fit: cover;
background: var(--tile-3);
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
.hex-avatar-lg { width: 46px; height: 53px; }
.hex-avatar-md { width: 38px; height: 44px; }
.hex-avatar-sm { width: 30px; height: 35px; }
.hex-avatar-initials {
display: grid;
place-items: center;
color: var(--avatar-ink);
font-weight: 600;
letter-spacing: .02em;
}
.hex-avatar-lg.hex-avatar-initials { font-size: 16px; }
.hex-avatar-md.hex-avatar-initials { font-size: 14px; }
.hex-avatar-sm.hex-avatar-initials { font-size: 11px; }
.hex-avatar-oxblood { background: var(--hex-oxblood); }
.hex-avatar-navy { background: var(--hex-navy); }
.hex-avatar-forest { background: var(--hex-forest); }
.hex-avatar-slate { background: var(--hex-slate); }
.hex-avatar-plum { background: var(--hex-plum); }
.hex-avatar-graphite { background: var(--hex-graphite); }
+509
View File
@@ -0,0 +1,509 @@
/*
* 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 no global
* `button` rule, such as `button { color: inherit }`, can leak in.
*/
/* Shared base for every style. */
.btn-flip,
.btn-grow,
.btn-sweep,
.btn-depth,
.btn-spark,
.btn-gloss {
appearance: none;
margin: 0;
border: 0;
font-family: inherit;
cursor: pointer;
position: relative;
overflow: hidden;
isolation: isolate;
opacity: 1;
-webkit-tap-highlight-color: transparent;
}
.btn-flip svg,
.btn-grow svg,
.btn-depth svg,
.btn-spark svg,
.btn-gloss svg { flex: none; }
/* 1. Flip reveal: Post. Ready, disabled ("write first") and busy ("posting"). */
.btn-flip {
height: 42px;
min-width: 118px;
padding: 0 22px;
border-radius: var(--r-ctl);
background: var(--ink);
color: var(--bg);
font-size: 13px;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
box-shadow: var(--shadow-btn);
transition: transform .3s cubic-bezier(.23,1,.32,1);
}
.btn-flip .btn-flip-front {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
color: var(--bg);
transition: transform .6s cubic-bezier(.23,1,.32,1);
}
.btn-flip .btn-flip-back {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--on-accent);
background: linear-gradient(135deg, var(--accent-deep), var(--accent));
transform: translateY(100%);
transition: transform .6s cubic-bezier(.23,1,.32,1);
}
.btn-flip svg { width: 16px; height: 16px; }
.btn-flip:hover .btn-flip-front,
.btn-flip:focus-visible .btn-flip-front { transform: translateY(-200%); }
.btn-flip:hover .btn-flip-back,
.btn-flip:focus-visible .btn-flip-back { transform: none; }
.btn-flip:active:not(:disabled) { transform: scale(.95); }
/* Disabled: solid graphite face, readable text, never faded. */
.btn-flip:disabled {
opacity: 1;
cursor: not-allowed;
background: var(--tile-3);
color: var(--ink-2);
box-shadow: inset 0 0 0 1px var(--line-2);
}
.btn-flip:disabled .btn-flip-front { color: var(--ink-2); }
.btn-flip:disabled .btn-flip-back {
background: var(--tile-3);
color: var(--ink);
font-size: 12px;
}
/* Busy (posting): keeps the ink face, shows "Posting", never flips. */
.btn-flip.btn-flip-busy:disabled {
cursor: progress;
background: var(--ink);
color: var(--bg);
box-shadow: var(--shadow-btn);
}
.btn-flip.btn-flip-busy:disabled .btn-flip-front { color: var(--bg); transform: none; }
.btn-flip.btn-flip-busy .btn-flip-back { transform: translateY(100%); }
/* 2. Circle expand: the new-posts pill. */
.btn-grow {
display: block;
width: 178px;
height: 42px;
padding: 0;
border-radius: 999px;
background: var(--glass);
color: var(--ink);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
}
.btn-grow .btn-grow-circle {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 34px;
border-radius: 999px;
background: linear-gradient(180deg, var(--accent-hi), var(--accent) 55%, var(--accent-lo));
transition: width .45s cubic-bezier(.65,0,.076,1);
}
.btn-grow .btn-grow-circle svg {
position: absolute;
left: 9px;
top: 9px;
width: 16px;
height: 16px;
color: var(--on-accent);
transition: transform .45s cubic-bezier(.65,0,.076,1);
}
.btn-grow .btn-grow-text {
position: relative;
z-index: 1;
display: block;
padding-left: 40px;
line-height: 42px;
font-size: 11.5px;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
white-space: nowrap;
color: var(--ink);
transition: color .45s cubic-bezier(.65,0,.076,1);
}
.btn-grow:hover .btn-grow-circle,
.btn-grow:focus-visible .btn-grow-circle { width: calc(100% - 8px); }
.btn-grow:hover .btn-grow-circle svg,
.btn-grow:focus-visible .btn-grow-circle svg { transform: translateY(-3px); }
.btn-grow:hover .btn-grow-text,
.btn-grow:focus-visible .btn-grow-text { color: var(--on-accent); }
.btn-grow:active { transform: scale(.97); }
/* 3. Outline fill sweep: category chips and Cancel. Selected stays filled. */
.btn-sweep {
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
padding: 0 15px;
border-radius: 999px;
background: transparent;
color: var(--ink);
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
box-shadow: inset 0 0 0 1.5px var(--line-2);
transition: color .3s, box-shadow .3s, transform .15s;
}
.btn-sweep::before {
content: "";
position: absolute;
z-index: -1;
width: 170px;
height: 170px;
border-radius: 50%;
background: var(--ink);
top: 100%;
left: 100%;
transition: top .35s var(--ease), left .35s var(--ease);
}
.btn-sweep:hover,
.btn-sweep:focus-visible,
.btn-sweep.btn-sweep-on {
color: var(--bg);
box-shadow: inset 0 0 0 1.5px var(--ink);
}
.btn-sweep:hover::before,
.btn-sweep:focus-visible::before,
.btn-sweep.btn-sweep-on::before { top: -70px; left: -40px; }
.btn-sweep:active { transform: scale(.95); }
/* 4. Soft depth, rising liquid: mobile More sheet rows and the theme switch. */
.btn-depth {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px;
border-radius: var(--r-card);
background: var(--bg);
color: var(--ink);
text-align: left;
font-size: 14px;
box-shadow: 6px 6px 14px var(--neu-d), -6px -6px 14px var(--neu-l);
transition: color .3s, box-shadow .25s;
}
.btn-depth::before,
.btn-depth::after {
content: "";
position: absolute;
width: 140%;
height: 240%;
border-radius: 50%;
z-index: -1;
transition: all .5s .05s cubic-bezier(.55,0,.1,1);
}
.btn-depth::before {
left: 50%;
top: 100%;
transform: translateX(-50%) scaleX(1.25);
background: color-mix(in srgb, var(--accent), transparent 82%);
}
.btn-depth::after {
left: 55%;
top: 180%;
transform: translateX(-50%) scaleX(1.45);
background: var(--accent);
}
.btn-depth .btn-depth-icon {
width: 44px;
height: 44px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-ctl);
background: var(--tile-2);
color: var(--ink);
transition: background-color .3s, color .3s;
}
.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; 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,
.btn-depth:focus-visible::before {
top: -35%;
background: var(--accent);
transform: translateX(-50%) scaleY(1.3) scaleX(.8);
}
.btn-depth:hover::after,
.btn-depth:focus-visible::after { top: -45%; transform: translateX(-50%) scaleY(1.3) scaleX(.8); }
.btn-depth:hover .btn-depth-sub,
.btn-depth:focus-visible .btn-depth-sub { color: color-mix(in srgb, white 82%, transparent); }
.btn-depth:hover .btn-depth-icon,
.btn-depth:focus-visible .btn-depth-icon { background: color-mix(in srgb, white 16%, transparent); color: var(--on-accent); }
.btn-depth:active { box-shadow: inset 4px 4px 12px var(--neu-d), inset -4px -4px 12px var(--neu-l); }
/* 5. Sparkle glow: Sign in (fold + hexagon) and the mobile new-post button (plus). */
.btn-spark {
--spark-round: 14px;
display: flex;
align-items: center;
justify-content: center;
min-width: 180px;
height: 50px;
padding: 0 22px;
border-radius: var(--spark-round);
color: var(--on-accent);
background:
radial-gradient(65% 65% at 50% 100%, var(--accent-glow) 0%, var(--accent-glow-0) 100%),
linear-gradient(0deg, var(--accent), var(--accent));
box-shadow: 0 18px 36px -14px color-mix(in srgb, var(--accent), transparent 15%);
transition: transform .25s;
}
.btn-spark::before,
.btn-spark::after {
content: "";
position: absolute;
inset: var(--spark-inset);
border-radius: calc(var(--spark-round) - var(--spark-inset));
z-index: 0;
transition: all .5s ease-in-out;
}
.btn-spark::before {
--spark-inset: 1px;
background: linear-gradient(178deg, color-mix(in srgb, white 24%, transparent) 0%, transparent 100%);
}
.btn-spark::after {
--spark-inset: 2px;
background:
radial-gradient(65% 65% at 50% 100%, var(--accent-glow) 0%, var(--accent-glow-0) 100%),
linear-gradient(0deg, var(--accent), var(--accent));
}
.btn-spark:active { transform: scale(.96); }
.btn-spark .btn-spark-fold {
z-index: 1;
position: absolute;
top: 0;
right: 0;
width: 1rem;
height: 1rem;
transition: all .5s ease-in-out;
background: radial-gradient(100% 75% at 55%, var(--accent-glow) 0%, var(--accent-glow-0) 100%);
box-shadow: 0 0 3px black;
border-bottom-left-radius: .5rem;
border-top-right-radius: var(--spark-round);
}
.btn-spark .btn-spark-fold::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 150%;
height: 150%;
transform: rotate(45deg) translateY(-18px);
background: var(--bg);
pointer-events: none;
}
.btn-spark:hover .btn-spark-fold,
.btn-spark:focus-visible .btn-spark-fold { margin-top: -1rem; margin-right: -1rem; }
.btn-spark .btn-spark-pts { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 1; }
.btn-spark .btn-spark-pts i {
position: absolute;
bottom: -10px;
width: 2px;
height: 2px;
border-radius: 50%;
background: var(--on-accent);
animation: btn-spark-rise infinite ease-in-out;
}
@keyframes btn-spark-rise {
0% { transform: translateY(0); }
85% { opacity: 0; }
100% { transform: translateY(-60px); opacity: 0; }
}
.btn-spark .btn-spark-pts i:nth-child(1) { left: 10%; animation-duration: 2.35s; animation-delay: .2s; }
.btn-spark .btn-spark-pts i:nth-child(2) { left: 30%; opacity: .7; animation-duration: 2.5s; animation-delay: .5s; }
.btn-spark .btn-spark-pts i:nth-child(3) { left: 25%; opacity: .8; animation-duration: 2.2s; animation-delay: .1s; }
.btn-spark .btn-spark-pts i:nth-child(4) { left: 44%; opacity: .6; animation-duration: 2.05s; }
.btn-spark .btn-spark-pts i:nth-child(5) { left: 50%; animation-duration: 1.9s; }
.btn-spark .btn-spark-pts i:nth-child(6) { left: 75%; opacity: .5; animation-duration: 1.5s; animation-delay: 1.5s; }
.btn-spark .btn-spark-pts i:nth-child(7) { left: 88%; opacity: .9; animation-duration: 2.2s; animation-delay: .2s; }
.btn-spark .btn-spark-pts i:nth-child(8) { left: 58%; opacity: .8; animation-duration: 2.25s; animation-delay: .2s; }
.btn-spark .btn-spark-pts i:nth-child(9) { left: 96%; opacity: .6; animation-duration: 2.6s; animation-delay: .1s; }
.btn-spark .btn-spark-pts i:nth-child(10) { left: 65%; animation-duration: 2.5s; animation-delay: .2s; }
.btn-spark .btn-spark-inner {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--on-accent);
font-weight: 700;
font-size: 15px;
letter-spacing: .06em;
}
.btn-spark .btn-spark-hex {
width: 18px;
height: 18px;
fill: transparent;
stroke: var(--on-accent);
stroke-width: 2.2;
stroke-linejoin: round;
}
.btn-spark:hover .btn-spark-hex,
.btn-spark:focus-visible .btn-spark-hex {
animation: btn-spark-dash 1s linear forwards, btn-spark-fill .1s linear forwards .95s;
}
@keyframes btn-spark-dash { from { stroke-dasharray: 0 0 0 0; } to { stroke-dasharray: 68 68 0 0; } }
@keyframes btn-spark-fill { to { fill: var(--on-accent); } }
/* Mobile new-post variant: square, no fold, the plus turns 90 degrees. */
.btn-spark.btn-spark-fab {
--spark-round: 16px;
min-width: 0;
width: 56px;
height: 56px;
padding: 0;
box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--accent), transparent 15%);
}
.btn-spark.btn-spark-fab::after { display: none; }
.btn-spark.btn-spark-fab:active { transform: scale(.94); }
.btn-spark .btn-spark-plus {
position: relative;
z-index: 2;
width: 24px;
height: 24px;
color: var(--on-accent);
transition: transform .5s cubic-bezier(.2,.9,.3,1.4);
}
.btn-spark:hover .btn-spark-plus,
.btn-spark:focus-visible .btn-spark-plus { transform: rotate(90deg); }
/* Glossy crimson icon button: send comment, delete confirm, play. */
.btn-gloss {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border-radius: var(--r-ctl);
color: var(--on-accent);
background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo));
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 32%, transparent),
inset 0 -1px 0 color-mix(in srgb, black 28%, transparent),
0 0 0 1px var(--accent-lo),
0 12px 26px -14px var(--accent);
transition: transform .18s var(--ease), box-shadow .3s;
}
.btn-gloss.btn-gloss-round { border-radius: 50%; }
.btn-gloss.btn-gloss-sm { width: 36px; height: 36px; }
.btn-gloss svg { width: 18px; height: 18px; color: var(--on-accent); }
.btn-gloss::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 50%;
background: linear-gradient(180deg, color-mix(in srgb, white 20%, transparent), transparent);
z-index: -1;
}
.btn-gloss::after {
content: "";
position: absolute;
top: -10%;
bottom: -10%;
width: 55%;
left: -90%;
background: linear-gradient(100deg, transparent, color-mix(in srgb, white 42%, transparent), transparent);
transform: skewX(-20deg);
transition: left .8s var(--ease);
}
.btn-gloss:hover::after,
.btn-gloss:focus-visible::after { left: 140%; }
.btn-gloss:active:not(:disabled) { transform: scale(.96); }
/* Disabled keeps a solid face (no opacity) so the icon stays readable. */
.btn-gloss:disabled {
opacity: 1;
cursor: not-allowed;
background: var(--tile-3);
color: var(--ink-2);
box-shadow: inset 0 0 0 1px var(--line-2);
}
.btn-gloss:disabled svg { color: var(--ink-2); }
.btn-gloss:disabled::before,
.btn-gloss:disabled::after { display: none; }
/* Reduced motion: hover and press states still change color, but nothing moves. */
@media (prefers-reduced-motion: reduce) {
.btn-flip,
.btn-flip *,
.btn-grow,
.btn-grow *,
.btn-sweep,
.btn-sweep::before,
.btn-depth,
.btn-depth *,
.btn-depth::before,
.btn-depth::after,
.btn-spark,
.btn-spark *,
.btn-spark::before,
.btn-spark::after,
.btn-gloss,
.btn-gloss::before,
.btn-gloss::after {
transition: none !important;
animation: none !important;
}
.btn-flip:active,
.btn-grow:active,
.btn-sweep:active,
.btn-spark:active,
.btn-gloss:active { transform: none !important; }
.btn-grow:hover .btn-grow-circle svg,
.btn-grow:focus-visible .btn-grow-circle svg,
.btn-spark:hover .btn-spark-plus,
.btn-spark:focus-visible .btn-spark-plus { transform: none !important; }
.btn-spark .btn-spark-pts { display: none; }
.btn-spark:hover .btn-spark-hex,
.btn-spark:focus-visible .btn-spark-hex { fill: var(--on-accent); }
.btn-gloss::after { display: none; }
}
/* Sparkle glow, disabled: a solid readable face, no glow, no sparks, no fade. */
.btn-spark:disabled {
opacity: 1;
cursor: not-allowed;
background: var(--tile-3);
color: var(--ink-2);
box-shadow: inset 0 0 0 1px var(--line-2);
transform: none;
}
.btn-spark:disabled .btn-spark-inner,
.btn-spark:disabled .btn-spark-hex { color: var(--ink-2); }
.btn-spark:disabled .btn-spark-hex { stroke: var(--ink-2); animation: none; }
.btn-spark:disabled::before,
.btn-spark:disabled::after,
.btn-spark:disabled .btn-spark-fold,
.btn-spark:disabled .btn-spark-pts { display: none; }
+225
View File
@@ -0,0 +1,225 @@
/*
* Composer, toolbar, emoji popover and attachment row (spec "Composer",
* ADR 0001). Ported from the mockup's .cmp, .bar, .emo and .att rules.
* Tokens only. The Post button is the flip reveal from buttons.css.
*/
.composer {
--composer-soft: color-mix(in srgb, var(--ink), transparent 93%);
position: relative;
border-radius: var(--r-card);
background: var(--tile-2);
box-shadow: inset 0 0 0 1px var(--line);
padding: 10px 10px 8px 12px;
display: flex;
flex-direction: column;
transition: box-shadow .3s, background-color .3s;
}
.composer.composer-open {
background: var(--tile);
box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
}
.composer-top { display: flex; gap: 12px; align-items: flex-start; }
.composer-top .hex-avatar { flex: none; }
.composer .composer-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.composer textarea {
flex: 1;
min-width: 0;
margin: 0;
border: 0;
outline: 0;
resize: none;
background: transparent;
font: inherit;
font-size: 15px;
line-height: 1.5;
color: var(--ink);
padding: 12px 2px;
height: 50px;
transition: height .3s var(--ease);
}
.composer.composer-open textarea { height: 92px; }
.composer textarea::placeholder { color: var(--ink-3); }
.composer textarea:disabled { color: var(--ink-2); }
/* Toolbar: photo, video, emoji | bulleted list ... Ctrl + Enter, Post. */
.composer-bar {
display: flex;
align-items: center;
gap: 2px;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.composer-tool {
appearance: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
border-radius: var(--r-ctl);
display: inline-grid;
place-items: center;
color: var(--ink-2);
cursor: pointer;
flex: none;
transition: background-color .2s, color .2s, transform .15s;
}
.composer-tool svg { width: 20px; height: 20px; }
.composer-tool:hover:not(:disabled),
.composer-tool.composer-tool-on { background: var(--composer-soft); color: var(--ink); }
.composer-tool:active:not(:disabled) { transform: scale(.9); }
.composer-tool:disabled { cursor: not-allowed; color: var(--ink-3); background: transparent; }
.composer-file {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
border: 0;
}
.composer-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 6px; flex: none; }
.composer-spacer { flex: 1; }
.composer-kbd { font-size: 11.5px; color: var(--ink-3); margin-right: 10px; white-space: nowrap; }
.composer-kbd b {
font-weight: 700;
color: var(--ink-2);
padding: 1px 6px;
border-radius: 6px;
background: var(--tile-2);
box-shadow: inset 0 0 0 1px var(--line);
}
/* Emoji: a frosted popover with 32 frequent emoji. */
.composer-emoji-wrap { position: relative; }
.composer-emoji {
position: absolute;
z-index: 30;
top: calc(100% + 8px);
left: -8px;
width: 320px;
padding: 10px;
background: var(--glass);
-webkit-backdrop-filter: blur(20px) saturate(1.7);
backdrop-filter: blur(20px) saturate(1.7);
border: 1px solid var(--line);
border-radius: var(--r-card);
box-shadow: var(--shadow-pop);
animation: composer-pop .24s cubic-bezier(.2,.9,.3,1.25) both;
}
.composer-eyebrow {
display: block;
padding: 2px 6px 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--ink-3);
}
.composer-emoji-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; }
.composer-emoji-grid button {
appearance: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--ink);
height: 36px;
border-radius: 10px;
font-family: inherit;
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: transform .15s cubic-bezier(.2,.9,.3,1.4), background-color .15s;
}
.composer-emoji-grid button:hover { background: var(--composer-soft); transform: scale(1.2); }
/* Attachment row: thumbnail, name, type and size, progress, remove. */
.composer-att {
display: flex;
align-items: center;
gap: 12px;
margin: 6px 0 2px;
padding: 8px;
border-radius: 14px;
background: var(--tile-2);
box-shadow: inset 0 0 0 1px var(--line);
animation: composer-rise .35s var(--ease) both;
}
.composer-att.composer-att-indent { margin-left: 58px; }
.composer-thumb {
position: relative;
width: 56px;
height: 56px;
border-radius: 10px;
flex: none;
overflow: hidden;
background: var(--tile-3);
}
.composer-thumb img,
.composer-thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
.composer-thumb svg {
position: absolute;
inset: 0;
margin: auto;
width: 20px;
height: 20px;
color: var(--on-accent);
filter: drop-shadow(0 1px 3px var(--accent-deep));
}
.composer-att-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.composer-att-text strong {
font-size: 13px;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-att-text span { font-size: 12px; color: var(--ink-3); }
.composer-prog { height: 3px; border-radius: 3px; background: var(--tile-3); overflow: hidden; }
.composer-prog i {
display: block;
height: 100%;
background: var(--accent);
transform-origin: left;
animation: composer-load 1.6s var(--ease) both;
}
/* While posting the upload has no byte count, so the bar sweeps. */
.composer-prog.composer-prog-busy i { width: 40%; animation: composer-sweep 1.1s ease-in-out infinite; }
.composer .error { margin: 8px 0 0; }
/* The flip Post button keeps its own size inside the toolbar. */
.composer-bar .composer-post { flex: none; }
@keyframes composer-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
@keyframes composer-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes composer-load { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes composer-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
@media (prefers-reduced-motion: reduce) {
.composer,
.composer textarea,
.composer-tool,
.composer-emoji-grid button { transition: none !important; }
.composer-emoji,
.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; }
+116
View File
@@ -0,0 +1,116 @@
/* Featured experts carousel (ADR 0001, section 6; spec "Featured experts
carousel"). Ported from the mockup's .cf rules; colors are tokens only.
--cfw is the center photo width, set by the component's size prop. */
.featured { flex: none; }
.featured-title { display: inline-flex; align-items: center; gap: 8px; }
.featured-title svg { width: 16px; height: 16px; color: var(--accent); }
.featured-count { font-variant-numeric: tabular-nums; }
.featured-cf {
--cfw: 96px;
position: relative;
height: calc(var(--cfw) * 1.155 + 6px);
overflow: hidden;
overflow: clip;
overflow-clip-margin: 24px;
}
.featured-cf::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--cfw) * 1.9);
height: calc(var(--cfw) * 1.3);
transform: translate(-50%, -50%);
background: radial-gradient(closest-side, color-mix(in oklab, var(--accent), transparent 70%), transparent);
filter: blur(12px);
animation: featured-breathe 4s ease-in-out infinite;
}
@keyframes featured-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
.featured-item {
position: absolute;
left: 50%;
top: 0;
width: var(--cfw);
margin-left: calc(var(--cfw) / -2);
transition: transform 1.1s var(--ease), opacity 1.1s, filter 1.1s;
}
.featured-ring {
display: block;
padding: 3px;
background: var(--line-2);
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
transition: background .8s;
}
.featured-photo {
display: block;
width: 100%;
aspect-ratio: .866;
object-fit: cover;
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
/* Photos sit on the tile while loading; initials keep their HexAvatar tone. */
img.featured-photo { background: var(--tile-3); }
.featured-initials {
display: grid;
place-items: center;
color: var(--avatar-ink);
font-weight: 600;
letter-spacing: .02em;
font-size: calc(var(--cfw) * .3);
}
.featured-c0 { z-index: 5; }
.featured-c0 .featured-ring { background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo)); }
.featured-r1 { transform: translateX(76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
.featured-l1 { transform: translateX(-76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
.featured-r2 { transform: translateX(132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
.featured-l2 { transform: translateX(-132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
.featured-r3 { transform: translateX(170%) scale(.3); opacity: 0; z-index: 1; }
.featured-l3 { transform: translateX(-170%) scale(.3); opacity: 0; z-index: 1; }
.featured-cap {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 3px;
padding: 12px 16px 2px;
animation: featured-cap .8s var(--ease) both;
}
@keyframes featured-cap { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.featured-name { font-size: 23px; line-height: 1.1; }
.featured-by {
font-size: 12.5px;
color: var(--ink-3);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.featured-rate {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12.5px;
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.featured-rate > span { display: inline-flex; align-items: center; gap: 4px; }
.featured-rate svg { width: 13px; height: 13px; color: var(--accent); }
.featured-rate em { font-style: normal; font-weight: 500; color: var(--ink-3); }
.featured-dots { display: flex; justify-content: center; gap: 5px; padding: 10px 0 16px; }
.featured-dots-gap { height: 16px; }
.featured-dot {
display: block;
width: 5px;
height: 5px;
border-radius: 3px;
background: var(--line-2);
transition: width .5s var(--ease), background-color .5s;
}
.featured-dot-on { width: 18px; background: var(--accent); }
+72
View File
@@ -0,0 +1,72 @@
/*
* Feed list: scroll snap, the new-posts pill and the loading-more skeleton
* (spec "Feed list", ADR 0001). Prefixed `feed-` classes, tokens only.
*/
.feed .feed-list { scroll-snap-type: y proximity; }
.feed-message { text-align: center; color: var(--ink-3); padding: 32px 16px; }
.feed-message button { margin-top: 8px; }
.feed-center { display: grid; place-items: center; padding: 16px; }
.sentinel { height: 1px; }
.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); } }
/* The pill floats at the top center of the list. The zero-height sticky
wrapper takes the entrance animation, so the button keeps its own transforms
(hover expand, press). */
.feed-pill {
position: sticky;
top: 12px;
z-index: 4;
display: flex;
justify-content: center;
height: 0;
margin-bottom: -16px;
overflow: visible;
animation: feed-pill-in .45s var(--ease) backwards;
}
.feed-pill .btn-grow { flex: none; }
@keyframes feed-pill-in { from { opacity: 0; transform: translateY(-10px) scale(.96); } to { opacity: 1; transform: none; } }
/* Skeleton card: same footprint as a post, with a shimmer band sweeping over it. */
.feed-skel {
flex: none;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--tile);
border: 1px solid var(--line);
border-radius: var(--r-card);
scroll-snap-align: none;
}
.feed-skel::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--ink), transparent 90%) 50%, transparent 80%);
transform: translateX(-100%);
animation: feed-shimmer 1.4s ease-in-out infinite;
}
@keyframes feed-shimmer { to { transform: translateX(100%); } }
.feed-skel-head { display: flex; align-items: center; gap: 12px; }
.feed-skel-lines { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.feed-skel-dot { width: 40px; height: 40px; border-radius: 50%; background: var(--tile-3); flex: none; }
.feed-skel-line { display: block; height: 12px; border-radius: 6px; background: var(--tile-3); }
.feed-skel-w25 { width: 25%; }
.feed-skel-w40 { width: 40%; }
.feed-skel-w80 { width: 80%; }
.feed-skel-w100 { width: 100%; }
.feed-skel-media { display: block; aspect-ratio: 16 / 9; border-radius: 12px; background: var(--tile-3); }
/* Refresh button in the feed header. Its disabled state keeps full opacity, so
the spinning icon is the only sign that a reload is running. */
.refresh-icon { display: block; }
.refresh-icon-spin { animation: spin .8s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.refresh-icon-spin { animation: none !important; }
}
.feed-refresh-error { margin-left: auto; font-size: 13px; color: var(--danger); }
+212
View File
@@ -0,0 +1,212 @@
/* 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. */
/* 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;
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;
}
/* 761px to 1000px: the feed spans the full height on the left; the account and
search panels stack over the featured and trending panels on the right. */
@container app (max-width: 1000px) and (min-width: 761px) {
.bento {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
.bento-center { grid-column: 1; grid-row: 1 / 3; height: 100%; }
.bento-left { grid-column: 2; grid-row: 1; }
.bento-right { grid-column: 2; grid-row: 2; overflow-y: auto; overscroll-behavior: contain; }
.bento-left .expert-search { flex: 0 1 auto; max-height: 250px; }
.bento-right .rail-trending { flex: 1 0 200px; }
.bento .account-live { display: none; }
}
/* 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: none; }
.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; }
/* 760px and below: the full-screen feed (spec "Mobile layout"). The feed
column fills the app frame and the reel inside it is the only scroller.
The side panels are hidden; the phone dock and sheets reach them later. */
@container app (max-width: 760px) {
.bento { display: block; padding: 0; overflow: hidden; }
.bento-left,
.bento-right { display: none; }
.bento-center { height: 100%; }
}
/* 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; }
}
+598
View File
@@ -0,0 +1,598 @@
/*
* Phone feed: full-screen snapping slides (spec "Mobile layout" > "Full-screen
* feed", ADR 0001). Ported from the mockup's .reel / .slide / .sl-* rules with
* the `slide-` and `reel-` prefixes, tokens only. White, the dark fades and the
* dark glass over photos and video come from the --on-media / --media-* tokens.
* Only rendered at 760px and below (AppShell measures .app).
*/
@keyframes slide-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@keyframes slide-shimmer { from { background-position: 120% 0; } to { background-position: -100% 0; } }
.reel-feed {
position: relative;
height: 100%;
overflow: hidden;
background: var(--bg);
}
.reel {
position: absolute;
inset: 0;
overflow-y: auto;
scroll-snap-type: y mandatory;
overscroll-behavior: contain;
scrollbar-width: none;
}
.reel::-webkit-scrollbar { display: none; }
/* Long text is open on the slide on screen: the reel holds still, swipes scroll the text. */
.reel[data-locked="true"] { overflow-y: hidden; }
.slide {
--slide-text-bg: radial-gradient(120% 70% at 0% 0%, var(--accent-soft), transparent 62%), var(--bg);
position: relative;
height: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
overflow: hidden;
background: var(--slide-text-bg);
color: var(--ink);
}
/* Media: edge to edge. Scaled to 104% until the slide is on screen, then eases to 100%. */
.slide-media { position: absolute; inset: 0; background: var(--tile-3); }
.slide-art {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.04);
transition: transform 6s ease-out;
}
.is-cur .slide-art { transform: scale(1); }
.slide-video .slide-art video,
.slide-video .slide-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
.slide-video .slide-art > div { height: 100%; }
.slide-video .slide-art video { background: transparent; }
/* Wider than 4:5: show the whole video, centered, over a blurred, darkened, enlarged copy of its own thumbnail. */
.slide-backdrop { display: none; }
.slide-video[data-fit="contain"] .slide-backdrop {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.25);
filter: blur(28px) brightness(0.5);
pointer-events: none;
}
.slide-video[data-fit="contain"] .slide-art video,
.slide-video[data-fit="contain"] .slide-art img { object-fit: contain; }
.slide-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
.slide-play { z-index: 4; }
.slide-dur { top: 128px; right: 16px; z-index: 4; }
/* Autoplay: a small frosted sound toggle on the video; a tap on the video pauses or resumes it. */
.slide-video[data-autoplay="true"] .slide-art { cursor: pointer; }
.slide-mute {
position: absolute;
top: calc(72px + env(safe-area-inset-top, 0px));
right: 16px;
z-index: 4;
display: grid;
place-items: center;
width: 36px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
appearance: none;
color: var(--on-media);
background: var(--media-glass);
-webkit-backdrop-filter: blur(16px) saturate(1.6);
backdrop-filter: blur(16px) saturate(1.6);
box-shadow: inset 0 0 0 1px var(--on-media-line);
cursor: pointer;
transition: transform .2s var(--ease);
}
.slide-mute svg { width: 18px; height: 18px; }
.slide-mute:active { transform: scale(.92); }
/* Several media items: a sideways snap track; each pane is one full-slide item. */
.slide-track {
position: absolute;
inset: 0;
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.slide-track::-webkit-scrollbar { display: none; }
.slide-pane { position: relative; flex: 0 0 100%; height: 100%; scroll-snap-align: start; scroll-snap-stop: always; }
.slide-dots {
position: absolute;
left: 0;
right: 0;
bottom: 90px;
z-index: 4;
display: flex;
justify-content: center;
gap: 5px;
pointer-events: none;
}
.slide-dot {
display: block;
width: 5px;
height: 5px;
border-radius: 3px;
background: var(--on-media-line);
transition: width .5s var(--ease), background-color .5s;
}
.slide-dot-on { width: 18px; background: var(--accent); }
.slide-scrim,
.slide-scrim-top { position: absolute; left: 0; right: 0; z-index: 1; pointer-events: none; }
.slide-scrim { bottom: 0; height: 58%; background: linear-gradient(180deg, var(--media-fade-0), var(--media-fade)); }
.slide-scrim-top { top: 0; height: 150px; background: linear-gradient(180deg, var(--media-fade-top), var(--media-fade-0)); }
/* A playing video: the author, text and fades step aside so the native controls are clear. */
.slide-meta,
.slide-scrim,
.slide-scrim-top { transition: opacity .3s var(--ease); }
.slide-playing .slide-meta,
.slide-playing .slide-scrim,
.slide-playing .slide-scrim-top { opacity: 0; pointer-events: none; }
.slide-playing .slide-meta > * { pointer-events: none; }
/* Author and text over the bottom fade. */
.slide-meta {
position: absolute;
left: 20px;
right: 24px;
bottom: 108px;
z-index: 3;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.slide-meta > * { pointer-events: auto; }
.slide-on-media .slide-meta { color: var(--on-media); }
.slide-who { display: flex; align-items: center; gap: 12px; }
.slide-who > div { display: flex; flex-direction: column; min-width: 0; }
.slide-who strong { display: flex; align-items: center; font-size: 15px; font-weight: 700; }
.slide-who time { font-size: 12.5px; opacity: .72; }
.slide-on-media .slide-you { color: var(--on-media); box-shadow: inset 0 0 0 1px var(--on-media-badge); }
.slide-text {
margin: 0;
font-size: 15px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 12px var(--media-shadow);
}
.slide-error { margin: 0; color: var(--danger); font-size: 13px; font-weight: 600; }
.slide-on-media .slide-error { color: var(--on-media); }
/* Text-only post: the text is the hero, a serif quote with a crimson opening mark.
The box spans from under the top bar (or the own-post menu) to above the author
block; SlideText steps the size 31 → 26 → 22 → 19px until the text fits. */
.slide-quote-w {
position: absolute;
top: calc(72px + env(safe-area-inset-top, 0px));
bottom: 181px; /* .slide-meta bottom (108px) + the author block (53px) + 20px */
left: 24px;
right: 28px;
z-index: 2;
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: none;
}
.slide-own .slide-quote-w { top: calc(122px + env(safe-area-inset-top, 0px)); }
/* Auto margins center the quote and, unlike justify-content, never push overflow above the box. */
.slide-quote-in { margin: auto 0; }
.slide-quote-w.is-clamped .slide-quote-in { margin-top: 0; }
.slide-quote-mark {
display: block;
margin-bottom: 6px;
color: var(--accent);
font-family: var(--font-serif), Georgia, serif;
font-size: 84px;
font-weight: 600;
line-height: .62;
}
.slide-quote {
margin: 0;
font-family: var(--font-serif), Georgia, serif;
font-size: 31px;
font-weight: 600;
line-height: 1.2;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.slide-quote.is-clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* "Read more", "Close", "more" and "less": real buttons, 44px tall to tap. */
.slide-more {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin: 0;
padding: 0 18px;
border: 0;
border-radius: 14px;
font: inherit;
font-size: 14px;
font-weight: 700;
letter-spacing: .01em;
color: var(--ink);
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);
cursor: pointer;
pointer-events: auto;
transition: transform .15s;
}
.slide-more:active { transform: scale(.96); }
.slide-more:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.slide-more-quote { position: absolute; left: 0; bottom: 0; }
/* The full text of a long text-only post, over the slide between the top bar and the dock. */
@keyframes slide-reader-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.slide-reader {
position: absolute;
top: calc(64px + env(safe-area-inset-top, 0px));
bottom: calc(88px + env(safe-area-inset-bottom, 0px));
left: 12px;
right: 12px;
z-index: 7;
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 14px 14px 20px;
border-radius: 22px;
color: var(--ink);
background: var(--bg);
box-shadow: var(--shadow-pop), inset 0 0 0 1px var(--line);
animation: slide-reader-in .28s var(--ease) both;
}
.slide-reader-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 6px;
}
.slide-reader-scroll:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 6px; }
.slide-reader-text {
margin: 0;
font-family: var(--font-serif), Georgia, serif;
font-size: 22px;
font-weight: 600;
line-height: 1.35;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.slide-reader-close { flex: none; align-self: flex-end; }
/* Caption "more" / "less": a text button right under the three-line clamp. */
.slide-more.slide-more-caption {
align-self: flex-start;
flex: none;
margin: -12px 0 -10px -6px;
padding: 0 6px;
min-width: 44px;
background: none;
-webkit-backdrop-filter: none;
backdrop-filter: none;
box-shadow: none;
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
text-shadow: 0 1px 12px var(--media-shadow);
}
/* Open caption: the full text over the media on a darker scrim, scrolling inside the slide. */
.slide-who,
.slide-error { flex: none; }
.slide-text-open .slide-meta {
top: calc(122px + env(safe-area-inset-top, 0px));
justify-content: flex-end;
}
.slide-text.is-open {
display: block;
-webkit-line-clamp: unset;
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
animation: slide-reader-in .28s var(--ease) both;
}
.slide-text.is-open:focus-visible { outline: 2px solid var(--on-media); outline-offset: 4px; }
.slide-text-open .slide-scrim { height: 100%; background: var(--media-scrim); }
.slide-text-open .slide-scrim-top { opacity: 0; }
/* The slide on screen: its content rises in when it snaps into place. */
.is-cur .slide-meta,
.is-cur .slide-quote-w { animation: slide-rise .7s var(--ease) both; }
/* The rise animation's fill would keep the meta visible; a playing video takes it off. */
.is-cur.slide-playing .slide-meta { animation: none; }
/* Own-post menu: frosted "..." at the top right, the card's delete confirm under it. */
.slide-menu { position: absolute; top: calc(70px + env(safe-area-inset-top, 0px)); right: 12px; z-index: 6; }
.slide-menu-btn {
appearance: none;
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 14px;
color: var(--ink);
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);
cursor: pointer;
transition: transform .15s, background-color .2s;
}
.slide-menu-btn svg { width: 20px; height: 20px; }
.slide-menu-btn:active { transform: scale(.92); }
.slide-menu-btn:disabled { cursor: default; }
.slide-on-media .slide-menu-btn {
color: var(--on-media);
background: var(--media-glass);
box-shadow: inset 0 0 0 1px var(--on-media-line);
}
.slide-menu .post-pop { min-width: 230px; }
/* Double-tap heart burst over a post slide (mockup .burst), crimson accent. */
@keyframes slide-burst {
0% { opacity: 0; transform: scale(.2); }
25% { opacity: 1; transform: scale(1.15); }
60% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1.25) translateY(-20px); }
}
.slide-burst {
position: absolute;
left: 50%;
top: 44%;
width: 120px;
height: 120px;
margin: -60px 0 0 -60px;
color: var(--accent);
z-index: 5;
pointer-events: none;
animation: slide-burst .95s cubic-bezier(.2, .9, .3, 1.3) both;
filter: drop-shadow(0 12px 30px color-mix(in oklab, var(--accent), transparent 45%));
}
/* Featured experts slide: not a post. Eyebrow, title, carousel at 156px, hint. */
@keyframes slide-nudge { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(4px); } }
.slide-featured {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
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;
font-size: 32px;
line-height: 1.1;
color: var(--ink);
}
.slide-feat-cf { width: 100%; margin-top: 26px; }
.featured-bare { background: none; }
.featured-bare .featured-cap { padding-top: 16px; }
.slide-hint {
display: flex;
align-items: center;
gap: 6px;
margin-top: 14px;
font-size: 12px;
font-weight: 600;
letter-spacing: .04em;
color: var(--ink-3);
}
.slide-hint svg { width: 16px; height: 16px; animation: slide-nudge 1.6s ease-in-out infinite; }
/* Loading slide: a skeleton card in the middle while the next page loads. */
.slide-load { background: var(--bg); }
.slide-load .sentinel { position: absolute; top: 0; left: 0; right: 0; }
.slide-load-body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 0 22px 110px;
}
.slide-skel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--r-card);
}
.slide-skel-head { display: flex; align-items: center; gap: 12px; }
.slide-skel-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; }
.slide-skel i {
display: block;
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, var(--tile-2) 0%, var(--tile-3) 40%, var(--tile-2) 80%);
background-size: 220% 100%;
animation: slide-shimmer 1.4s linear infinite;
}
.slide-skel .slide-skel-hex {
flex: none;
width: 46px;
height: 53px;
border-radius: 0;
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
/* Empty feed or a failed page, as a slide. */
.slide-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 24px 110px;
text-align: center;
color: var(--ink-3);
}
/* New-posts pill floats over the top of the reel. */
.reel-pill {
position: absolute;
top: calc(76px + env(safe-area-inset-top, 0px));
left: 0;
right: 0;
z-index: 8;
display: flex;
justify-content: center;
pointer-events: none;
animation: feed-pill-in .45s var(--ease) backwards;
}
.reel-pill > * { pointer-events: auto; }
/* Bottom dock (spec "Bottom dock"): a frosted bar 16px above the bottom. Like and
comments act on the post on screen. Disabled buttons stay fully opaque and use
a solid muted ink instead of fading. */
.dock {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 30;
display: flex;
align-items: center;
gap: 4px;
padding: 6px;
border-radius: 22px;
background: var(--glass);
-webkit-backdrop-filter: blur(24px) saturate(1.8);
backdrop-filter: blur(24px) saturate(1.8);
box-shadow: var(--shadow-pop), inset 0 0 0 1px var(--line);
}
.dk {
appearance: none;
flex: 1;
min-width: 44px;
height: 52px;
padding: 0;
border: 0;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: transparent;
color: var(--ink);
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color .25s, color .25s;
}
.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-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; }
@media (prefers-reduced-motion: reduce) {
.is-cur .slide-meta,
.is-cur .slide-quote-w,
.slide-skel i,
.slide-burst,
.slide-reader,
.slide-text.is-open,
.slide-hint svg,
.reel-pill,
.dk-beat svg { animation: none !important; }
.slide-art,
.slide-more { 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; } }
+248
View File
@@ -0,0 +1,248 @@
/*
* 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 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; } }
@keyframes post-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
@keyframes post-beat { 0% { transform: scale(1); } 30% { transform: scale(1.4); } 60% { transform: scale(.9); } 100% { transform: scale(1); } }
.post {
--post-soft: color-mix(in srgb, var(--ink), transparent 92%);
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 16px 10px;
background: var(--tile);
border: 1px solid var(--line);
border-radius: var(--r-card);
animation: post-rise .55s var(--ease) both;
animation-delay: calc(var(--i, 0) * 70ms);
scroll-snap-align: start;
scroll-margin-top: 16px;
transition: border-color .3s, box-shadow .3s;
}
.post:hover { border-color: var(--line-2); box-shadow: var(--shadow-tile); }
.post-head { display: flex; align-items: center; gap: 12px; }
.post-who { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.post-who strong { display: flex; align-items: center; font-size: 14.5px; font-weight: 700; }
.post-who time { font-size: 12.5px; color: var(--ink-3); }
.post-you {
margin-left: 8px;
padding: 2px 7px;
border-radius: 6px;
box-shadow: inset 0 0 0 1px var(--line-2);
color: var(--ink-2);
font-size: 10px;
font-weight: 800;
letter-spacing: .12em;
text-transform: uppercase;
}
.post-text { margin: 0; font-size: 15px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.post-error { margin: 0; align-self: center; color: var(--danger); font-size: 13px; }
/* Media: 16:9, 12px radius. */
.post-media {
position: relative;
isolation: isolate;
overflow: hidden;
aspect-ratio: 16 / 9;
border-radius: var(--r-ctl);
background: var(--tile-3);
box-shadow: inset 0 0 0 1px var(--line);
}
.post-media img,
.post-media video { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
.post-media video { object-fit: contain; background: var(--tile-3); }
.post-video-pending { display: grid; place-items: center; color: var(--ink-2); font-size: 14px; }
.post-play {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
width: 62px;
height: 62px;
margin: auto;
padding: 0;
border: 0;
border-radius: 50%;
color: var(--on-accent);
background: color-mix(in srgb, var(--on-accent), transparent 84%);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--on-accent), transparent 60%), 0 12px 30px color-mix(in srgb, black 28%, transparent);
cursor: pointer;
transition: transform .3s cubic-bezier(.2,.9,.3,1.4), background-color .3s;
}
.post-play svg { width: 24px; height: 24px; margin-left: 3px; }
.post-play:hover { transform: scale(1.1); background: color-mix(in srgb, var(--on-accent), transparent 72%); }
.post-dur {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
padding: 3px 8px;
border-radius: 8px;
color: var(--on-accent);
background: color-mix(in srgb, black 42%, transparent);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
/* Footer actions. */
.post-acts { display: flex; align-items: center; gap: 2px; margin: 0 -8px; }
.post-act {
appearance: none;
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
margin: 0;
padding: 0 12px;
border: 0;
border-radius: 11px;
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 13.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: 1;
cursor: pointer;
transition: background-color .2s, color .2s;
}
.post-act svg { width: 20px; height: 20px; flex: none; }
.post-act:hover:not(:disabled) { background: var(--post-soft); color: var(--ink); }
.post-act:disabled { cursor: default; }
.post-like:hover:not(:disabled),
.post-like-on { color: var(--accent-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); }
/* Own-post menu. */
.post-menu { position: relative; }
.post-menu-btn {
appearance: none;
display: grid;
place-items: center;
width: 36px;
height: 36px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--r-ctl);
background: transparent;
color: var(--ink-2);
opacity: 1;
cursor: pointer;
transition: background-color .2s, color .2s;
}
.post-menu-btn svg { width: 20px; height: 20px; }
.post-menu-btn:hover:not(:disabled),
.post-menu-btn-open { background: var(--post-soft); color: var(--ink); }
.post-menu-btn:disabled { cursor: default; }
.post-pop {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 6;
min-width: 210px;
padding: 6px;
color: var(--ink);
background: var(--glass);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
border: 1px solid var(--line-2);
border-radius: 14px;
box-shadow: var(--shadow-pop);
animation: post-pop .18s var(--ease) both;
}
.post-mi {
appearance: none;
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 40px;
margin: 0;
padding: 0 12px;
border: 0;
border-radius: var(--r-ctl);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 13.5px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.post-mi svg { width: 18px; height: 18px; flex: none; }
.post-mi:hover { background: var(--post-soft); }
.post-mi-danger { color: var(--danger); }
.post-ask { padding: 8px 6px 4px; }
.post-ask-title { margin: 0; padding: 0 4px; font-size: 13px; font-weight: 600; }
.post-ask-sub { margin: 2px 0 0; padding: 0 4px; font-size: 12.5px; color: var(--ink-3); }
.post-ask-row { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 12px; }
/* Inline comments. */
.post-cm {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0 -16px;
padding: 12px 16px 6px;
border-top: 1px solid var(--line);
animation: post-rise .35s var(--ease) both;
}
.post-cm-list { display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; }
.post-cm-empty { margin: 0; font-size: 13px; color: var(--ink-3); }
.post-c { display: flex; align-items: flex-start; gap: 10px; }
.post-c-body { flex: 1; min-width: 0; padding: 8px 12px; background: var(--tile-2); border-radius: 4px 14px 14px 14px; }
.post-c-head { display: flex; align-items: baseline; gap: 8px; }
.post-c-head strong { font-size: 13px; }
.post-c-head time { font-size: 12px; color: var(--ink-3); }
.post-c-body p { margin: 2px 0 0; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
.post-c-removed p { font-style: italic; color: var(--ink-3); }
.post-cm-form { display: flex; align-items: center; gap: 8px; }
.post-cm-form input {
flex: 1;
min-width: 0;
height: 44px;
margin: 0;
padding: 0 14px;
border: 0;
border-radius: var(--r-ctl);
background: var(--tile-2);
color: var(--ink);
font: inherit;
font-size: 14px;
box-shadow: inset 0 0 0 1px var(--line);
}
.post-cm-form input::placeholder { color: var(--ink-3); }
.post-cm-form input:focus-visible { outline: none; box-shadow: inset 0 0 0 1.5px var(--ink-2); }
.post-link {
appearance: none;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.post, .post-cm, .post-pop { animation: none; }
.post-like-beat .post-heart { animation: none; }
.post-play { transition: none; }
}
+74
View File
@@ -0,0 +1,74 @@
/* Find experts panel (ADR 0001, section 6; spec "Find experts"). Ported from
the mockup's .field, .chips, .rows and .rate rules; tokens only. The
category chips are SweepButton (buttons.css). */
.search-form label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.search-field {
display: flex;
align-items: center;
gap: 10px;
height: 44px;
padding: 0 14px;
border-radius: 12px;
background: var(--tile-2);
color: var(--ink-3);
box-shadow: inset 0 0 0 1px var(--line);
transition: box-shadow .25s, background-color .25s;
}
.search-field svg { width: 18px; height: 18px; flex: none; }
.search-field:focus-within {
background: var(--tile);
box-shadow: inset 0 0 0 1px var(--ink-3), 0 0 0 4px var(--line);
}
.search-field input,
.search-field input:focus-visible {
flex: 1;
min-width: 0;
width: auto;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 14px;
color: var(--ink);
}
.search-field input::placeholder { color: var(--ink-3); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
.search-rows { gap: 2px; }
.expert-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 14px;
transition: background-color .2s;
}
.expert-row:hover { background: var(--tile-2); }
.expert-text { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
.expert-text strong { font-size: 13.5px; color: var(--ink); }
.expert-text span { font-size: 12.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.expert-rate {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12.5px;
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.expert-rate svg { width: 13px; height: 13px; color: var(--accent); }
+156
View File
@@ -0,0 +1,156 @@
/*
* Bottom sheets (spec "Sheets"). Ported from the mockup's .psheet, .grab,
* .psheet-h and .ph-bg rules, plus the `.mx.sheet .cmp` composer rules. Tokens
* only. The sheet sits inside the phone feed (.reel-feed), over the dock.
*/
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }
@keyframes sheet-fade { from { opacity: 0; } to { opacity: 1; } }
.sheet-bg {
position: absolute;
inset: 0;
z-index: 55;
appearance: none;
border: 0;
padding: 0;
cursor: pointer;
background: color-mix(in srgb, var(--bg), transparent 50%);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
animation: sheet-fade .3s both;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 60;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 78%;
padding: 10px 14px max(20px, calc(env(safe-area-inset-bottom, 0px) + 12px));
background: var(--bg);
color: var(--ink);
border-radius: 26px 26px 0 0;
box-shadow: 0 -20px 50px -10px color-mix(in srgb, var(--neu-d), transparent 45%), inset 0 1px 0 var(--line);
animation: sheet-up .45s cubic-bezier(.2,.9,.25,1) both;
outline: none;
}
.sheet-grab {
flex: none;
width: 40px;
height: 5px;
margin: 0 auto 4px;
border-radius: 3px;
background: var(--line-2);
}
.sheet-h { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.sheet-h h2 { margin: 0; font-size: 25px; line-height: 1.2; }
.sheet-x {
appearance: none;
flex: none;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--r-ctl);
display: grid;
place-items: center;
background: transparent;
color: var(--ink-2);
cursor: pointer;
transition: background-color .2s, color .2s;
}
.sheet-x svg { width: 20px; height: 20px; }
@media (hover: hover) { .sheet-x:hover { background: var(--tile-2); color: var(--ink); } }
.sheet-x:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.sheet-body { min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
/* Comments sheet: the shared comments panel without its inline-card chrome. */
.sheet-comments .post-cm { margin: 0; padding: 0; border-top: 0; animation: none; flex: 1; min-height: 0; }
.sheet-comments .post-cm-list { overflow-y: auto; min-height: 60px; }
.sheet-comments .post-cm-form { flex: none; }
.sheet-comments .post-cm-form input { font-size: 16px; }
/* Composer sheet: the desktop composer, borderless, with a 150px text area. */
.sheet-composer { max-height: 92%; }
.sheet-composer .sheet-body { overflow: visible; }
.sheet-composer .composer,
.sheet-composer .composer.composer-open { margin: 0; padding: 0; background: transparent; box-shadow: none; }
.sheet-composer .composer textarea,
.sheet-composer .composer.composer-open textarea { height: 150px; font-size: 16px; }
.sheet-composer .composer-bar { flex-wrap: wrap; }
.sheet-composer .composer-kbd { display: none; }
/* The popover anchors to the whole composer (not the emoji button) so it fits a phone: full width, just above the toolbar. */
.sheet-composer .composer-emoji-wrap { position: static; }
.sheet-composer .composer-emoji { left: 0; right: 0; width: auto; top: auto; bottom: 64px; }
.sheet-composer .composer-att.composer-att-indent { margin-left: 0; }
/* Discover sheet (More): soft depth rows, then the Appearance switch. */
.sheet-discover { gap: 14px; }
.sheet-discover .sheet-body { gap: 16px; padding: 4px 4px 4px; overflow-y: auto; }
.sheet-discover .btn-depth { position: relative; overflow: hidden; isolation: isolate; min-height: 72px; }
.sheet-discover .btn-depth-chev { width: 18px; height: 18px; flex: none; color: var(--ink-3); transition: color .3s; }
.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; }
/* 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 {
display: flex;
gap: 4px;
padding: 4px;
border-radius: var(--r-card);
background: var(--bg);
box-shadow: inset 4px 4px 10px var(--neu-d), inset -4px -4px 10px var(--neu-l);
}
.seg-depth button {
appearance: none;
border: 0;
cursor: pointer;
flex: 1;
height: 44px;
border-radius: var(--r-ctl);
background: transparent;
color: var(--ink-3);
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background-color .25s, color .25s, box-shadow .25s;
}
.seg-depth button svg { width: 18px; height: 18px; }
.seg-depth button.on {
background: var(--bg);
color: var(--ink);
box-shadow: 4px 4px 10px var(--neu-d), -4px -4px 10px var(--neu-l);
}
.seg-depth button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* Panel sheets: 80% height, the desktop panels without their tile chrome or repeated heading. */
.sheet-panel { height: 80%; max-height: 80%; }
.sheet-panel .sheet-body { flex: 1; overflow: hidden; }
.sheet-panel .sheet-body > .tile {
flex: 1;
min-height: 0;
margin: 0;
padding: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
}
.sheet-panel .sheet-body > .tile > .panel-head { display: none; }
.sheet-panel .search-results,
.sheet-panel .trend-list { flex: 1 1 0; min-height: 0; overflow-y: auto; }
.sheet-panel .search-field input { font-size: 16px; }
.sheet-panel-featured .sheet-body { overflow-y: auto; align-items: center; justify-content: center; }
@media (prefers-reduced-motion: reduce) {
.sheet, .sheet-bg { animation: none !important; }
}
+103
View File
@@ -0,0 +1,103 @@
/* Sign-in page (spec "Sign in", ADR 0001). Outside .app: one centered column, no breakpoints.
Tokens only, so it follows the active theme (dark by default). */
.signin {
position: relative;
min-height: 100vh;
overflow: hidden;
display: flex;
justify-content: center;
padding: 28px 24px;
background: var(--bg);
color: var(--ink);
}
.signin-glow {
position: absolute;
left: 50%;
top: -220px;
width: 560px;
height: 420px;
transform: translateX(-50%);
border-radius: 50%;
background: radial-gradient(ellipse, color-mix(in srgb, var(--accent), transparent 84%), transparent 65%);
pointer-events: none;
}
.signin-col {
position: relative;
width: 100%;
max-width: 342px;
display: flex;
flex-direction: column;
}
.signin-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding-top: 40px;
font-size: 15px;
}
.signin-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
text-align: center;
margin: 72px 0 40px;
}
.signin-title {
margin: 0;
font-family: var(--font-serif), Georgia, serif;
font-size: 40px;
font-weight: 600;
line-height: 1.05;
color: var(--ink);
}
.signin-title em { font-style: italic; font-weight: 500; }
.signin-rule {
width: 48px;
height: 1px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--ink), transparent 40%), transparent);
}
.signin-form { display: flex; flex-direction: column; gap: 12px; }
.signin-label {
margin: 0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--ink-3);
}
.signin-label-next { margin-top: 6px; }
.signin-field {
display: flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 16px;
border-radius: var(--r-ctl);
background: var(--tile-2);
color: var(--ink-3);
box-shadow: inset 0 0 0 1px var(--line-2);
transition: box-shadow .25s;
}
.signin-field:focus-within {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink), transparent 45%), 0 0 0 4px color-mix(in srgb, var(--ink), transparent 94%);
}
.signin-field svg { width: 18px; height: 18px; flex: none; }
.signin-field input {
flex: 1;
min-width: 0;
height: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 15px;
color: var(--ink);
}
.signin-field input::placeholder { color: var(--ink-3); }
.signin-form .signin-submit { margin-top: 18px; width: 100%; height: 54px; }
.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; }
+103
View File
@@ -0,0 +1,103 @@
/* Trending minnects panel (ADR 0001, section 6; spec "Trending minnects").
Ported from the mockup's .tr rules; tokens only. The white inside the media
frame is text and glass over the photo, which is dark in both themes. */
.trend-list { gap: 16px; }
.minnect-card { display: flex; flex-direction: column; gap: 9px; animation: trend-rise .55s var(--ease) both; }
@keyframes trend-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.trend-media {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 12px;
overflow: hidden;
isolation: isolate;
background: var(--tile-3);
box-shadow: inset 0 0 0 1px var(--line);
}
.trend-media .minnect-thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 1s var(--ease);
}
.minnect-card:hover .trend-media:not(.trend-media-playing) .minnect-thumb { transform: scale(1.035); }
.minnect-video { background: black; }
.trend-scrim {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 62%;
z-index: 1;
pointer-events: none;
background: linear-gradient(180deg, transparent, color-mix(in srgb, black 62%, transparent));
}
.trend-who {
position: absolute;
left: 10px;
top: 10px;
z-index: 2;
max-width: calc(100% - 20px);
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px 3px 4px;
border-radius: 12px;
font-size: 12px;
color: var(--on-accent);
background: color-mix(in srgb, black 34%, transparent);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
pointer-events: none;
}
.trend-who strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
.trend-play {
position: absolute;
inset: 0;
margin: auto;
z-index: 2;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
color: var(--on-accent);
background: color-mix(in srgb, var(--on-accent) calc(100% - 84%), transparent);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--on-accent) calc(100% - 60%), transparent), 0 12px 30px color-mix(in srgb, black 28%, transparent);
transition: transform .3s cubic-bezier(.2, .9, .3, 1.4), background-color .3s;
}
.trend-play svg { width: 18px; height: 18px; margin-left: 2px; }
.trend-play:hover { transform: scale(1.1); background: color-mix(in srgb, var(--on-accent) calc(100% - 72%), transparent); }
.trend-play:focus-visible { outline: 2px solid var(--on-accent); outline-offset: 2px; }
.trend-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.trend-views { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--ink-2); }
.trend-views svg { width: 15px; height: 15px; }
.trend-kind { font-size: 10.5px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); }
/* While a video plays its own controls take over the frame. */
.trend-media-playing .trend-scrim,
.trend-media-playing .trend-who,
.trend-media-playing .trend-play { opacity: 0; visibility: hidden; }
.trend-q {
margin: 0;
font-size: 13.5px;
line-height: 1.45;
font-weight: 600;
color: var(--ink);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.minnect-audio { width: 100%; height: 36px; }
.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
+39
View File
@@ -0,0 +1,39 @@
/* Reusable type classes (spec "Typography", ADR 0001 section 4).
Manrope is the body font on <body>. Cormorant Garamond is for titles only. */
/* Wordmark: the text MINNECT. */
.type-wordmark {
font-family: var(--font-sans), system-ui, sans-serif;
font-weight: 800;
letter-spacing: 0.26em;
}
/* Eyebrow label. */
.type-eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--ink-3);
}
/* Serif title: feed title, panel titles, featured expert names, sheet titles,
text-only post slides on mobile. Add .type-title-italic for the italic 500 cut. */
.type-title {
font-family: var(--font-serif), Georgia, serif;
font-weight: 500;
}
.type-title-strong { font-weight: 600; }
.type-title-italic { font-style: italic; font-weight: 500; }
/* Post body. */
.type-post-body {
font-size: 15px;
line-height: 1.7;
}
/* Meta text (times, counts). */
.type-meta {
font-size: 12.5px;
color: var(--ink-3);
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { getSessionMuted, isAutoplayBlocked, setSessionMuted } from "@/lib/autoplay";
import { VideoPlayer } from "@/components/VideoPlayer";
describe("session mute choice", () => {
it("starts muted and keeps the person's choice", () => {
expect(getSessionMuted()).toBe(true);
setSessionMuted(false);
expect(getSessionMuted()).toBe(false);
setSessionMuted(true);
expect(getSessionMuted()).toBe(true);
});
});
describe("autoplay refusal", () => {
it("only counts the browser's NotAllowedError", () => {
expect(isAutoplayBlocked({ name: "NotAllowedError" })).toBe(true);
expect(isAutoplayBlocked({ name: "AbortError" })).toBe(false);
expect(isAutoplayBlocked(null)).toBe(false);
expect(isAutoplayBlocked("NotAllowedError")).toBe(false);
});
});
describe("VideoPlayer", () => {
it("is inline and not muted unless asked (desktop unchanged)", () => {
const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v" }));
expect(html).toContain("playsInline");
expect(html).toContain("controls");
expect(html).not.toContain("muted");
});
it("renders muted inline video for the mobile autoplay", () => {
const html = renderToStaticMarkup(createElement(VideoPlayer, { mp4Url: "https://cdn/v.mp4", poster: null, label: "v", controls: false, muted: true }));
expect(html).toContain("muted");
expect(html).not.toContain("controls");
});
});
describe("mobile autoplay styles", () => {
it("has a frosted mute button", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
expect(css).toMatch(/\.slide-mute \{[^}]*backdrop-filter/);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { createElement, type FC } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { BottomSheet } from "@/components/BottomSheet";
const css = readFileSync("src/styles/sheet.css", "utf8");
const feed = readFileSync("src/components/MobileFeed.tsx", "utf8");
describe("BottomSheet", () => {
const html = renderToStaticMarkup(createElement(BottomSheet as FC<{ title: string; onClose: () => void }>, { title: "New post", onClose: () => {} }, createElement("p", null, "body")));
it("is a modal dialog named by its serif title, with a grab handle, close buttons and a backdrop", () => {
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toMatch(/aria-labelledby="([^"]+)"[\s\S]*<h2 id="\1"[^>]*type-title[^>]*>New post<\/h2>/);
expect(html).toContain("sheet-grab");
expect(html).toContain('class="sheet-bg"');
expect(html.match(/aria-label="Close"/g)?.length).toBe(2);
expect(html).toContain("<p>body</p>");
});
it("slides up over 450ms and turns the motion off under reduced motion", () => {
expect(css).toMatch(/animation: sheet-up \.45s/);
expect(css).toMatch(/prefers-reduced-motion: reduce[\s\S]*animation: none/);
expect(css).toMatch(/backdrop-filter: blur\(3px\)/);
});
it("gives the composer sheet a 150px text area", () => {
expect(css).toMatch(/\.sheet-composer \.composer textarea[\s\S]*height: 150px/);
});
});
describe("phone feed sheets", () => {
it("opens the shared comments section and composer from the dock", () => {
expect(feed).toContain('<BottomSheet title="Comments"');
expect(feed).toContain("<CommentsSection postId={sheet.postId}");
expect(feed).toContain('<BottomSheet title="New post"');
expect(feed).toContain("<Composer {...p.composer} />");
});
});
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
DepthButton,
FlipButton,
GrowButton,
IconButton,
SendIcon,
SparkleButton,
SweepButton,
flipLabels,
} from "@/components/Buttons";
import { ButtonGallery } from "@/components/ButtonGallery";
const html = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el);
const css = readFileSync(path.resolve(__dirname, "../src/styles/buttons.css"), "utf8");
const globals = readFileSync(path.resolve(__dirname, "../src/app/globals.css"), "utf8");
/** Body of the first rule whose selector list matches exactly. */
function rule(selector: string): string {
const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const m = css.match(new RegExp(`(?:^|\\n|,)\\s*${esc}\\s*\\{([^}]*)\\}`));
if (!m) throw new Error(`no rule for ${selector}`);
return m[1];
}
describe("SparkleButton disabled", () => {
it("keeps full opacity and a solid readable face", () => {
const body = rule(".btn-spark:disabled");
expect(body).toMatch(/opacity:\s*1\b/);
expect(body).toContain("var(--tile-3)");
expect(body).toContain("var(--ink-2)");
expect(body).toContain("not-allowed");
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 });
const out = html(createElement(FlipButton, { state: "ready" }));
expect(out).toContain('class="btn-flip"');
expect(out).not.toContain("disabled");
expect(out.match(/<svg/g)).toHaveLength(2);
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Publish/);
});
it("disabled: native disabled, flips to WRITE FIRST, no icon", () => {
const out = html(createElement(FlipButton, { state: "disabled" }));
expect(out).toContain('disabled=""');
expect(out).toMatch(/btn-flip-front">Post</);
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Write first</);
expect(out).not.toContain("<svg");
});
it("posting: POSTING, busy, no flip panel", () => {
const out = html(createElement(FlipButton, { state: "posting" }));
expect(out).toContain("btn-flip btn-flip-busy");
expect(out).toContain('aria-busy="true"');
expect(out).toMatch(/btn-flip-front">Posting</);
expect(out).not.toContain("btn-flip-back");
});
it("the disabled face is solid graphite and never faded", () => {
const disabled = rule(".btn-flip:disabled");
expect(disabled).toMatch(/opacity:\s*1/);
expect(disabled).toMatch(/background:\s*var\(--tile-3\)/);
expect(disabled).toMatch(/color:\s*var\(--ink-2\)/);
expect(css).not.toMatch(/\.btn-flip[^{]*\{[^}]*opacity:\s*0?\.\d/);
});
it("sets text color explicitly for every state, never by inheritance", () => {
expect(rule(".btn-flip")).toMatch(/color:\s*var\(--bg\)/);
expect(rule(".btn-flip .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
expect(rule(".btn-flip .btn-flip-back")).toMatch(/color:\s*var\(--on-accent\)/);
expect(rule(".btn-flip:disabled .btn-flip-front")).toMatch(/color:\s*var\(--ink-2\)/);
expect(rule(".btn-flip:disabled .btn-flip-back")).toMatch(/color:\s*var\(--ink\)/);
expect(rule(".btn-flip.btn-flip-busy:disabled .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
// Face and text never share a token in any state.
expect(rule(".btn-flip")).toMatch(/background:\s*var\(--ink\)/);
expect(rule(".btn-flip.btn-flip-busy:disabled")).toMatch(/background:\s*var\(--ink\)/);
});
});
describe("the other styles", () => {
it("GrowButton renders the crimson circle and the label", () => {
const out = html(createElement(GrowButton, { label: "3 new posts" }));
expect(out).toContain('class="btn-grow"');
expect(out).toContain("btn-grow-circle");
expect(out).toMatch(/btn-grow-text">3 new posts</);
});
it("SweepButton stays filled and pressed when selected", () => {
expect(html(createElement(SweepButton, { label: "Business", selected: true }))).toContain('class="btn-sweep btn-sweep-on" aria-pressed="true"');
expect(html(createElement(SweepButton, { label: "Media", selected: false }))).toContain('aria-pressed="false"');
expect(html(createElement(SweepButton, { label: "Cancel" }))).not.toContain("aria-pressed");
});
it("DepthButton renders label, sublabel and icon", () => {
const out = html(createElement(DepthButton, { label: "Trending", sublabel: "Hot now", icon: createElement(SendIcon) }));
expect(out).toContain("btn-depth-icon");
expect(out).toMatch(/btn-depth-label">Trending</);
expect(out).toMatch(/btn-depth-sub">Hot now</);
});
it("SparkleButton: sign in has the fold, hexagon and ten sparks; fab has a plus and a name", () => {
const signin = html(createElement(SparkleButton, { label: "Sign in", type: "submit" }));
expect(signin).toContain("btn-spark-fold");
expect(signin).toContain("btn-spark-hex");
expect(signin.match(/<i><\/i>/g)).toHaveLength(10);
expect(signin).toContain('type="submit"');
const fab = html(createElement(SparkleButton, { variant: "fab", label: "New post" }));
expect(fab).toContain('aria-label="New post"');
expect(fab).toContain("btn-spark btn-spark-fab");
expect(fab).not.toContain("btn-spark-fold");
expect(fab).toContain("btn-spark-plus");
});
it("IconButton is the glossy crimson button with an accessible name", () => {
const out = html(createElement(IconButton, { label: "Send comment", icon: createElement(SendIcon), shape: "round", size: "sm" }));
expect(out).toContain('class="btn-gloss btn-gloss-round btn-gloss-sm"');
expect(out).toContain('aria-label="Send comment"');
expect(rule(".btn-gloss:disabled")).toMatch(/opacity:\s*1/);
});
});
describe("button CSS rules", () => {
it("uses only .btn-* class selectors", () => {
const selectors = css.replace(/\/\*[\s\S]*?\*\//g, "").match(/\.[a-zA-Z][\w-]*/g) ?? [];
expect(selectors.filter((s) => !s.startsWith(".btn-"))).toEqual([]);
});
it("does not include the rejected liquid waves or 3D drop buttons", () => {
expect(css).not.toMatch(/wave|wv/i);
});
it("stops hover and press motion under prefers-reduced-motion", () => {
const block = css.slice(css.indexOf("@media (prefers-reduced-motion: reduce)"));
expect(block.length).toBeGreaterThan(0);
for (const cls of ["btn-flip", "btn-grow", "btn-sweep", "btn-depth", "btn-spark", "btn-gloss"]) {
expect(block).toContain(`.${cls}`);
}
expect(block).toMatch(/transition:\s*none !important/);
expect(block).toMatch(/animation:\s*none !important/);
expect(block).toMatch(/transform:\s*none !important/);
});
it("every theme defines the button tokens", () => {
for (const token of ["--shadow-btn", "--shadow-float", "--neu-d", "--neu-l"]) {
expect(globals.match(new RegExp(`${token}:`, "g"))).toHaveLength(2);
}
expect(globals).toContain("--on-accent:");
});
});
describe("ButtonGallery story", () => {
it("shows every style in both themes", () => {
for (const theme of ["dark", "light"] as const) {
const out = html(createElement(ButtonGallery, { theme }));
expect(out).toContain(`data-theme="${theme}"`);
for (const cls of ["btn-flip", "btn-flip-busy", "btn-grow", "btn-sweep-on", "btn-depth", "btn-spark-fold", "btn-spark-fab", "btn-gloss"]) {
expect(out).toContain(cls);
}
expect(out).toMatch(/Write first/);
expect(out).toMatch(/Posting/);
}
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { Composer, isPublishShortcut } from "@/components/Composer";
import { attachmentMeta, BULLET, COMPOSER_EMOJI, formatBytes, insertBullets } from "@/components/composer-text";
describe("insertBullets", () => {
it("adds a bullet at the start of the caret's line", () => {
expect(insertBullets("hello", 3, 3)).toEqual({ text: "• hello", selectionStart: 5, selectionEnd: 5 });
expect(insertBullets("one\ntwo\nthree", 5, 5).text).toBe("one\n• two\nthree");
});
it("adds a bullet to an empty text and to an empty last line", () => {
expect(insertBullets("", 0, 0)).toEqual({ text: BULLET, selectionStart: 2, selectionEnd: 2 });
expect(insertBullets("a\n", 2, 2).text).toBe("a\n• ");
});
it("adds a bullet to every selected line and keeps the selection on the same characters", () => {
const r = insertBullets("one\ntwo\nthree", 1, 9);
expect(r.text).toBe("• one\n• two\n• three");
expect(r.text.slice(r.selectionStart, r.selectionEnd)).toBe("ne\n• two\n• t");
});
it("does not bullet the next line when the selection ends right after a line break", () => {
expect(insertBullets("one\ntwo", 0, 4).text).toBe("• one\ntwo");
});
it("leaves lines that already start with a bullet unchanged", () => {
expect(insertBullets("• one\ntwo", 0, 9).text).toBe("• one\n• two");
expect(insertBullets("• one", 3, 3).text).toBe("• one");
});
it("changes nothing but the inserted characters", () => {
const text = "**bold** _it_\n indented";
const r = insertBullets(text, 0, text.length);
expect(r.text.replaceAll(BULLET, "")).toBe(text);
});
});
describe("composer helpers", () => {
it("offers 32 distinct emoji", () => {
expect(COMPOSER_EMOJI).toHaveLength(32);
expect(new Set(COMPOSER_EMOJI).size).toBe(32);
});
it("describes an attachment by type and size", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(2048)).toBe("2 KB");
expect(attachmentMeta({ type: "image/png", size: 2.4 * 1024 * 1024 })).toBe("Image, 2.4 MB");
expect(attachmentMeta({ type: "video/mp4", size: 18.2 * 1024 * 1024 })).toBe("Video, 18.2 MB");
});
it("publishes on Ctrl + Enter and Cmd + Enter only", () => {
expect(isPublishShortcut({ key: "Enter", ctrlKey: true, metaKey: false })).toBe(true);
expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: true })).toBe(true);
expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: false })).toBe(false);
expect(isPublishShortcut({ key: "a", ctrlKey: true, metaKey: false })).toBe(false);
});
});
describe("Composer markup", () => {
const base = { value: "", busy: false, error: false, onChange: () => {}, onSubmit: () => {} };
const html = (p: Partial<Parameters<typeof Composer>[0]>) => renderToStaticMarkup(createElement(Composer, { ...base, ...p }));
it("is collapsed when empty and open when holding text or a file", () => {
expect(html({})).toMatch(/class="composer"/);
expect(html({ value: "hi" })).toContain("composer composer-open");
const f = new File(["x"], "a.png", { type: "image/png" });
expect(html({ file: f, onFile: () => {} })).toContain("composer composer-open");
});
it("orders the toolbar: photo, video, emoji, divider, bulleted list, hint, Post", () => {
const out = html({ onFile: () => {} });
const order = ["Add image", "Add video", "Add emoji", "composer-sep", "Bulleted list", "composer-kbd", "btn-flip"];
const at = order.map((s) => out.indexOf(s));
expect(at.every((i) => i >= 0)).toBe(true);
expect([...at].sort((a, b) => a - b)).toEqual(at);
expect(out).not.toMatch(/aria-label="(Bold|Italic)"/);
});
it("shows the three Post states", () => {
expect(html({})).toMatch(/btn-flip-front">Post<.*btn-flip-back[^>]*>Write first/);
expect(html({ value: "hi" })).toMatch(/btn-flip-back[^>]*>Publish<svg/);
const busy = html({ value: "hi", busy: true });
expect(busy).toContain("btn-flip-busy");
expect(busy).toContain(">Posting<");
});
it("shows the attachment row with name, type, size, progress and remove", () => {
const f = new File([new Uint8Array(3 * 1024)], "clip.mp4", { type: "video/mp4" });
const out = html({ file: f, onFile: () => {}, person: { name: "Jordan Rivera" } });
expect(out).toContain("clip.mp4");
expect(out).toContain("Video, 3 KB");
expect(out).toContain('role="progressbar"');
expect(out).toContain('aria-label="Remove attachment"');
expect(out).toContain("composer-att-indent");
expect(out).toContain("hex-avatar");
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
/** Every .css file under src/ (src/styles, src/app/globals.css, and any other). */
function cssFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return cssFiles(full);
return full.endsWith(".css") ? [full] : [];
});
}
/** Rules whose selector targets a disabled state and whose opacity is below 1. */
function fadedDisabledRules(source: string): string[] {
const text = source.replace(/\/\*[\s\S]*?\*\//g, "");
const found: string[] = [];
// Innermost blocks only: the selector text runs back to the previous brace,
// so rules nested inside @media or @supports are scanned too.
for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selector = m[1].trim();
if (!/:disabled|\[disabled\]|\[aria-disabled(="true")?\]/.test(selector)) continue;
for (const o of m[2].matchAll(/(?:^|;|\s)opacity\s*:\s*([\d.]+)(%?)/g)) {
const value = parseFloat(o[1]) / (o[2] ? 100 : 1);
if (value < 1) found.push(`${selector} { opacity: ${o[1]}${o[2]} }`);
}
}
return found;
}
describe("disabled buttons are never faded", () => {
const root = path.resolve(__dirname, "../src");
const files = cssFiles(root);
it("finds the stylesheets, globals.css included", () => {
expect(files.some((f) => f.endsWith(path.join("app", "globals.css")))).toBe(true);
expect(files.some((f) => f.endsWith(path.join("styles", "signin.css")))).toBe(true);
});
it("the scanner catches a faded disabled rule", () => {
expect(fadedDisabledRules(".a:disabled { opacity: .5; }")).toHaveLength(1);
expect(fadedDisabledRules("@media (x) { .a[disabled] { color: red; opacity: 40% } }")).toHaveLength(1);
expect(fadedDisabledRules(".a:disabled { opacity: 1; }")).toHaveLength(0);
expect(fadedDisabledRules(".a:hover { opacity: .5; }")).toHaveLength(0);
});
for (const file of files) {
it(`${path.relative(root, file)} has no :disabled rule with opacity below 1`, () => {
expect(fadedDisabledRules(readFileSync(file, "utf8"))).toEqual([]);
});
}
});
+9 -7
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { fetchDiscover } from "@/lib/discover-client";
@@ -18,9 +18,9 @@ const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
});
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
describe("FeaturedExpertsRail", () => {
it("renders a card with name, byline, rating and reviews", () => {
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
describe("FeaturedCarousel (static render)", () => {
it("renders the center expert with name, byline, rating and reviews", () => {
const out = html(createElement(FeaturedCarousel, { experts: [expert()] }));
expect(out).toContain("<h2");
expect(out).toContain("Ann Lee");
expect(out).toContain("Growth coach");
@@ -29,7 +29,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).toContain("<img");
});
it("falls back to initials and omits missing fields", () => {
const out = html(createElement(FeaturedExpertsRail, {
const out = html(createElement(FeaturedCarousel, {
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
}));
expect(out).not.toContain("<img");
@@ -37,7 +37,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).not.toContain("review");
});
it("renders nothing when empty", () => {
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
});
});
@@ -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"');
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { DiscoverPanelSheet, DiscoverSheet, featuredSubtitle, PANEL_CAROUSEL_SIZE } from "@/components/DiscoverSheet";
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
const experts = FIXTURE_DISCOVER.featuredExperts;
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
describe("featuredSubtitle", () => {
it("names two experts and counts the rest", () => {
const list = [{ displayName: "A" }, { displayName: "B" }, { displayName: "C" }, { displayName: "D" }];
expect(featuredSubtitle(list)).toBe("A, B and 2 more");
expect(featuredSubtitle(list.slice(0, 3))).toBe("A, B and 1 more");
});
it("reads plainly for short lists", () => {
expect(featuredSubtitle([])).toBe("");
expect(featuredSubtitle([{ displayName: "A" }])).toBe("A");
expect(featuredSubtitle([{ displayName: "A" }, { displayName: "B" }])).toBe("A and B");
});
});
describe("Discover sheet", () => {
const out = html(createElement(DiscoverSheet, { experts, onOpen: () => {}, onClose: () => {} }));
it("has three depth rows and the appearance switch", () => {
expect(out).toContain("Discover");
expect((out.match(/btn-depth"/g) ?? []).length).toBe(3);
expect(out).toContain("Featured experts");
expect(out).toContain("Trending minnects");
expect(out).toContain("Find experts");
expect(out).toContain('aria-label="Appearance"');
expect(out).toContain("Dark");
expect(out).toContain("Light");
expect(out).toContain(featuredSubtitle(experts));
});
});
describe("Discover panel sheets", () => {
it("shows the featured carousel at 150px", () => {
expect(PANEL_CAROUSEL_SIZE).toBe(150);
const out = html(createElement(DiscoverPanelSheet, { panel: "featured", experts, trending: [], onClose: () => {} }));
expect(out).toContain("--cfw:150px");
expect(out).toContain('aria-label="Close"');
});
it("is 80% high", () => {
const css = readFileSync("src/styles/sheet.css", "utf8");
expect(css).toMatch(/\.sheet-panel\s*\{[^}]*height:\s*80%/);
});
});
+1 -1
View File
@@ -91,7 +91,7 @@ describe("fetchExperts", () => {
const view = (o: Partial<ExpertSearchViewProps> = {}) =>
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", () => {
+109
View File
@@ -0,0 +1,109 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
import { FEATURED_INTERVAL_MS, featuredLiveMode, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
import type { DiscoverExpert } from "@/lib/discover";
const expert = (i: number): DiscoverExpert => ({
id: i, slug: `e${i}`, displayName: `Expert ${i}`, byline: `Byline ${i}`, pictureUrl: i % 2 ? `https://x/${i}.jpg` : null,
rating: 4.5, reviewsCount: i, available: true, location: { city: null, country: null },
});
const experts = (n: number) => Array.from({ length: n }, (_, i) => expert(i + 1));
describe("featuredSlot", () => {
it("places items by offset from the current index, wrapping round", () => {
const slots = Array.from({ length: 8 }, (_, i) => featuredSlot(i, 0, 8));
expect(slots).toEqual(["c0", "r1", "r2", "r3", "r3", "l3", "l2", "l1"]);
expect(featuredSlot(0, 7, 8)).toBe("r1");
expect(featuredSlot(6, 7, 8)).toBe("l1");
});
it("degrades with one or two experts: no empty neighbors", () => {
expect(featuredSlot(0, 0, 1)).toBe("c0");
expect([featuredSlot(0, 0, 2), featuredSlot(1, 0, 2)]).toEqual(["c0", "r1"]);
expect([featuredSlot(0, 1, 2), featuredSlot(1, 1, 2)]).toEqual(["r1", "c0"]);
expect([0, 1, 2].map((i) => featuredSlot(i, 0, 3))).toEqual(["c0", "r1", "l1"]);
});
});
describe("startFeaturedAutoplay", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("advances every 2.8s and wraps to the start", () => {
const seen: number[] = [];
const ctl = startFeaturedAutoplay({ count: 3, onChange: (i) => seen.push(i) });
expect(FEATURED_INTERVAL_MS).toBe(2800);
vi.advanceTimersByTime(2799);
expect(seen).toEqual([]);
vi.advanceTimersByTime(1);
expect(seen).toEqual([1]);
vi.advanceTimersByTime(2800 * 2);
expect(seen).toEqual([1, 2, 0]);
expect(ctl.index).toBe(0);
ctl.stop();
});
it("does not advance while paused and resumes after", () => {
const onChange = vi.fn();
const ctl = startFeaturedAutoplay({ count: 5, onChange });
ctl.pause();
expect(ctl.paused).toBe(true);
vi.advanceTimersByTime(4000 * 3);
expect(onChange).not.toHaveBeenCalled();
expect(ctl.index).toBe(0);
ctl.resume();
vi.advanceTimersByTime(2800);
expect(onChange).toHaveBeenCalledWith(1);
ctl.stop();
});
it("never rotates a single expert and stops cleanly", () => {
const one = vi.fn();
startFeaturedAutoplay({ count: 1, onChange: one });
vi.advanceTimersByTime(10_000);
expect(one).not.toHaveBeenCalled();
const many = vi.fn();
const ctl = startFeaturedAutoplay({ count: 4, onChange: many });
ctl.stop();
vi.advanceTimersByTime(10_000);
expect(many).not.toHaveBeenCalled();
});
});
describe("FeaturedCarousel markup", () => {
it("starts with the live region off while it rotates, and hides non-center items and shows the counter and dots", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
expect(out).toContain('aria-live="off"');
expect(out.match(/data-testid="featured-item"/g)).toHaveLength(8);
expect(out.match(/aria-hidden="true" data-testid="featured-item"/g)).toHaveLength(7);
expect(out).toContain("featured-c0");
expect(out).toContain(">1 / 8<");
expect(out.match(/class="featured-dot( featured-dot-on)?"/g)).toHaveLength(8);
expect(out.match(/featured-dot-on/g)).toHaveLength(1);
expect(out).toContain("--cfw:96px");
});
it("takes a size prop for mobile", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(3), size: 156 }));
expect(out).toContain("--cfw:156px");
});
it("shows no dots for a single expert", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
expect(out).not.toContain("featured-dot ");
expect(out).toContain(">1 / 1<");
expect(out).toContain('aria-live="polite"');
});
});
describe("featuredLiveMode (WAI-ARIA carousel live region)", () => {
it("is off while autoplay rotates", () => {
expect(featuredLiveMode({ rotating: true, paused: false })).toBe("off");
});
it("is polite while paused by hover or focus", () => {
expect(featuredLiveMode({ rotating: true, paused: true })).toBe("polite");
});
it("is polite when rotation is stopped (reduced motion or one expert)", () => {
expect(featuredLiveMode({ rotating: false, paused: false })).toBe("polite");
expect(featuredLiveMode({ rotating: false, paused: true })).toBe("polite");
});
});
+86 -4
View File
@@ -1,9 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { FeedView, RefreshButton, type FeedViewProps } from "@/components/FeedView";
import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client";
import { mergePosts, relativeTime, toCardPosts, upgradeVideos } from "@/lib/feed-model";
import { commentsLabel, firstLoadFailure, formatDuration, mergePosts, relativeTime, toCardPost, toCardPosts, upgradeVideos } from "@/lib/feed-model";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const post = (id: string, publishedAt: string, extra: Record<string, unknown> = {}) => ({
@@ -115,9 +115,11 @@ describe("FeedView", () => {
expect(html).toContain("Try again");
});
it("spinner while loading more and a new-posts control", () => {
it("skeleton card while loading more and the new-posts pill", () => {
const html = render({ posts: toCardPosts([post("1", "2026-10-19T11:00:00Z")]), loadingMore: true, newCount: 2 });
expect(html).toContain('class="spinner"');
expect(html).toContain('data-testid="feed-skeleton"');
expect(html).not.toContain('class="spinner"');
expect(html).toContain("btn-grow");
expect(html).toContain("2 new posts");
expect(html).not.toContain("Load more");
});
@@ -185,3 +187,83 @@ describe("video transcoding", () => {
expect(live).toContain("<video");
});
});
describe("video duration", () => {
it("reads videoDuration (seconds) first and formats it as m:ss", () => {
const [m] = toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", videoDuration: 154, duration: 9 }] } })!.media;
expect(m.durationSeconds).toBe(154);
expect(formatDuration(154)).toBe("2:34");
expect(formatDuration(5)).toBe("0:05");
expect(formatDuration(3725)).toBe("1:02:05");
expect(formatDuration(0)).toBeNull();
expect(formatDuration(undefined)).toBeNull();
});
it("falls back to durationSeconds, then duration, else none", () => {
const dur = (item: object) => toCardPost({ type: "post", post: { id: "v", author: {}, mediaItems: [{ mediaType: "video", ...item }] } })!.media[0].durationSeconds;
expect(dur({ durationSeconds: 30 })).toBe(30);
expect(dur({ duration: 12 })).toBe(12);
expect(dur({})).toBeUndefined();
});
});
describe("refresh button", () => {
it("is in the Home feed header with its accessible name", () => {
const html = render({ onRefresh: () => {} });
expect(html).toContain('aria-label="Refresh feed"');
expect(html.indexOf("Home feed")).toBeLessThan(html.indexOf("Refresh feed"));
});
it("is absent without a handler", () => {
expect(render({})).not.toContain("Refresh feed");
});
it("triggers a reload when clicked", () => {
const onRefresh = vi.fn();
const el = RefreshButton({ onRefresh }) as { props: { onClick: () => void } };
el.props.onClick();
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("is disabled and spinning while a reload runs", () => {
const html = render({ onRefresh: () => {}, refreshing: true });
expect(html).toMatch(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*aria-label="Refresh feed"[^>]*disabled/);
expect(html).toContain("refresh-icon-spin");
});
});
describe("comments label", () => {
it("is singular for 1 and plural otherwise", () => {
expect(commentsLabel(1)).toBe("1 comment");
expect(commentsLabel(0)).toBe("0 comments");
expect(commentsLabel(2)).toBe("2 comments");
});
it("renders singular on a one-comment post", () => {
const posts = toCardPosts([{ ...post("1", "2026-10-19T11:00:00Z"), commentsCount: 1 }]);
const html = render({ posts });
expect(html).toContain("1 comment<");
expect(html).not.toContain("1 comments");
});
});
describe("failed refresh versus failed first load", () => {
it("a failed refresh keeps the posts and shows an inline error with a usable button", () => {
const posts = toCardPosts([post("1", "2026-10-19T11:00:00Z")]);
const html = render({ posts, onRefresh: () => {}, refreshFailed: true });
expect(html).toContain("Could not refresh. Try again.");
expect(html).toContain("text 1");
expect(html).not.toContain("The feed did not load.");
expect(html).not.toMatch(/<button[^>]*disabled[^>]*aria-label="Refresh feed"|<button[^>]*aria-label="Refresh feed"[^>]*disabled/);
});
it("a failed first load shows the full error state", () => {
const html = render({ status: "error", onRefresh: () => {} });
expect(html).toContain("The feed did not load.");
expect(html).not.toContain("Could not refresh");
});
it("the loader maps a failure to the right outcome", () => {
expect(firstLoadFailure(true)).toEqual({ fullError: false, refreshFailed: true });
expect(firstLoadFailure(false)).toEqual({ fullError: true, refreshFailed: false });
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { HexAvatar, HEX_SIZES, HEX_TONES, toneFor } from "@/components/HexAvatar";
const render = (props: Parameters<typeof HexAvatar>[0]) => renderToStaticMarkup(createElement(HexAvatar, props));
describe("HexAvatar", () => {
it("renders the photo when there is one", () => {
const html = render({ name: "Ada Lovelace", pictureUrl: "https://img/a.jpg", size: "lg" });
expect(html).toContain("<img");
expect(html).toContain('src="https://img/a.jpg"');
expect(html).toContain('width="46"');
expect(html).toContain('height="53"');
expect(html).not.toContain("AL");
});
it("renders initials on a tone gradient without a photo", () => {
const html = render({ name: "Ada Lovelace", pictureUrl: null, size: "md" });
expect(html).not.toContain("<img");
expect(html).toContain(">AL<");
expect(html).toContain("hex-avatar-initials");
expect(html).toContain(`hex-avatar-${toneFor("Ada Lovelace")}`);
expect(html).toContain('aria-hidden="true"');
});
it("falls back to initials for an empty picture url", () => {
expect(render({ name: "Bo", pictureUrl: "" })).toContain(">B<");
});
it("has the three spec sizes at a 0.866 ratio", () => {
expect(HEX_SIZES).toEqual({
lg: { width: 46, height: 53 },
md: { width: 38, height: 44 },
sm: { width: 30, height: 35 },
});
for (const { width, height } of Object.values(HEX_SIZES)) {
expect(width / height).toBeCloseTo(0.866, 1);
}
});
it("picks the same tone for the same name, from the six tones", () => {
expect(toneFor("Ada Lovelace")).toBe(toneFor(" ada lovelace "));
for (const n of ["A", "Bo", "Carl Sagan", "Dee"]) expect(HEX_TONES).toContain(toneFor(n));
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { MobileDock, dockState } from "@/components/MobileDock";
import { CompactLayoutContext } from "@/components/layout-context";
import { toCardPosts } from "@/lib/feed-model";
const item = (id: string, likes: number, comments: number, liked = false) => ({
type: "post",
publishedAt: "2026-10-19T11:00:00Z",
likesCount: likes,
commentsCount: comments,
isLiked: liked,
post: { id, content: `text ${id}`, author: { id: "a", displayName: `Author ${id}` }, mediaItems: [] },
});
const POSTS = toCardPosts([item("p1", 12, 3), item("p2", 40, 1, true)]);
function feed(over: Partial<FeedViewProps>, compact = true) {
const props: FeedViewProps = {
posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false,
newCount: 0, onRetry: () => {}, onShowNew: () => {}, onToggleLike: () => {}, ...over,
};
return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props)));
}
describe("dock state", () => {
it("shows the counts and liked state of the post on screen", () => {
const s = dockState({ index: 1, kind: "post", post: POSTS[1] }, { canLike: true });
expect(s.likes).toBe(POSTS[1].likesCount);
expect(s.comments).toBe(POSTS[1].commentsCount);
expect(s.liked).toBe(POSTS[1].isLiked);
expect(s.likeDisabled).toBe(false);
expect(s.commentsDisabled).toBe(false);
});
it("disables like and comments on the featured and loading slides", () => {
for (const kind of ["featured", "load"] as const) {
const s = dockState({ index: 2, kind, post: null });
expect(s.likeDisabled).toBe(true);
expect(s.commentsDisabled).toBe(true);
expect(s.likes).toBeNull();
}
});
it("disables like while the like call is in flight", () => {
expect(dockState({ index: 0, kind: "post", post: POSTS[0] }, { canLike: true, likeBusy: true }).likeDisabled).toBe(true);
});
});
describe("dock markup", () => {
it("renders like with aria-pressed, comments, the sparkle new-post button and More", () => {
const out = renderToStaticMarkup(createElement(MobileDock, { slide: { index: 1, kind: "post", post: POSTS[1] }, onToggleLike: () => {} }));
expect(out).toContain('aria-label="Post actions"');
expect(out).toMatch(/class="dk dk-like dk-like-on"[^>]*aria-pressed="true"/);
expect(out).toContain(`>${POSTS[1].likesCount}</span>`);
expect(out).toContain(`Open comments, ${POSTS[1].commentsCount} comment`);
expect(out).toContain("btn-spark btn-spark-fab dk-plus");
expect(out).not.toContain("btn-spark-fold");
expect(out).toContain('aria-label="View more"');
});
it("is part of the phone feed and follows the first slide", () => {
const out = feed({});
expect(out).toContain('data-testid="feed-dock"');
expect(out).toContain(`data-testid="dock-likes">${POSTS[0].likesCount}<`);
});
it("is absent on desktop", () => {
expect(feed({}, false)).not.toContain("feed-dock");
});
});
describe("dock stylesheet", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
it("floats 16px above the bottom on frosted glass", () => {
expect(css).toMatch(/\.dock \{[^}]*bottom: calc\(16px[^}]*backdrop-filter: blur/);
});
it("keeps touch targets at least 44px and disabled buttons fully opaque", () => {
expect(css).toMatch(/\.dk \{[^}]*min-width: 44px;[^}]*height: 52px/);
expect(css).toMatch(/\.dk:disabled \{ opacity: 1;/);
});
});
+259
View File
@@ -0,0 +1,259 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { FeedView, type FeedViewProps } from "@/components/FeedView";
import { pickCurrent, isDoubleTap, isTapTarget, featuredSlideIndex, railPercent } from "@/components/MobileFeed";
import { FIXTURE_DISCOVER } from "@/dev/fixtures";
import { CompactLayoutContext, isCompactWidth } from "@/components/layout-context";
import { toCardPosts } from "@/lib/feed-model";
import { pickQuoteFit, QUOTE_SIZES } from "@/components/SlideText";
const NOW = Date.parse("2026-10-19T12:00:00Z");
const item = (id: string, authorId: string, mediaItems: unknown[] = []) => ({
type: "post",
publishedAt: "2026-10-19T11:00:00Z",
likesCount: 1,
commentsCount: 0,
post: { id, content: `text ${id}`, author: { id: authorId, displayName: `Author ${id}` }, mediaItems },
});
const POSTS = toCardPosts([
item("t1", "me"),
item("i1", "a2", [{ mediaType: "image", imageUrl: "https://img/x.jpg", order: 0 }]),
item("v1", "a3", [{ mediaType: "video", thumbnailUrl: "https://img/v.jpg", mp4Url: "https://cdn.feed.sharedservices.vt.com/v.mp4", videoDuration: 61, order: 0 }]),
]);
function render(over: Partial<FeedViewProps>, compact = true) {
const props: FeedViewProps = {
posts: POSTS, status: "ready", loadingMore: false, loadMoreFailed: false,
newCount: 0, onRetry: () => {}, onShowNew: () => {}, now: NOW, feedUserId: "me", onDelete: () => {}, ...over,
};
return renderToStaticMarkup(createElement(CompactLayoutContext.Provider, { value: compact }, createElement(FeedView, props)));
}
describe("compact layout", () => {
it("switches at 760px and below", () => {
expect(isCompactWidth(390)).toBe(true);
expect(isCompactWidth(760)).toBe(true);
expect(isCompactWidth(761)).toBe(false);
expect(isCompactWidth(1440)).toBe(false);
expect(isCompactWidth(0)).toBe(false);
});
it("keeps the desktop panel outside the phone layout", () => {
const html = render({}, false);
expect(html).toContain('data-testid="post-card"');
expect(html).not.toContain("feed-reel");
});
});
describe("mobile feed", () => {
it("renders one slide per post, text as a quote and media under the fades", () => {
const html = render({});
expect(html.match(/data-testid="feed-slide"/g)).toHaveLength(3);
expect(html).toMatch(/class="slide-quote" data-fit-size="31" style="font-size:31px">text t1<\/p>/);
expect(html).toMatch(/slide slide-on-media[^"]*"[^>]*aria-label="Post by Author i1"/);
expect(html).toContain("slide-scrim-top");
expect(html).toMatch(/class="slide-text"[^>]*>text i1<\/p>/);
expect(html).toContain('aria-label="Play video"');
// The quote slide shows its text once, as the quote.
expect(html.match(/text t1/g)).toHaveLength(1);
});
it("marks the first slide as the one on screen", () => {
const html = render({});
expect(html).toMatch(/class="slide slide-on-text is-cur slide-own"/);
expect(html).toContain('data-current="0"');
});
it("shows the frosted menu only on the person's own posts", () => {
const html = render({});
expect(html.match(/slide-menu-btn/g)).toHaveLength(1);
expect(html).toContain('aria-label="More actions"');
expect(render({ feedUserId: "someone-else" })).not.toContain("slide-menu-btn");
});
it("ends with a loading slide while another page can load", () => {
expect(render({ hasMore: true })).toContain('data-testid="feed-slide-load"');
expect(render({ loadingMore: true })).toContain('data-testid="feed-slide-load"');
expect(render({})).not.toContain("feed-slide-load");
const failed = render({ hasMore: true, loadMoreFailed: true });
expect(failed).not.toContain("feed-slide-load");
expect(failed).toContain("Try again");
});
it("shows the loading slide on first load", () => {
expect(render({ status: "loading", posts: [] })).toContain('data-testid="feed-slide-load"');
});
it("picks the slide that is mostly on screen", () => {
expect(pickCurrent(0, [])).toBe(0);
expect(pickCurrent(0, [1])).toBe(1);
expect(pickCurrent(2, [1, 2])).toBe(2);
expect(pickCurrent(4, [1, 2])).toBe(1);
});
});
describe("mobile stylesheet", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
it("snaps one slide per screen", () => {
expect(css).toMatch(/\.reel \{[^}]*scroll-snap-type: y mandatory/);
expect(css).toMatch(/\.slide \{[^}]*height: 100%;[^}]*scroll-snap-stop: always/);
});
it("uses a 31px serif quote with a crimson mark, and eases media from 104%", () => {
expect(css).toMatch(/\.slide-quote \{[^}]*font-size: 31px/);
expect(css).toMatch(/\.slide-quote-mark \{[^}]*color: var\(--accent\)/);
expect(css).toMatch(/transform: scale\(1\.04\)/);
expect(css).toMatch(/\.is-cur \.slide-art \{ transform: scale\(1\); \}/);
});
it("uses tokens only", () => {
expect(css).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba?\(/);
});
});
describe("one step per gesture", () => {
it("moves one slide in the gesture's direction and stays inside the reel", async () => {
const { stepTarget } = await import("@/components/MobileFeed");
expect(stepTarget(0, 120, 5)).toBe(1);
expect(stepTarget(3, -4, 5)).toBe(2);
expect(stepTarget(4, 300, 5)).toBe(4);
expect(stepTarget(0, -50, 5)).toBe(0);
expect(stepTarget(2, 0, 5)).toBe(2);
});
});
describe("video slide before play", () => {
it("shows only the frosted play button, no native controls", () => {
const html = render({});
const video = /<video[^>]*>/.exec(html)?.[0] ?? "";
expect(video).not.toBe("");
expect(video).not.toContain("controls");
expect(html).toContain('class="post-play slide-play"');
});
});
describe("double-tap detection", () => {
it("needs two taps within 300ms", () => {
expect(isDoubleTap(null, 100)).toBe(false);
expect(isDoubleTap(100, 250)).toBe(true);
expect(isDoubleTap(100, 400)).toBe(true);
expect(isDoubleTap(100, 401)).toBe(false);
});
it("ignores taps on controls", () => {
const el = (html: string) => {
const fake = (match: boolean) => ({ closest: () => (match ? {} : null) });
return html === "slide" ? fake(false) : fake(true);
};
expect(isTapTarget(el("slide") as unknown as EventTarget)).toBe(true);
expect(isTapTarget(el("button") as unknown as EventTarget)).toBe(false);
});
});
describe("featured experts slide", () => {
it("sits after the second post and only with experts", () => {
expect(featuredSlideIndex(3, 5)).toBe(2);
expect(featuredSlideIndex(2, 5)).toBe(2);
expect(featuredSlideIndex(1, 5)).toBe(-1);
expect(featuredSlideIndex(3, 0)).toBe(-1);
});
it("renders the title, eyebrow, hint and a 156px carousel as a non-post slide", () => {
const out = render({ featuredExperts: FIXTURE_DISCOVER.featuredExperts });
expect(out).toContain('data-slide-kind="featured"');
expect(out).toContain("Featured experts");
expect(out).toContain("Ask the people everyone is talking about");
expect(out).toContain("--cfw:156px");
expect(out).toContain("Keep scrolling");
expect(out.match(/data-testid="feed-slide"/g)).toHaveLength(3);
// After the second post, before the third.
expect(out.indexOf("Author i1")).toBeLessThan(out.indexOf("feed-slide-featured"));
expect(out.indexOf("feed-slide-featured")).toBeLessThan(out.indexOf("Author v1"));
});
it("is absent without experts", () => {
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/);
});
});
describe("videoFit", () => {
it("fills the slide for 4:5 and taller, shows whole for wider, covers while unknown", async () => {
const { videoFit } = await import("@/components/MobileFeed");
expect(videoFit(720, 1280)).toBe("cover");
expect(videoFit(1080, 1350)).toBe("cover");
expect(videoFit(1080, 1080)).toBe("contain");
expect(videoFit(1920, 1080)).toBe("contain");
expect(videoFit(0, 0)).toBe("cover");
});
});
describe("long post text", () => {
it("picks the largest quote size that fits, measured, not counted", () => {
const fitsBelow = (max: number) => (size: number) => size <= max;
expect(pickQuoteFit(fitsBelow(40), () => 0)).toEqual({ size: 31, lines: 0 });
expect(pickQuoteFit(fitsBelow(26), () => 0)).toEqual({ size: 26, lines: 0 });
expect(pickQuoteFit(fitsBelow(22), () => 0)).toEqual({ size: 22, lines: 0 });
expect(pickQuoteFit(fitsBelow(19), () => 0)).toEqual({ size: 19, lines: 0 });
expect([...QUOTE_SIZES]).toEqual([31, 26, 22, 19]);
});
it("clamps at 19px to the whole lines that fit when nothing fits", () => {
const tried: number[] = [];
const fit = pickQuoteFit((s) => (tried.push(s), false), () => 17.8);
expect(tried).toEqual([31, 26, 22, 19]);
expect(fit).toEqual({ size: 19, lines: 17 });
expect(pickQuoteFit(() => false, () => 0.2)).toEqual({ size: 19, lines: 1 });
});
it("shows no button on short text until a measurement says it is long", () => {
const html = render({});
expect(html).not.toContain("Read more");
expect(html).not.toMatch(/>more<\/button>/);
expect(html).not.toContain("slide-reader");
});
it("keeps taps on open text from counting toward a double-tap like", () => {
const el = { closest: (sel: string) => (sel.includes("[data-expanded-text]") ? {} : null) } as unknown as Element;
expect(isTapTarget(el)).toBe(false);
});
});
describe("long post text stylesheet", () => {
const css = readFileSync("src/styles/mobile.css", "utf8");
it("fits the quote between the top bar and the author block", () => {
expect(css).toMatch(/\.slide-quote-w \{[^}]*top: calc\(72px[^}]*bottom: 181px[^}]*overflow: hidden/);
});
it("makes the buttons at least 44px tall", () => {
expect(css).toMatch(/\.slide-more \{[^}]*min-height: 44px/);
});
it("holds the reel still while text is open, and the open text scrolls", () => {
expect(css).toMatch(/\.reel\[data-locked="true"\] \{ overflow-y: hidden; \}/);
expect(css).toMatch(/\.slide-reader-scroll \{[^}]*overflow-y: auto/);
expect(css).toMatch(/\.slide-text\.is-open \{[^}]*overflow-y: auto/);
expect(css).toMatch(/\.slide-text-open \.slide-scrim \{[^}]*var\(--media-scrim\)/);
});
it("drops the expand animation under reduced motion", () => {
expect(css).toMatch(/prefers-reduced-motion: reduce\) \{[^}]*\.slide-reader,\s*\.slide-text\.is-open,/);
});
});
+2 -1
View File
@@ -158,7 +158,8 @@ describe("Composer", () => {
const html = renderToStaticMarkup(createElement(Composer, { ...base, file: f, onFile: () => {} }));
expect(html).toContain("Add image");
expect(html).toContain("photo.jpg");
expect(html).not.toMatch(/<button[^>]*class="composer-post"[^>]*disabled/);
expect(html).toMatch(/<button[^>]*type="submit"[^>]*class="[^"]*composer-post/);
expect(html).not.toMatch(/<button[^>]*class="[^"]*composer-post[^"]*"[^>]*disabled/);
});
it("keeps the text and the image name on a publish failure", () => {
const f = new File(["x"], "sunset.png", { type: "image/png" });
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { applyTheme, currentTheme, readStoredTheme, THEME_KEY, THEME_SCRIPT } from "@/lib/theme";
import { useTheme } from "@/lib/use-theme";
const memoryStorage = (initial: Record<string, string> = {}) => {
const data = new Map(Object.entries(initial));
return {
data,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
};
};
const throwingStorage = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
};
const fakeRoot = () => ({ dataset: {} as DOMStringMap, style: { colorScheme: "" } });
describe("readStoredTheme", () => {
it("is dark on a first visit", () => {
expect(readStoredTheme(memoryStorage())).toBe("dark");
expect(readStoredTheme(null)).toBe("dark");
});
it("returns a stored light or dark choice", () => {
expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "light" }))).toBe("light");
expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "dark" }))).toBe("dark");
});
it("ignores junk values and storage that throws", () => {
expect(readStoredTheme(memoryStorage({ [THEME_KEY]: "neon" }))).toBe("dark");
expect(readStoredTheme(throwingStorage)).toBe("dark");
});
});
describe("applyTheme", () => {
it("sets the attribute and color-scheme and stores the choice", () => {
const root = fakeRoot();
const storage = memoryStorage();
applyTheme("light", root, storage);
expect(root.dataset.theme).toBe("light");
expect(root.style.colorScheme).toBe("light");
expect(storage.data.get(THEME_KEY)).toBe("light");
expect(currentTheme(root)).toBe("light");
});
it("still changes the theme when storage throws", () => {
const root = fakeRoot();
expect(() => applyTheme("light", root, throwingStorage)).not.toThrow();
expect(root.dataset.theme).toBe("light");
});
it("reads a missing attribute as dark", () => {
expect(currentTheme(fakeRoot())).toBe("dark");
});
});
describe("THEME_SCRIPT (pre-paint)", () => {
const run = (storage: unknown) => {
const documentElement = fakeRoot();
new Function("localStorage", "document", THEME_SCRIPT)(storage, { documentElement });
return documentElement;
};
it("applies a stored light theme", () => {
expect(run(memoryStorage({ [THEME_KEY]: "light" })).dataset.theme).toBe("light");
});
it("falls back to dark for nothing stored, junk, or throwing storage", () => {
expect(run(memoryStorage()).dataset.theme).toBe("dark");
expect(run(memoryStorage({ [THEME_KEY]: "x" })).dataset.theme).toBe("dark");
expect(run(throwingStorage).dataset.theme).toBe("dark");
});
});
describe("useTheme", () => {
it("renders dark on the server", () => {
let seen = "";
const Probe = () => {
seen = useTheme().theme;
return null;
};
renderToStaticMarkup(createElement(Probe));
expect(seen).toBe("dark");
});
});
describe("no raw colors outside the token definitions", () => {
const files = (dir: string): string[] =>
readdirSync(dir).flatMap((f) => {
const p = path.join(dir, f);
return statSync(p).isDirectory() ? files(p) : [p];
});
it("has no hex color in any source file except globals.css", () => {
const src = path.resolve(__dirname, "../src");
const offenders = files(src)
.filter((f) => /\.(tsx?|css)$/.test(f) && !f.endsWith(path.join("app", "globals.css")))
.filter((f) => /#[0-9a-fA-F]{3,8}\b|rgba?\(/.test(readFileSync(f, "utf8")));
expect(offenders).toEqual([]);
});
});