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