Merge pull request 'Feed browser: Redesign foundation' (#2) from feature/feed-browser-redesign-foundation into develop

This commit was merged in pull request #2.
This commit is contained in:
2026-10-19 22:02:36 -06:00
24 changed files with 1841 additions and 193 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR 0001: Styling approach for the feed browser redesign
- Status: Proposed (waiting for Cesar's sign-off on CRM task `1ublul6nq`)
- 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.
+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>
);
}
+87 -124
View File
@@ -1,128 +1,91 @@
: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;
--danger: #FF6B70;
--glass: rgba(18,18,21,.72);
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
/* 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;
--danger: #B3121A;
--glass: rgba(251,250,248,.74);
--shadow-pop: 0 10px 28px rgba(26,28,31,.14);
--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;
/* 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; }
}
@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; }
}
+26 -3
View File
@@ -1,9 +1,28 @@
import type { Metadata } 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/buttons.css";
import "../styles/avatar.css";
import "../styles/signin.css";
import "../styles/legacy.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",
});
export const metadata: Metadata = {
title: "Minnect feed browser",
@@ -12,7 +31,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>
);
+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>
);
}
+239
View File
@@ -0,0 +1,239 @@
// 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;
/** Trailing content, for example a chevron or the current theme name. */
trailing?: ReactNode;
}
export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) {
return (
<button {...rest} type={type} className={cx("btn-depth", className)}>
{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
{...rest}
type={type}
aria-label={label}
title={rest.title ?? label}
className={cx("btn-gloss", shape === "round" && "btn-gloss-round", size === "sm" && "btn-gloss-sm", className)}
>
{icon}
</button>
);
}
+2 -7
View File
@@ -1,11 +1,11 @@
"use client";
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";
@@ -61,12 +61,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
<ul className="rail-list">
{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>
)}
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
+2 -7
View File
@@ -1,5 +1,5 @@
import { HexAvatar } from "@/components/HexAvatar";
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;
@@ -19,12 +19,7 @@ export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] })
<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>
)}
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
+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>
);
}
+3 -9
View File
@@ -1,9 +1,10 @@
"use client";
import { useState } 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 { relativeTime, type CardPost } from "@/lib/feed-model";
export interface PostCardProps {
post: CardPost;
@@ -36,14 +37,7 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
return (
<article className="post" data-testid="post-card">
<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>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
+44 -10
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>
<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="error">
<p role="alert" className="error signin-error">
{error}
</p>
)}
<label htmlFor="email">Email</label>
<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)}
/>
<label htmlFor="password">Password</label>
</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)}
/>
<button type="submit" disabled={busy || !email || !password}>
{busy ? "Signing in..." : "Sign in"}
</button>
</div>
<SparkleButton
type="submit"
className="signin-submit"
label={busy ? "Signing in..." : "Sign in"}
disabled={busy || !email || !password}
/>
</form>
</div>
</main>
);
}
+2 -7
View File
@@ -1,6 +1,6 @@
import { HexAvatar } from "@/components/HexAvatar";
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;
@@ -13,12 +13,7 @@ export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[]
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>
)}
<HexAvatar name={who} pictureUrl={m.expert.pictureUrl} size="sm" />
<strong className="rail-name">{who}</strong>
</div>
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
+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 the legacy
* `button` rules and any `button { color: inherit }` never 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; 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; }
+124
View File
@@ -0,0 +1,124 @@
/* 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; }
.shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: var(--tile); border-bottom: 1px solid var(--line); }
.shell header div { display: flex; flex-direction: column; font-size: 14px; }
.shell header span { color: var(--ink-3); }
.shell header { position: sticky; top: 0; z-index: 5; }
.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: transparent; 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: var(--tile); 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; }
.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; }
.post-who time { color: var(--ink-3); 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: black; border-radius: 8px; overflow: hidden; }
.post-video-pending { color: white; padding: 48px 12px; text-align: center; }
.post-video video { display: block; width: 100%; max-height: 520px; background: black; }
.post-stats { display: flex; gap: 16px; color: var(--ink-3); font-size: 14px; }
.heart { color: var(--accent); }
.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; }
.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); } }
.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(--ink-3); border-radius: 8px; font-size: 14px; font-weight: 400; }
.like-btn:hover:not(:disabled) { color: var(--accent); background: var(--accent-soft); }
.like-btn.liked { color: var(--accent); }
.like-btn:disabled { opacity: 1; cursor: default; }
.like-btn:disabled:not(.liked) { color: var(--ink-3); }
.like-error { color: var(--danger); font-size: 13px; align-self: center; }
.post-stats { align-items: center; }
.composer { background: var(--tile); 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 var(--line-2); border-radius: 0; resize: vertical; background: var(--tile-2); 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 var(--line-2); background: var(--tile-2); }
.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: black; }
.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; }
.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
.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(--ink-3); font-size: 12px; }
.comment.removed .comment-removed { color: var(--ink-3); font-style: italic; }
.comments-empty { margin: 0; color: var(--ink-3); font-size: 14px; }
.comment-error { margin: 0; color: var(--danger); 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(--ink-3); 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(--ink-3); 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: var(--tile); border: 1px solid var(--line-2); border-radius: 10px; box-shadow: var(--shadow-pop); }
.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: var(--tile-2); }
.menu .menu-danger { color: var(--danger); }
.menu-ask { font-size: 13px; color: var(--ink-3); 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: var(--bg); }
.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: var(--tile); 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(--ink-3); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-meta { color: var(--ink-3); font-size: 13px; }
.minnect-who { display: flex; align-items: center; gap: 8px; }
.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: black; 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; }
}
/* 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: transparent; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
.chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: white; }
.search-results { margin-top: 12px; }
.search-note { margin: 0; font-size: 14px; color: var(--ink-3); }
@media (max-width: 959px) {
.col-left .expert-search { flex-basis: 100%; }
}
+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; }
+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);
}
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import { 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");
expect(readFileSync(path.resolve(__dirname, "../src/styles/legacy.css"), "utf8")).not.toMatch(/button:disabled\s*\{[^}]*opacity/);
expect(html(createElement(SparkleButton, { label: "Sign in", disabled: true }))).toContain("disabled");
});
});
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/);
}
});
});
+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([]);
});
}
});
+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));
});
});
+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([]);
});
});