Feed browser: Redesign foundation #2
@@ -1,6 +1,6 @@
|
|||||||
# ADR 0001: Styling approach for the feed browser redesign
|
# 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
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,128 +1,91 @@
|
|||||||
:root { --red: #E8202A; --ink: #111; --grey: #6b6b6b; --line: #d9d9d9; --bg: #f5f5f5; }
|
/* Global styles only (ADR 0001, section 6): layer order, tokens, reset, focus ring. */
|
||||||
* { box-sizing: border-box; }
|
@layer reset, base;
|
||||||
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; }
|
|
||||||
|
|
||||||
.comments { display: flex; flex-direction: column; gap: 10px; padding-top: 10px; border-top: 1px solid #eee; }
|
/* Tokens. Dark is the default, so it applies even without data-theme.
|
||||||
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
A nested [data-theme] element re-scopes the theme tokens (used by the dev button page). */
|
||||||
.comment p { margin: 2px 0 0; font-size: 14px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
:root,
|
||||||
.comment-head { display: flex; gap: 8px; align-items: baseline; font-size: 13px; }
|
:root[data-theme="dark"],
|
||||||
.comment-head time { color: var(--grey); font-size: 12px; }
|
[data-theme="dark"] {
|
||||||
.comment.removed .comment-removed { color: var(--grey); font-style: italic; }
|
--bg: #09090B;
|
||||||
.comments-empty { margin: 0; color: var(--grey); font-size: 14px; }
|
--tile: #121215;
|
||||||
.comment-error { margin: 0; color: #a8141b; font-size: 13px; }
|
--tile-2: #19191D;
|
||||||
.comment-form { display: flex; gap: 8px; }
|
--tile-3: #232328;
|
||||||
.comment-form input { flex: 1; margin: 0; }
|
--line: rgba(255,255,255,.07);
|
||||||
.comment-form button { margin: 0; }
|
--line-2: rgba(255,255,255,.15);
|
||||||
.link-btn { background: transparent; color: inherit; text-decoration: underline; padding: 0; margin: 0; font-size: inherit; width: auto; }
|
--ink: #F4F2EE;
|
||||||
.comments-toggle { background: transparent; color: var(--grey); padding: 4px 8px; margin: 0; font-size: 14px; font-weight: 400; width: auto; }
|
--ink-2: #BFBDB8;
|
||||||
|
--ink-3: #95938E;
|
||||||
.post-menu { position: relative; margin-left: auto; }
|
--accent: #C8141C;
|
||||||
.menu-btn { margin: 0; padding: 2px 10px; background: transparent; color: var(--grey); border-radius: 8px; font-size: 18px; line-height: 1; letter-spacing: 1px; }
|
--danger: #FF6B70;
|
||||||
.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); }
|
--glass: rgba(18,18,21,.72);
|
||||||
.menu button { margin: 0; text-align: left; background: transparent; color: inherit; padding: 6px 8px; border-radius: 6px; font-size: 14px; font-weight: 400; }
|
--shadow-pop: 0 10px 28px rgba(0,0,0,.5);
|
||||||
.menu button:hover { background: #f4f4f4; }
|
/* Buttons: drop shadows and the soft-depth (neumorphic) light and shade. */
|
||||||
.menu .menu-danger { color: #a8141b; }
|
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
|
||||||
.menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; }
|
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
|
||||||
|
--neu-d: rgba(0,0,0,.8);
|
||||||
/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */
|
--neu-l: rgba(255,255,255,.05);
|
||||||
.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; }
|
color-scheme: dark;
|
||||||
.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; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expert search + category chips (left column). Squared corners, monochrome + red. */
|
:root[data-theme="light"],
|
||||||
.expert-search { margin-top: 16px; }
|
[data-theme="light"] {
|
||||||
.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; }
|
--bg: #EFEDE9;
|
||||||
.expert-search form { display: flex; flex-direction: column; }
|
--tile: #FBFAF8;
|
||||||
.expert-search input { border-radius: 0; width: 100%; }
|
--tile-2: #F3F1EE;
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
|
--tile-3: #E8E5E0;
|
||||||
.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: #fff; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; }
|
--line: rgba(26,28,31,.08);
|
||||||
.chip[aria-pressed="true"] { background: var(--red); border-color: var(--red); color: #fff; }
|
--line-2: rgba(26,28,31,.16);
|
||||||
.search-results { margin-top: 12px; }
|
--ink: #1A1C1F;
|
||||||
.search-note { margin: 0; font-size: 14px; color: var(--grey); }
|
--ink-2: #46484D;
|
||||||
@media (max-width: 959px) {
|
--ink-3: #686A70;
|
||||||
.col-left .expert-search { flex-basis: 100%; }
|
--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
@@ -1,9 +1,28 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
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 "./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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Minnect feed browser",
|
title: "Minnect feed browser",
|
||||||
@@ -12,7 +31,11 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
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>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { HexAvatar } from "@/components/HexAvatar";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { DiscoverCategory } from "@/lib/discover";
|
import type { DiscoverCategory } from "@/lib/discover";
|
||||||
import { fetchDiscover } from "@/lib/discover-client";
|
import { fetchDiscover } from "@/lib/discover-client";
|
||||||
import type { ExpertResult } from "@/lib/experts-search";
|
import type { ExpertResult } from "@/lib/experts-search";
|
||||||
import { fetchExperts } from "@/lib/experts-search-client";
|
import { fetchExperts } from "@/lib/experts-search-client";
|
||||||
import { initials } from "@/lib/feed-model";
|
|
||||||
|
|
||||||
export type SearchStatus = "idle" | "loading" | "ready" | "error";
|
export type SearchStatus = "idle" | "loading" | "ready" | "error";
|
||||||
|
|
||||||
@@ -61,12 +61,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
|
|||||||
<ul className="rail-list">
|
<ul className="rail-list">
|
||||||
{p.results.map((e) => (
|
{p.results.map((e) => (
|
||||||
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
|
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
|
||||||
{e.pictureUrl ? (
|
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
|
|
||||||
) : (
|
|
||||||
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
|
|
||||||
)}
|
|
||||||
<div className="rail-text">
|
<div className="rail-text">
|
||||||
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
||||||
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { HexAvatar } from "@/components/HexAvatar";
|
||||||
import type { DiscoverExpert } from "@/lib/discover";
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
import { initials } from "@/lib/feed-model";
|
|
||||||
|
|
||||||
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
|
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
|
||||||
if (rating === null && reviews === null) return null;
|
if (rating === null && reviews === null) return null;
|
||||||
@@ -19,12 +19,7 @@ export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] })
|
|||||||
<ul className="rail-list">
|
<ul className="rail-list">
|
||||||
{experts.map((e) => (
|
{experts.map((e) => (
|
||||||
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
|
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
|
||||||
{e.pictureUrl ? (
|
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
|
|
||||||
) : (
|
|
||||||
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
|
|
||||||
)}
|
|
||||||
<div className="rail-text">
|
<div className="rail-text">
|
||||||
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
||||||
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export function Logo({ size = 44 }: { size?: number }) {
|
export function Logo({ size = 44 }: { size?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 48 48" role="img" aria-label="Minnect">
|
<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" />
|
<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="#fff" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
|
<path d="M14 33V15l10 11 10-11v18" fill="none" stroke="white" strokeWidth="3.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { HexAvatar } from "@/components/HexAvatar";
|
||||||
import { CommentsSection } from "@/components/CommentsSection";
|
import { CommentsSection } from "@/components/CommentsSection";
|
||||||
import { VideoPlayer } from "@/components/VideoPlayer";
|
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 {
|
export interface PostCardProps {
|
||||||
post: CardPost;
|
post: CardPost;
|
||||||
@@ -36,14 +37,7 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
|
|||||||
return (
|
return (
|
||||||
<article className="post" data-testid="post-card">
|
<article className="post" data-testid="post-card">
|
||||||
<header className="post-head">
|
<header className="post-head">
|
||||||
{post.authorPictureUrl ? (
|
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||||
// 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>
|
|
||||||
)}
|
|
||||||
<div className="post-who">
|
<div className="post-who">
|
||||||
<strong>{post.authorName}</strong>
|
<strong>{post.authorName}</strong>
|
||||||
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { signIn, type Person } from "@/lib/auth-client";
|
import { signIn, type Person } from "@/lib/auth-client";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
|
import { SparkleButton } from "./Buttons";
|
||||||
|
|
||||||
export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void }) {
|
export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void }) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -27,39 +28,72 @@ export function SignInForm({ onSignedIn }: { onSignedIn: (p: Person) => void })
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="center">
|
<main className="signin">
|
||||||
<form className="card" onSubmit={submit} noValidate>
|
<div className="signin-glow" aria-hidden="true" />
|
||||||
<Logo />
|
<div className="signin-col">
|
||||||
<h1>Sign in to Minnect</h1>
|
<div className="signin-brand">
|
||||||
{error && (
|
<Logo size={30} />
|
||||||
<p role="alert" className="error">
|
<span className="type-wordmark">MINNECT</span>
|
||||||
{error}
|
</div>
|
||||||
</p>
|
<div className="signin-head">
|
||||||
)}
|
<span className="type-eyebrow">Members</span>
|
||||||
<label htmlFor="email">Email</label>
|
<h1 className="signin-title">
|
||||||
<input
|
Human answers for <em>human decisions</em>
|
||||||
id="email"
|
</h1>
|
||||||
name="email"
|
<div className="signin-rule" aria-hidden="true" />
|
||||||
type="email"
|
</div>
|
||||||
autoComplete="username"
|
<form className="signin-form" onSubmit={submit} noValidate>
|
||||||
required
|
{error && (
|
||||||
value={email}
|
<p role="alert" className="error signin-error">
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
{error}
|
||||||
/>
|
</p>
|
||||||
<label htmlFor="password">Password</label>
|
)}
|
||||||
<input
|
<label className="signin-label" htmlFor="email">
|
||||||
id="password"
|
Email
|
||||||
name="password"
|
</label>
|
||||||
type="password"
|
<div className="signin-field">
|
||||||
autoComplete="current-password"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
required
|
<rect x="3" y="5" width="18" height="14" rx="3" />
|
||||||
value={password}
|
<path d="m4 7 8 6 8-6" />
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
</svg>
|
||||||
/>
|
<input
|
||||||
<button type="submit" disabled={busy || !email || !password}>
|
id="email"
|
||||||
{busy ? "Signing in..." : "Sign in"}
|
name="email"
|
||||||
</button>
|
type="email"
|
||||||
</form>
|
autoComplete="username"
|
||||||
|
placeholder="you@company.com"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="signin-label signin-label-next" htmlFor="password">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="signin-field">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="4.5" y="10.5" width="15" height="10" rx="2.5" />
|
||||||
|
<path d="M8 10.5V8a4 4 0 0 1 8 0v2.5" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="Your password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SparkleButton
|
||||||
|
type="submit"
|
||||||
|
className="signin-submit"
|
||||||
|
label={busy ? "Signing in..." : "Sign in"}
|
||||||
|
disabled={busy || !email || !password}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { HexAvatar } from "@/components/HexAvatar";
|
||||||
import type { DiscoverMinnect } from "@/lib/discover";
|
import type { DiscoverMinnect } from "@/lib/discover";
|
||||||
import { MinnectMedia } from "./MinnectMedia";
|
import { MinnectMedia } from "./MinnectMedia";
|
||||||
import { initials } from "@/lib/feed-model";
|
|
||||||
|
|
||||||
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
|
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
|
||||||
if (minnects.length === 0) return null;
|
if (minnects.length === 0) return null;
|
||||||
@@ -13,12 +13,7 @@ export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[]
|
|||||||
return (
|
return (
|
||||||
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
|
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
|
||||||
<div className="minnect-who">
|
<div className="minnect-who">
|
||||||
{m.expert.pictureUrl ? (
|
<HexAvatar name={who} pictureUrl={m.expert.pictureUrl} size="sm" />
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img className="avatar avatar-sm" src={m.expert.pictureUrl} alt="" width={28} height={28} />
|
|
||||||
) : (
|
|
||||||
<span className="avatar avatar-sm avatar-initials" aria-hidden="true">{initials(who)}</span>
|
|
||||||
)}
|
|
||||||
<strong className="rail-name">{who}</strong>
|
<strong className="rail-name">{who}</strong>
|
||||||
</div>
|
</div>
|
||||||
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
|
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
|
||||||
|
|||||||
@@ -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;})();`;
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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; }
|
||||||
@@ -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%; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user