05rgfly6g: Add HexAvatar and use it for every existing avatar

This commit is contained in:
2026-10-19 21:50:40 -06:00
parent dfc0cac95a
commit 3306a24d33
10 changed files with 134 additions and 33 deletions
+9
View File
@@ -43,6 +43,15 @@
--accent-lo: color-mix(in srgb, var(--accent), black 30%);
--accent-soft: color-mix(in srgb, var(--accent), transparent 88%);
/* 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;
+1
View File
@@ -4,6 +4,7 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
import "./globals.css";
import "../styles/type.css";
import "../styles/avatar.css";
import "../styles/legacy.css";
const manrope = Manrope({
+2 -7
View File
@@ -1,11 +1,11 @@
"use client";
import { HexAvatar } from "@/components/HexAvatar";
import { useEffect, useRef, useState } from "react";
import type { DiscoverCategory } from "@/lib/discover";
import { fetchDiscover } from "@/lib/discover-client";
import type { ExpertResult } from "@/lib/experts-search";
import { fetchExperts } from "@/lib/experts-search-client";
import { initials } from "@/lib/feed-model";
export type SearchStatus = "idle" | "loading" | "ready" | "error";
@@ -61,12 +61,7 @@ export function ExpertSearchView(p: ExpertSearchViewProps) {
<ul className="rail-list">
{p.results.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="search-result">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
+2 -7
View File
@@ -1,5 +1,5 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
if (rating === null && reviews === null) return null;
@@ -19,12 +19,7 @@ export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] })
<ul className="rail-list">
{experts.map((e) => (
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
{e.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={e.pictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">{initials(e.displayName)}</span>
)}
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
<div className="rail-text">
<strong className="rail-name">{e.displayName || "Expert"}</strong>
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
+41
View File
@@ -0,0 +1,41 @@
import { initials } from "@/lib/feed-model";
export type HexAvatarSize = "lg" | "md" | "sm";
/** Width by height in px for each spec size (ratio 0.866). */
export const HEX_SIZES: Record<HexAvatarSize, { width: number; height: number }> = {
lg: { width: 46, height: 53 },
md: { width: 38, height: 44 },
sm: { width: 30, height: 35 },
};
export const HEX_TONES = ["oxblood", "navy", "forest", "slate", "plum", "graphite"] as const;
export type HexTone = (typeof HEX_TONES)[number];
/** Same name always gives the same tone. */
export function toneFor(name: string): HexTone {
let sum = 0;
for (const ch of name.trim().toLowerCase()) sum = (sum * 31 + ch.codePointAt(0)!) >>> 0;
return HEX_TONES[sum % HEX_TONES.length];
}
export interface HexAvatarProps {
name: string;
pictureUrl?: string | null;
size?: HexAvatarSize;
}
export function HexAvatar({ name, pictureUrl, size = "md" }: HexAvatarProps) {
const { width, height } = HEX_SIZES[size];
if (pictureUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img className={`hex-avatar hex-avatar-${size}`} src={pictureUrl} alt="" width={width} height={height} />
);
}
return (
<span className={`hex-avatar hex-avatar-${size} hex-avatar-initials hex-avatar-${toneFor(name)}`} aria-hidden="true">
{initials(name)}
</span>
);
}
+3 -9
View File
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import { HexAvatar } from "@/components/HexAvatar";
import { CommentsSection } from "@/components/CommentsSection";
import { VideoPlayer } from "@/components/VideoPlayer";
import { initials, relativeTime, type CardPost } from "@/lib/feed-model";
import { relativeTime, type CardPost } from "@/lib/feed-model";
export interface PostCardProps {
post: CardPost;
@@ -36,14 +37,7 @@ export function PostCard({ post, now, onToggleLike, likeBusy, likeError, onSigne
return (
<article className="post" data-testid="post-card">
<header className="post-head">
{post.authorPictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar" src={post.authorPictureUrl} alt="" width={40} height={40} />
) : (
<span className="avatar avatar-initials" aria-hidden="true">
{initials(post.authorName)}
</span>
)}
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
<div className="post-who">
<strong>{post.authorName}</strong>
<time dateTime={post.publishedAt ?? undefined}>{relativeTime(post.publishedAt, now)}</time>
+2 -7
View File
@@ -1,6 +1,6 @@
import { HexAvatar } from "@/components/HexAvatar";
import type { DiscoverMinnect } from "@/lib/discover";
import { MinnectMedia } from "./MinnectMedia";
import { initials } from "@/lib/feed-model";
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
if (minnects.length === 0) return null;
@@ -13,12 +13,7 @@ export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[]
return (
<li key={String(m.id)} className="rail-card minnect-card" data-testid="minnect-card">
<div className="minnect-who">
{m.expert.pictureUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="avatar avatar-sm" src={m.expert.pictureUrl} alt="" width={28} height={28} />
) : (
<span className="avatar avatar-sm avatar-initials" aria-hidden="true">{initials(who)}</span>
)}
<HexAvatar name={who} pictureUrl={m.expert.pictureUrl} size="sm" />
<strong className="rail-name">{who}</strong>
</div>
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
+28
View File
@@ -0,0 +1,28 @@
/* HexAvatar (ADR 0001, section 6). Pointy-top hexagon, width to height 0.866. */
.hex-avatar {
display: block;
flex: none;
object-fit: cover;
background: var(--tile-3);
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
}
.hex-avatar-lg { width: 46px; height: 53px; }
.hex-avatar-md { width: 38px; height: 44px; }
.hex-avatar-sm { width: 30px; height: 35px; }
.hex-avatar-initials {
display: grid;
place-items: center;
color: var(--avatar-ink);
font-weight: 600;
letter-spacing: .02em;
}
.hex-avatar-lg.hex-avatar-initials { font-size: 16px; }
.hex-avatar-md.hex-avatar-initials { font-size: 14px; }
.hex-avatar-sm.hex-avatar-initials { font-size: 11px; }
.hex-avatar-oxblood { background: var(--hex-oxblood); }
.hex-avatar-navy { background: var(--hex-navy); }
.hex-avatar-forest { background: var(--hex-forest); }
.hex-avatar-slate { background: var(--hex-slate); }
.hex-avatar-plum { background: var(--hex-plum); }
.hex-avatar-graphite { background: var(--hex-graphite); }
-3
View File
@@ -20,8 +20,6 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.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; }
.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: var(--bg); font-weight: 500; font-size: 14px; }
.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; }
@@ -101,7 +99,6 @@ button:disabled { opacity: .5; cursor: not-allowed; }
.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; }
.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: black; border-radius: 0; }
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { HexAvatar, HEX_SIZES, HEX_TONES, toneFor } from "@/components/HexAvatar";
const render = (props: Parameters<typeof HexAvatar>[0]) => renderToStaticMarkup(createElement(HexAvatar, props));
describe("HexAvatar", () => {
it("renders the photo when there is one", () => {
const html = render({ name: "Ada Lovelace", pictureUrl: "https://img/a.jpg", size: "lg" });
expect(html).toContain("<img");
expect(html).toContain('src="https://img/a.jpg"');
expect(html).toContain('width="46"');
expect(html).toContain('height="53"');
expect(html).not.toContain("AL");
});
it("renders initials on a tone gradient without a photo", () => {
const html = render({ name: "Ada Lovelace", pictureUrl: null, size: "md" });
expect(html).not.toContain("<img");
expect(html).toContain(">AL<");
expect(html).toContain("hex-avatar-initials");
expect(html).toContain(`hex-avatar-${toneFor("Ada Lovelace")}`);
expect(html).toContain('aria-hidden="true"');
});
it("falls back to initials for an empty picture url", () => {
expect(render({ name: "Bo", pictureUrl: "" })).toContain(">B<");
});
it("has the three spec sizes at a 0.866 ratio", () => {
expect(HEX_SIZES).toEqual({
lg: { width: 46, height: 53 },
md: { width: 38, height: 44 },
sm: { width: 30, height: 35 },
});
for (const { width, height } of Object.values(HEX_SIZES)) {
expect(width / height).toBeCloseTo(0.866, 1);
}
});
it("picks the same tone for the same name, from the six tones", () => {
expect(toneFor("Ada Lovelace")).toBe(toneFor(" ada lovelace "));
for (const n of ["A", "Bo", "Carl Sagan", "Dee"]) expect(HEX_TONES).toContain(toneFor(n));
});
});