Promote to main: Feed browser luxury redesign #7

Merged
calcazar merged 49 commits from develop into main 2026-10-20 10:33:01 -06:00
19 changed files with 137 additions and 42 deletions
Showing only changes of commit c4434198ad - Show all commits
+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 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).
+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.
+5
View File
@@ -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 {
-1
View File
@@ -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"],
+1 -1
View File
@@ -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 ? (
+3 -3
View File
@@ -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}
+9 -7
View File
@@ -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 ? (
+3 -3
View File
@@ -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 ? (
+1 -1
View File
@@ -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>
)} )}
+3 -3
View File
@@ -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,
+3
View File
@@ -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; }
+3
View File
@@ -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;
-15
View File
@@ -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; }
+4 -1
View File
@@ -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
View File
@@ -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); }
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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 });
+3 -1
View File
@@ -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"');