Expand style picker: 12 directions with per-page mockups.
CI / skip-ci-check (pull_request) Successful in 29s
CI / docker-ci (pull_request) Successful in 31s
CI / python-lint (pull_request) Successful in 33s
CI / secret-scan (pull_request) Successful in 38s
CI / viewer-unit (pull_request) Successful in 1m45s
CI / admin-unit (pull_request) Successful in 2m2s
CI / e2e (pull_request) Successful in 2m45s

Add Matchmaker Arcade, Parade Mosaic, and Orbit Kin; show Home, Photo, Search, Identify, Upload, and Login for each style.
This commit is contained in:
2026-08-04 13:57:26 -04:00
parent 70f89ae7ad
commit cadf94f4c1
4 changed files with 972 additions and 223 deletions
+458
View File
@@ -0,0 +1,458 @@
'use client';
import type { CSSProperties, ReactNode } from 'react';
import { cn } from '@/lib/utils';
import type { PageTypeId, StyleOption, StyleTokens } from './styleOptions';
function shellStyle(t: StyleTokens): CSSProperties {
return {
background: t.bg,
color: t.text,
fontFamily: t.body,
borderRadius: t.radius === '999px' ? '16px' : t.radius,
border: `1px solid ${t.border}`,
};
}
function HeaderBar({ t, title }: { t: StyleTokens; title: string }) {
return (
<div
className="flex items-center justify-between gap-2 px-2.5 py-1.5 border-b"
style={{ borderColor: t.border, background: t.surface }}
>
<div className="min-w-0">
<div
className="text-[8px] font-bold tracking-wide truncate"
style={{ fontFamily: t.display, color: t.accent }}
>
JRCC
</div>
<div className="text-[9px] truncate opacity-90" style={{ color: t.muted }}>
{title}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<span
className="text-[8px] px-1.5 py-0.5 font-semibold"
style={{
background: t.accent,
color: t.accentText,
borderRadius: t.radius === '999px' ? '999px' : '4px',
}}
>
menu
</span>
</div>
</div>
);
}
function PhotoTile({
t,
tone,
label,
tall,
}: {
t: StyleTokens;
tone: 'A' | 'B' | 'C';
label?: string;
tall?: boolean;
}) {
const bg = tone === 'A' ? t.photoA : tone === 'B' ? t.photoB : t.photoC;
return (
<div
className={cn('relative overflow-hidden flex items-end', tall ? 'aspect-[3/4]' : 'aspect-square')}
style={{
background: bg,
borderRadius: t.vibe === 'arcade' ? '4px' : t.radius === '999px' ? '12px' : t.radius,
boxShadow: t.vibe === 'cinematic' ? `0 0 0 1px ${t.border}` : undefined,
}}
>
{t.vibe === 'festive' && (
<div
className="absolute inset-x-0 top-0 h-1"
style={{
background: `repeating-linear-gradient(90deg, ${t.accent} 0 6px, ${t.photoC} 6px 12px)`,
}}
/>
)}
{label && (
<span
className="m-1 text-[7px] px-1 py-0.5 font-medium"
style={{
background: `${t.surface}cc`,
color: t.text,
borderRadius: '3px',
}}
>
{label}
</span>
)}
</div>
);
}
function MockHome({ t }: { t: StyleTokens }) {
const gap = t.density === 'airy' ? 'gap-2' : t.density === 'dense' ? 'gap-1' : 'gap-1.5';
return (
<div style={shellStyle(t)} className="overflow-hidden text-left shadow-md">
<HeaderBar t={t} title="Browse our photo collection" />
<div className="px-2.5 pt-2 pb-1 flex items-center justify-between">
<div style={{ fontFamily: t.display }} className="text-[11px] font-semibold leading-tight">
{t.vibe === 'arcade'
? 'High scores tonight'
: t.vibe === 'festive'
? 'Parade wall · live'
: t.vibe === 'cinematic' && t.radius === '999px'
? 'Your constellation'
: 'This week at JRCC'}
</div>
<span className="text-[8px]" style={{ color: t.muted }}>
248 photos
</span>
</div>
{t.vibe === 'cinematic' && t.radius === '999px' ? (
<div className="relative h-[118px] mx-2 mb-2">
{[
{ x: '18%', y: '20%', s: 28 },
{ x: '48%', y: '8%', s: 36 },
{ x: '72%', y: '28%', s: 24 },
{ x: '38%', y: '55%', s: 30 },
{ x: '62%', y: '58%', s: 22 },
].map((n, i) => (
<div
key={i}
className="absolute rounded-full border"
style={{
left: n.x,
top: n.y,
width: n.s,
height: n.s,
background: i % 2 ? t.photoB : t.photoC,
borderColor: t.accent,
boxShadow: `0 0 12px ${t.accent}55`,
}}
/>
))}
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-50">
<line x1="28%" y1="30%" x2="55%" y2="20%" stroke={t.accent} strokeWidth="0.5" />
<line x1="55%" y1="20%" x2="78%" y2="38%" stroke={t.accent} strokeWidth="0.5" />
<line x1="48%" y1="22%" x2="48%" y2="62%" stroke={t.accent} strokeWidth="0.5" />
</svg>
</div>
) : (
<div className={cn('grid grid-cols-3 px-2.5 pb-2.5', gap)}>
<PhotoTile t={t} tone="A" label={t.vibe === 'festive' ? 'named' : undefined} />
<PhotoTile t={t} tone="B" />
<PhotoTile t={t} tone="C" label={t.vibe === 'arcade' ? '+120' : undefined} />
<PhotoTile t={t} tone="B" />
<PhotoTile t={t} tone="C" />
<PhotoTile t={t} tone="A" />
</div>
)}
{t.vibe === 'playful' || t.vibe === 'arcade' ? (
<div
className="mx-2.5 mb-2.5 px-2 py-1.5 flex items-center justify-between text-[9px] font-semibold"
style={{
background: t.accent,
color: t.accentText,
borderRadius: t.radius === '999px' ? '999px' : t.radius,
}}
>
<span>{t.vibe === 'arcade' ? 'START DAILY MATCH' : 'Daily challenge · 3/5'}</span>
<span></span>
</div>
) : null}
</div>
);
}
function MockPhoto({ t }: { t: StyleTokens }) {
return (
<div style={shellStyle(t)} className="overflow-hidden text-left shadow-md">
<HeaderBar t={t} title="Photo · Aug 2024" />
<div className="p-2 flex gap-2">
<div
className="flex-1 aspect-[4/3] relative"
style={{
background: `linear-gradient(145deg, ${t.photoA}, ${t.photoB})`,
borderRadius: t.radius === '999px' ? '12px' : t.radius,
}}
>
<div
className="absolute left-[28%] top-[22%] w-8 h-8 rounded-full border-2"
style={{ borderColor: t.accent, boxShadow: `0 0 0 1px ${t.accentText}` }}
/>
<div
className="absolute right-[24%] top-[36%] w-7 h-7 rounded-full border-2 opacity-70"
style={{ borderColor: t.muted }}
/>
{t.vibe === 'arcade' && (
<span
className="absolute bottom-1 left-1 text-[7px] px-1 py-0.5 font-bold"
style={{ background: t.accent, color: t.accentText }}
>
COMBO x3
</span>
)}
</div>
<div className="w-[72px] shrink-0 space-y-1.5">
<div className="text-[8px] font-semibold" style={{ fontFamily: t.display }}>
Faces
</div>
{['Anna K.', '???', 'Misha'].map((name, i) => (
<button
key={name}
type="button"
className="w-full text-left text-[8px] px-1.5 py-1 border"
style={{
background: i === 1 ? t.accent : t.surface,
color: i === 1 ? t.accentText : t.text,
borderColor: t.border,
borderRadius: t.radius === '999px' ? '999px' : '6px',
}}
>
{name}
</button>
))}
<div className="text-[7px]" style={{ color: t.muted }}>
Tap unknown to name
</div>
</div>
</div>
</div>
);
}
function MockSearch({ t }: { t: StyleTokens }) {
return (
<div style={shellStyle(t)} className="overflow-hidden text-left shadow-md">
<HeaderBar t={t} title="Search people & tags" />
<div className="p-2.5 space-y-2">
<div
className="flex items-center gap-1.5 px-2 py-1.5 text-[9px] border"
style={{
background: t.surface,
borderColor: t.border,
borderRadius: t.radius === '999px' ? '999px' : t.radius,
}}
>
<span style={{ color: t.muted }}></span>
<span style={{ color: t.muted }}>Levkin · Purim · 2019</span>
</div>
<div className="flex flex-wrap gap-1">
{['People', 'Tags', 'Year'].map((f) => (
<span
key={f}
className="text-[8px] px-1.5 py-0.5"
style={{
background: t.chip,
color: t.text,
borderRadius: t.radius === '999px' ? '999px' : '4px',
}}
>
{f}
</span>
))}
</div>
<div className="grid grid-cols-4 gap-1">
<PhotoTile t={t} tone="A" />
<PhotoTile t={t} tone="B" />
<PhotoTile t={t} tone="C" />
<PhotoTile t={t} tone="A" />
</div>
<div className="text-[8px]" style={{ color: t.muted }}>
18 matches · sorted by date
</div>
</div>
</div>
);
}
function MockIdentify({ t }: { t: StyleTokens }) {
return (
<div style={shellStyle(t)} className="overflow-hidden text-left shadow-md">
<HeaderBar t={t} title="Identify · help grow the album" />
<div className="p-2.5 flex gap-2">
<div
className="w-[88px] aspect-square shrink-0"
style={{
background: `radial-gradient(circle at 40% 35%, ${t.photoC}, ${t.photoA})`,
borderRadius: t.radius === '999px' ? '999px' : t.radius,
boxShadow: t.vibe === 'arcade' ? `0 0 16px ${t.accent}` : undefined,
}}
/>
<div className="flex-1 min-w-0 space-y-1.5">
<div className="text-[10px] font-semibold" style={{ fontFamily: t.display }}>
{t.vibe === 'arcade' ? 'WHO IS THIS?' : 'Who is this person?'}
</div>
<div
className="h-6 border px-1.5 flex items-center text-[8px]"
style={{
borderColor: t.border,
background: t.surface,
borderRadius: t.radius === '999px' ? '999px' : '6px',
color: t.muted,
}}
>
Type a name
</div>
<div className="grid grid-cols-2 gap-1">
<div
className="h-5 border px-1 flex items-center text-[7px]"
style={{ borderColor: t.border, borderRadius: '4px', color: t.muted }}
>
email (opt)
</div>
<div
className="h-5 border px-1 flex items-center text-[7px]"
style={{ borderColor: t.border, borderRadius: '4px', color: t.muted }}
>
phone (opt)
</div>
</div>
<button
type="button"
className="w-full text-[9px] font-bold py-1"
style={{
background: t.accent,
color: t.accentText,
borderRadius: t.radius === '999px' ? '999px' : t.radius,
}}
>
{t.vibe === 'arcade' ? 'LOCK IN +50' : 'Submit for approval'}
</button>
</div>
</div>
</div>
);
}
function MockUpload({ t }: { t: StyleTokens }) {
return (
<div style={shellStyle(t)} className="overflow-hidden text-left shadow-md">
<HeaderBar t={t} title="Upload photos" />
<div className="p-2.5">
<div
className="border-2 border-dashed px-3 py-5 text-center"
style={{
borderColor: t.accent,
background: `${t.surface}`,
borderRadius: t.radius === '999px' ? '16px' : t.radius,
}}
>
<div className="text-[11px] font-semibold" style={{ fontFamily: t.display }}>
{t.vibe === 'festive' ? 'Dump the parade here' : 'Drop photos'}
</div>
<div className="text-[8px] mt-1" style={{ color: t.muted }}>
JPG · PNG · HEIC · up to 50
</div>
<button
type="button"
className="mt-2 text-[8px] font-semibold px-2.5 py-1"
style={{
background: t.accent,
color: t.accentText,
borderRadius: t.radius === '999px' ? '999px' : '6px',
}}
>
Choose files
</button>
</div>
<div className="mt-2 flex gap-1">
<PhotoTile t={t} tone="A" />
<PhotoTile t={t} tone="B" />
<PhotoTile t={t} tone="C" />
</div>
</div>
</div>
);
}
function MockLogin({ t }: { t: StyleTokens }) {
return (
<div style={shellStyle(t)} className="overflow-hidden text-left shadow-md">
<div className="px-3 pt-4 pb-3 text-center">
<div
className="text-[12px] font-bold"
style={{ fontFamily: t.display, color: t.accent }}
>
JRCC Gallery
</div>
<div className="text-[8px] mt-0.5" style={{ color: t.muted }}>
Sign in to tag faces & upload
</div>
</div>
<div className="px-3 pb-3 space-y-1.5">
{['Email', 'Password'].map((label) => (
<div
key={label}
className="h-7 border px-2 flex items-center text-[8px]"
style={{
borderColor: t.border,
background: t.surface,
borderRadius: t.radius === '999px' ? '999px' : '6px',
color: t.muted,
}}
>
{label}
</div>
))}
<button
type="button"
className="w-full text-[9px] font-bold py-1.5 mt-1"
style={{
background: t.accent,
color: t.accentText,
borderRadius: t.radius === '999px' ? '999px' : t.radius,
}}
>
Sign in
</button>
<div className="text-center text-[7px]" style={{ color: t.muted }}>
New here? Create an account
</div>
</div>
</div>
);
}
const MOCKS: Record<PageTypeId, (props: { t: StyleTokens }) => ReactNode> = {
home: MockHome,
photo: MockPhoto,
search: MockSearch,
identify: MockIdentify,
upload: MockUpload,
login: MockLogin,
};
export function StylePageGallery({ style }: { style: StyleOption }) {
const t = style.tokens;
return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Object.entries(MOCKS).map(([id, Mock]) => {
const meta = (
[
['home', 'Home / browse'],
['photo', 'Photo viewer'],
['search', 'Search'],
['identify', 'Identify face'],
['upload', 'Upload'],
['login', 'Login'],
] as const
).find(([k]) => k === id)!;
return (
<div key={id} className="space-y-1.5">
<div className="flex items-center justify-between px-0.5">
<span className="text-[11px] font-medium text-foreground">{meta[1]}</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
mock
</span>
</div>
<Mock t={t} />
</div>
);
})}
</div>
);
}
+129 -221
View File
@@ -5,199 +5,26 @@ import Link from 'next/link';
import { ThemeToggle } from '@/components/ThemeToggle';
import { JrccLogo } from '@/components/JrccLogo';
import { cn } from '@/lib/utils';
import { StylePageGallery } from './PageMockups';
import { STYLES, type StyleId } from './styleOptions';
const STORAGE_KEY = 'jrcc-gallery-style-pick';
type StyleId =
| 'flame-forum'
| 'memory-lane'
| 'face-challenge'
| 'reunion-pulse'
| 'story-shelves'
| 'soft-salon'
| 'night-gallery'
| 'digest-desk'
| 'cluster-lab';
type StyleOption = {
id: StyleId;
name: string;
tagline: string;
whyComeBack: string;
palette: string[];
fonts: string;
mood: string;
previewClass: string;
accentClass: string;
sampleTitle: string;
sampleSub: string;
};
const STYLES: StyleOption[] = [
{
id: 'flame-forum',
name: 'Flame & Forum',
tagline: 'Community board energy',
whyComeBack: 'See who tagged faces this week and jump into open unknowns.',
palette: ['#0B1F4B', '#1E40AF', '#F5C518', '#F8FAFC'],
fonts: 'Display: Manrope · Body: Source Sans 3',
mood: 'Confident JRCC blue/gold, notice-board rhythm',
previewClass: 'bg-[#0B1F4B] text-white',
accentClass: 'bg-[#F5C518] text-[#0B1F4B]',
sampleTitle: "Who's new this week",
sampleSub: '12 faces waiting · 4 people tagged',
},
{
id: 'memory-lane',
name: 'Memory Lane',
tagline: 'On this day, years ago',
whyComeBack: 'Daily nostalgia: same date across decades of community photos.',
palette: ['#2A2118', '#C4A484', '#F3EDE4', '#8B4513'],
fonts: 'Display: Fraunces · Body: Literata',
mood: 'Warm archival paper, soft film grain',
previewClass: 'bg-[#F3EDE4] text-[#2A2118]',
accentClass: 'bg-[#8B4513] text-[#F3EDE4]',
sampleTitle: 'On this day · Aug 4',
sampleSub: 'Photos from 1998 · 2007 · 2019',
},
{
id: 'face-challenge',
name: 'Face Challenge',
tagline: 'Five unknowns a day',
whyComeBack: 'A short daily streak: help name faces, climb a light scoreboard.',
palette: ['#111827', '#22D3EE', '#F472B6', '#F9FAFB'],
fonts: 'Display: Space Grotesk · Body: IBM Plex Sans',
mood: 'Playful streak UI, big face crops, one-tap suggest',
previewClass: 'bg-[#111827] text-white',
accentClass: 'bg-[#22D3EE] text-[#111827]',
sampleTitle: 'Daily challenge · 3 / 5',
sampleSub: 'Streak: 6 days · Keep going',
},
{
id: 'reunion-pulse',
name: 'Reunion Pulse',
tagline: 'Event mode progress',
whyComeBack: 'Live “faces still unknown” bar after weddings, picnics, holidays.',
palette: ['#7F1D1D', '#FCA5A5', '#FEF2F2', '#1F2937'],
fonts: 'Display: Sora · Body: DM Sans',
mood: 'Celebration meter, upload dump → clear the queue',
previewClass: 'bg-[#FEF2F2] text-[#7F1D1D]',
accentClass: 'bg-[#7F1D1D] text-white',
sampleTitle: 'Purim 2026 album',
sampleSub: '68% identified · 41 faces left',
},
{
id: 'story-shelves',
name: 'Story Shelves',
tagline: 'People as timelines',
whyComeBack: 'Open a person and scroll their life across events — not crop grids.',
palette: ['#1C1917', '#A8A29E', '#FAFAF9', '#0EA5E9'],
fonts: 'Display: Newsreader · Body: Karla',
mood: 'Editorial shelves, chapter headings per decade',
previewClass: 'bg-[#FAFAF9] text-[#1C1917]',
accentClass: 'bg-[#0EA5E9] text-white',
sampleTitle: 'Anna K. · Timeline',
sampleSub: '1989 → Toronto · 112 photos',
},
{
id: 'soft-salon',
name: 'Soft Salon',
tagline: 'Calm museum gallery',
whyComeBack: 'Quiet browsing with generous space — invite family without noise.',
palette: ['#F7F5F2', '#3F3A36', '#9A8F84', '#D6CFC6'],
fonts: 'Display: Cormorant Garamond · Body: Outfit',
mood: 'Breathing room, soft stone, restrained motion',
previewClass: 'bg-[#F7F5F2] text-[#3F3A36]',
accentClass: 'bg-[#3F3A36] text-[#F7F5F2]',
sampleTitle: 'Collection',
sampleSub: 'Search by people · seasons · places',
},
{
id: 'night-gallery',
name: 'Night Gallery',
tagline: 'Dark-first cinema',
whyComeBack: 'Evening browsing: luminous photos on deep navy, focus on faces.',
palette: ['#020617', '#1E3A8A', '#E2E8F0', '#FBBF24'],
fonts: 'Display: Syne · Body: Figtree',
mood: 'Cinematic dark, gold spark accents',
previewClass: 'bg-[#020617] text-[#E2E8F0]',
accentClass: 'bg-[#FBBF24] text-[#020617]',
sampleTitle: 'Evening gallery',
sampleSub: 'Favorites · recently tagged',
},
{
id: 'digest-desk',
name: 'Digest Desk',
tagline: 'Reasons to open mail',
whyComeBack: 'Weekly digest preview in-app: 3 photos + one “who is this?”',
palette: ['#0F172A', '#334155', '#F1F5F9', '#EA580C'],
fonts: 'Display: Libre Baskerville · Body: Public Sans',
mood: 'Editorial inbox, clip + caption cards',
previewClass: 'bg-[#F1F5F9] text-[#0F172A]',
accentClass: 'bg-[#EA580C] text-white',
sampleTitle: 'This weeks digest',
sampleSub: '3 memories · 1 mystery face',
},
{
id: 'cluster-lab',
name: 'Cluster Lab',
tagline: 'Group lookalikes',
whyComeBack: 'Name one stranger → cascade to a cluster of similar unknowns.',
palette: ['#042F2E', '#14B8A6', '#CCFBF1', '#134E4A'],
fonts: 'Display: Rubik · Body: Nunito Sans',
mood: 'Lab grid of face clusters, teal precision',
previewClass: 'bg-[#042F2E] text-[#CCFBF1]',
accentClass: 'bg-[#14B8A6] text-[#042F2E]',
sampleTitle: 'Cluster · 9 similar faces',
sampleSub: 'Name one to link the rest',
},
];
function StylePreview({ style }: { style: StyleOption }) {
return (
<div className={cn('rounded-xl overflow-hidden border border-black/10 shadow-sm', style.previewClass)}>
<div className="px-3 pt-3 pb-2 flex items-center justify-between gap-2">
<div className="text-[10px] uppercase tracking-[0.14em] opacity-70">JRCC gallery</div>
<span className={cn('text-[10px] font-semibold px-2 py-0.5 rounded-full', style.accentClass)}>
preview
</span>
</div>
<div className="px-3 pb-3">
<div className="text-sm font-semibold leading-tight">{style.sampleTitle}</div>
<div className="text-[11px] opacity-75 mt-0.5">{style.sampleSub}</div>
<div className="mt-3 grid grid-cols-3 gap-1.5">
{[0, 1, 2].map((i) => (
<div
key={i}
className="aspect-square rounded-md bg-black/10 dark:bg-white/10 flex items-center justify-center text-[10px] opacity-60"
>
photo
</div>
))}
</div>
<div className="mt-3 flex gap-1.5">
{style.palette.map((c) => (
<span
key={c}
title={c}
className="h-3 w-3 rounded-full border border-black/15"
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
</div>
);
}
export default function StylesPickerClient() {
const [picked, setPicked] = useState<StyleId | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<StyleId>>(() => new Set());
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem(STORAGE_KEY) as StyleId | null;
if (saved && STYLES.some((s) => s.id === saved)) {
setPicked(saved);
setExpandedIds(new Set([saved]));
} else {
// Open the three NEW styles so the exciting options are visible immediately
setExpandedIds(
new Set(STYLES.filter((s) => s.isNew).map((s) => s.id))
);
}
setHydrated(true);
}, []);
@@ -207,15 +34,28 @@ export default function StylesPickerClient() {
[picked]
);
const toggleExpanded = (id: StyleId) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const expandAll = () => setExpandedIds(new Set(STYLES.map((s) => s.id)));
const collapseAll = () => setExpandedIds(new Set());
const choose = (id: StyleId) => {
setPicked(id);
setExpandedIds((prev) => new Set(prev).add(id));
window.localStorage.setItem(STORAGE_KEY, id);
};
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur">
<div className="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between gap-3">
<div className="mx-auto max-w-7xl px-4 py-3 flex items-center justify-between gap-3">
<Link href="/" aria-label="JRCC home" className="min-w-0">
<JrccLogo heightClassName="h-10" />
</Link>
@@ -231,23 +71,40 @@ export default function StylesPickerClient() {
</div>
</header>
<main id="main-content" className="mx-auto max-w-6xl px-4 py-8">
<div className="max-w-2xl">
<main id="main-content" className="mx-auto max-w-7xl px-4 py-8">
<div className="max-w-3xl">
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground mb-2">
Design lab · pick one
Design lab · 12 directions
</p>
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight">
Nine styles for a gallery people return to
See each style on every page
</h1>
<p className="mt-3 text-muted-foreground leading-relaxed">
Each card is a direction for more excitement and habit not a full rebuild yet.
Toggle light/dark above to check the JRCC logos. Tap a style to save your pick
(stored in this browser only).
Not just swatches each direction shows Home, Photo, Search, Identify, Upload, and
Login. Expand a style, scroll the mockups, then tap <strong>Pick this style</strong>.
Three new high-energy options are marked <span className="text-foreground font-medium">NEW</span>.
</p>
</div>
<div className="mt-6 flex flex-wrap gap-2">
<button
type="button"
onClick={expandAll}
className="text-sm px-3 py-1.5 rounded-md border hover:bg-muted"
>
Expand all page mockups
</button>
<button
type="button"
onClick={collapseAll}
className="text-sm px-3 py-1.5 rounded-md border hover:bg-muted"
>
Collapse all
</button>
</div>
{hydrated && selected && (
<div className="mt-6 rounded-xl border bg-card p-4 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
<div className="mt-4 rounded-xl border bg-card p-4 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">Your pick</div>
<div className="font-semibold text-lg">{selected.name}</div>
@@ -266,58 +123,109 @@ export default function StylesPickerClient() {
</div>
)}
<div className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="mt-8 space-y-4">
{STYLES.map((style, index) => {
const isSelected = picked === style.id;
const isOpen = expandedIds.has(style.id);
return (
<button
<section
key={style.id}
type="button"
onClick={() => choose(style.id)}
className={cn(
'text-left rounded-2xl border p-3 transition-all hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isSelected ? 'border-primary ring-2 ring-primary/40' : 'border-border'
'rounded-2xl border transition-shadow',
isSelected ? 'border-primary ring-2 ring-primary/30 shadow-md' : 'border-border',
isOpen && 'shadow-sm'
)}
>
<div className="flex items-baseline justify-between gap-2 mb-2 px-0.5">
<span className="text-xs text-muted-foreground tabular-nums">
{String(index + 1).padStart(2, '0')}
</span>
{isSelected && (
<span className="text-xs font-medium text-primary">Selected</span>
)}
<div className="p-4 sm:p-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground tabular-nums">
{String(index + 1).padStart(2, '0')}
</span>
{style.isNew && (
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-700 dark:text-amber-300">
New
</span>
)}
{isSelected && (
<span className="text-xs font-medium text-primary">Selected</span>
)}
</div>
<h2 className="text-xl font-semibold tracking-tight">{style.name}</h2>
<p className="text-sm text-muted-foreground">{style.tagline}</p>
<p className="mt-2 text-sm leading-snug max-w-2xl">{style.whyComeBack}</p>
<p className="mt-2 text-[11px] text-muted-foreground">
{style.fonts} · {style.mood}
</p>
<div className="mt-2 flex gap-1.5">
{[
style.tokens.bg,
style.tokens.accent,
style.tokens.photoB,
style.tokens.photoC,
].map((c) => (
<span
key={c}
title={c}
className="h-3.5 w-3.5 rounded-full border border-black/15"
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex flex-wrap gap-2 shrink-0">
<button
type="button"
onClick={() => toggleExpanded(style.id)}
className="text-sm px-3 py-1.5 rounded-md border hover:bg-muted"
aria-expanded={isOpen}
>
{isOpen ? 'Hide page mockups' : 'Show all pages'}
</button>
<button
type="button"
onClick={() => choose(style.id)}
className={cn(
'text-sm px-3 py-1.5 rounded-md font-medium',
isSelected
? 'bg-primary text-primary-foreground'
: 'bg-foreground text-background hover:opacity-90'
)}
>
{isSelected ? 'Picked' : 'Pick this style'}
</button>
</div>
</div>
<StylePreview style={style} />
<div className="mt-3 px-0.5">
<h2 className="font-semibold text-base">{style.name}</h2>
<p className="text-sm text-muted-foreground">{style.tagline}</p>
<p className="mt-2 text-sm leading-snug">{style.whyComeBack}</p>
<p className="mt-2 text-[11px] text-muted-foreground">{style.fonts}</p>
<p className="text-[11px] text-muted-foreground">{style.mood}</p>
</div>
</button>
{isOpen && (
<div className="px-4 sm:px-5 pb-5 border-t bg-muted/20">
<p className="pt-4 pb-3 text-xs text-muted-foreground">
How <strong className="text-foreground">{style.name}</strong> would feel on
each screen (wireframe mockups not live theme yet).
</p>
<StylePageGallery style={style} />
</div>
)}
</section>
);
})}
</div>
<section className="mt-12 mb-8 rounded-2xl border p-5 bg-muted/30">
<h2 className="font-semibold text-lg">Logo usage (this pass)</h2>
<h2 className="font-semibold text-lg">Logo usage (already live)</h2>
<ul className="mt-2 text-sm text-muted-foreground space-y-1.5 list-disc pl-5">
<li>
<strong className="text-foreground">Light mode header:</strong> classic black
bilingual JRCC mark (`jrcc-logo-light.png`)
<strong className="text-foreground">Light header:</strong> black bilingual JRCC
</li>
<li>
<strong className="text-foreground">Dark mode header:</strong> white bilingual
mark on transparent (`jrcc-logo-dark.png`)
<strong className="text-foreground">Dark header:</strong> white bilingual, transparent
</li>
<li>
<strong className="text-foreground">Photo watermark:</strong> translucent JRCC
overlay for guest downloads (`jrcc-watermark.png`)
<strong className="text-foreground">Guest watermark:</strong> translucent JRCC overlay
</li>
</ul>
<p className="mt-3 text-sm text-muted-foreground">
Tell us which style number wins and we&apos;ll implement that direction next.
Tell us the winning style number (0112) and we&apos;ll implement that direction next.
</p>
</section>
</main>
+13 -2
View File
@@ -3,9 +3,20 @@ import StylesPickerClient from './StylesPickerClient';
export const metadata: Metadata = {
title: 'JRCC gallery — style picker',
description: 'Pick a visual direction for the JRCC photo gallery redesign.',
description:
'Compare 12 redesign directions with page mockups for Home, Photo, Search, Identify, Upload, and Login.',
};
export default function StylesPage() {
return <StylesPickerClient />;
return (
<>
{/* Preview fonts for style mockups only */}
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=Chakra+Petch:wght@400;600;700&family=Cormorant+Garamond:wght@600&family=DM+Sans:wght@400;600&family=Figtree:wght@400;600&family=Fraunces:opsz,wght@9..144,600&family=IBM+Plex+Sans:wght@400;600&family=Instrument+Serif&family=Karla:wght@400;600&family=Libre+Baskerville:wght@700&family=Literata:opsz,wght@7..72,400;7..72,600&family=Manrope:wght@600;700&family=Newsreader:opsz,wght@6..72,600&family=Nunito+Sans:wght@400;700&family=Outfit:wght@400;600&family=Press+Start+2P&family=Public+Sans:wght@400;600&family=Rubik:wght@500;700&family=Sora:wght@600&family=Source+Sans+3:wght@400;600&family=Space+Grotesk:wght@500;700&family=Syne:wght@700&display=swap"
/>
<StylesPickerClient />
</>
);
}
+372
View File
@@ -0,0 +1,372 @@
export type StyleId =
| 'flame-forum'
| 'memory-lane'
| 'face-challenge'
| 'reunion-pulse'
| 'story-shelves'
| 'soft-salon'
| 'night-gallery'
| 'digest-desk'
| 'cluster-lab'
| 'matchmaker-arcade'
| 'parade-mosaic'
| 'orbit-kin';
export type StyleTokens = {
bg: string;
surface: string;
text: string;
muted: string;
accent: string;
accentText: string;
border: string;
chip: string;
photoA: string;
photoB: string;
photoC: string;
radius: string;
display: string;
body: string;
density: 'airy' | 'cozy' | 'dense';
vibe: 'editorial' | 'playful' | 'cinematic' | 'festive' | 'lab' | 'arcade';
};
export type StyleOption = {
id: StyleId;
name: string;
tagline: string;
whyComeBack: string;
fonts: string;
mood: string;
isNew?: boolean;
tokens: StyleTokens;
};
export const PAGE_TYPES = [
{ id: 'home', label: 'Home / browse' },
{ id: 'photo', label: 'Photo viewer' },
{ id: 'search', label: 'Search' },
{ id: 'identify', label: 'Identify face' },
{ id: 'upload', label: 'Upload' },
{ id: 'login', label: 'Login' },
] as const;
export type PageTypeId = (typeof PAGE_TYPES)[number]['id'];
export const STYLES: StyleOption[] = [
{
id: 'flame-forum',
name: 'Flame & Forum',
tagline: 'Community board energy',
whyComeBack: 'See who tagged faces this week and jump into open unknowns.',
fonts: 'Display: Manrope · Body: Source Sans 3',
mood: 'Confident JRCC blue/gold, notice-board rhythm',
tokens: {
bg: '#0B1F4B',
surface: '#132A5C',
text: '#F8FAFC',
muted: '#93C5FD',
accent: '#F5C518',
accentText: '#0B1F4B',
border: 'rgba(245,197,24,0.35)',
chip: '#1E40AF',
photoA: '#1E3A8A',
photoB: '#2563EB',
photoC: '#FBBF24',
radius: '10px',
display: '"Manrope", system-ui, sans-serif',
body: '"Source Sans 3", system-ui, sans-serif',
density: 'cozy',
vibe: 'editorial',
},
},
{
id: 'memory-lane',
name: 'Memory Lane',
tagline: 'On this day, years ago',
whyComeBack: 'Daily nostalgia: same date across decades of community photos.',
fonts: 'Display: Fraunces · Body: Literata',
mood: 'Warm archival paper, soft film grain',
tokens: {
bg: '#F3EDE4',
surface: '#EDE4D6',
text: '#2A2118',
muted: '#7A6550',
accent: '#8B4513',
accentText: '#F3EDE4',
border: 'rgba(42,33,24,0.18)',
chip: '#C4A484',
photoA: '#D6C3A8',
photoB: '#B8956E',
photoC: '#8B6914',
radius: '4px',
display: '"Fraunces", Georgia, serif',
body: '"Literata", Georgia, serif',
density: 'airy',
vibe: 'editorial',
},
},
{
id: 'face-challenge',
name: 'Face Challenge',
tagline: 'Five unknowns a day',
whyComeBack: 'A short daily streak: help name faces, climb a light scoreboard.',
fonts: 'Display: Space Grotesk · Body: IBM Plex Sans',
mood: 'Playful streak UI, big face crops, one-tap suggest',
tokens: {
bg: '#111827',
surface: '#1F2937',
text: '#F9FAFB',
muted: '#9CA3AF',
accent: '#22D3EE',
accentText: '#111827',
border: 'rgba(34,211,238,0.35)',
chip: '#F472B6',
photoA: '#374151',
photoB: '#0891B2',
photoC: '#DB2777',
radius: '16px',
display: '"Space Grotesk", system-ui, sans-serif',
body: '"IBM Plex Sans", system-ui, sans-serif',
density: 'cozy',
vibe: 'playful',
},
},
{
id: 'reunion-pulse',
name: 'Reunion Pulse',
tagline: 'Event mode progress',
whyComeBack: 'Live “faces still unknown” bar after weddings, picnics, holidays.',
fonts: 'Display: Sora · Body: DM Sans',
mood: 'Celebration meter, upload dump → clear the queue',
tokens: {
bg: '#FEF2F2',
surface: '#FFFFFF',
text: '#7F1D1D',
muted: '#B91C1C',
accent: '#7F1D1D',
accentText: '#FEF2F2',
border: 'rgba(127,29,29,0.2)',
chip: '#FCA5A5',
photoA: '#FECACA',
photoB: '#F87171',
photoC: '#991B1B',
radius: '14px',
display: '"Sora", system-ui, sans-serif',
body: '"DM Sans", system-ui, sans-serif',
density: 'cozy',
vibe: 'festive',
},
},
{
id: 'story-shelves',
name: 'Story Shelves',
tagline: 'People as timelines',
whyComeBack: 'Open a person and scroll their life across events — not crop grids.',
fonts: 'Display: Newsreader · Body: Karla',
mood: 'Editorial shelves, chapter headings per decade',
tokens: {
bg: '#FAFAF9',
surface: '#FFFFFF',
text: '#1C1917',
muted: '#78716C',
accent: '#0EA5E9',
accentText: '#FFFFFF',
border: 'rgba(28,25,23,0.12)',
chip: '#E7E5E4',
photoA: '#D6D3D1',
photoB: '#A8A29E',
photoC: '#0EA5E9',
radius: '2px',
display: '"Newsreader", Georgia, serif',
body: '"Karla", system-ui, sans-serif',
density: 'airy',
vibe: 'editorial',
},
},
{
id: 'soft-salon',
name: 'Soft Salon',
tagline: 'Calm museum gallery',
whyComeBack: 'Quiet browsing with generous space — invite family without noise.',
fonts: 'Display: Cormorant Garamond · Body: Outfit',
mood: 'Breathing room, soft stone, restrained motion',
tokens: {
bg: '#F7F5F2',
surface: '#FFFFFF',
text: '#3F3A36',
muted: '#9A8F84',
accent: '#3F3A36',
accentText: '#F7F5F2',
border: 'rgba(63,58,54,0.14)',
chip: '#D6CFC6',
photoA: '#E8E2DA',
photoB: '#C9BFB4',
photoC: '#8A8076',
radius: '20px',
display: '"Cormorant Garamond", Georgia, serif',
body: '"Outfit", system-ui, sans-serif',
density: 'airy',
vibe: 'editorial',
},
},
{
id: 'night-gallery',
name: 'Night Gallery',
tagline: 'Dark-first cinema',
whyComeBack: 'Evening browsing: luminous photos on deep navy, focus on faces.',
fonts: 'Display: Syne · Body: Figtree',
mood: 'Cinematic dark, gold spark accents',
tokens: {
bg: '#020617',
surface: '#0F172A',
text: '#E2E8F0',
muted: '#94A3B8',
accent: '#FBBF24',
accentText: '#020617',
border: 'rgba(251,191,36,0.28)',
chip: '#1E3A8A',
photoA: '#1E293B',
photoB: '#1E3A8A',
photoC: '#B45309',
radius: '12px',
display: '"Syne", system-ui, sans-serif',
body: '"Figtree", system-ui, sans-serif',
density: 'dense',
vibe: 'cinematic',
},
},
{
id: 'digest-desk',
name: 'Digest Desk',
tagline: 'Reasons to open mail',
whyComeBack: 'Weekly digest preview in-app: 3 photos + one “who is this?”',
fonts: 'Display: Libre Baskerville · Body: Public Sans',
mood: 'Editorial inbox, clip + caption cards',
tokens: {
bg: '#F1F5F9',
surface: '#FFFFFF',
text: '#0F172A',
muted: '#64748B',
accent: '#EA580C',
accentText: '#FFFFFF',
border: 'rgba(15,23,42,0.12)',
chip: '#E2E8F0',
photoA: '#CBD5E1',
photoB: '#94A3B8',
photoC: '#EA580C',
radius: '8px',
display: '"Libre Baskerville", Georgia, serif',
body: '"Public Sans", system-ui, sans-serif',
density: 'cozy',
vibe: 'editorial',
},
},
{
id: 'cluster-lab',
name: 'Cluster Lab',
tagline: 'Group lookalikes',
whyComeBack: 'Name one stranger → cascade to a cluster of similar unknowns.',
fonts: 'Display: Rubik · Body: Nunito Sans',
mood: 'Lab grid of face clusters, teal precision',
tokens: {
bg: '#042F2E',
surface: '#134E4A',
text: '#CCFBF1',
muted: '#5EEAD4',
accent: '#14B8A6',
accentText: '#042F2E',
border: 'rgba(20,184,166,0.4)',
chip: '#0F766E',
photoA: '#115E59',
photoB: '#14B8A6',
photoC: '#99F6E4',
radius: '10px',
display: '"Rubik", system-ui, sans-serif',
body: '"Nunito Sans", system-ui, sans-serif',
density: 'dense',
vibe: 'lab',
},
},
{
id: 'matchmaker-arcade',
name: 'Matchmaker Arcade',
tagline: 'Name faces like a high-score game',
whyComeBack: 'Combo multipliers, boss “mystery faces,” and weekly JRCC leaderboards.',
fonts: 'Display: Press Start 2P · Body: Chakra Petch',
mood: 'Cabinet glow, chunky CTAs, score pops on every ID',
isNew: true,
tokens: {
bg: '#1A0533',
surface: '#2D0B55',
text: '#F5E9FF',
muted: '#C4B5FD',
accent: '#A3E635',
accentText: '#14532D',
border: 'rgba(163,230,53,0.45)',
chip: '#7C3AED',
photoA: '#4C1D95',
photoB: '#DB2777',
photoC: '#A3E635',
radius: '6px',
display: '"Press Start 2P", monospace',
body: '"Chakra Petch", system-ui, sans-serif',
density: 'dense',
vibe: 'arcade',
},
},
{
id: 'parade-mosaic',
name: 'Parade Mosaic',
tagline: 'Event dump → living mural',
whyComeBack: 'After Purim/picnic uploads, the wall animates as faces get named.',
fonts: 'Display: Bricolage Grotesque · Body: Atkinson Hyperlegible',
mood: 'Festival stripes, diagonal banners, mosaic tiles that unlock',
isNew: true,
tokens: {
bg: '#FFF7ED',
surface: '#FFEDD5',
text: '#9A3412',
muted: '#C2410C',
accent: '#0369A1',
accentText: '#F0F9FF',
border: 'rgba(154,52,18,0.22)',
chip: '#FDBA74',
photoA: '#FB923C',
photoB: '#0EA5E9',
photoC: '#E11D48',
radius: '18px',
display: '"Bricolage Grotesque", system-ui, sans-serif',
body: '"Atkinson Hyperlegible", system-ui, sans-serif',
density: 'cozy',
vibe: 'festive',
},
},
{
id: 'orbit-kin',
name: 'Orbit Kin',
tagline: 'Family as a constellation',
whyComeBack: 'Tap a person → orbit their relatives & event clusters across decades.',
fonts: 'Display: Instrument Serif · Body: Geist',
mood: 'Ink-sky map, copper trails, quiet wow for multi-gen families',
isNew: true,
tokens: {
bg: '#071018',
surface: '#0E1A24',
text: '#E8F1F5',
muted: '#7A93A3',
accent: '#D4A373',
accentText: '#1C1208',
border: 'rgba(212,163,115,0.35)',
chip: '#1E3A47',
photoA: '#16303C',
photoB: '#2A5160',
photoC: '#D4A373',
radius: '999px',
display: '"Instrument Serif", Georgia, serif',
body: '"Geist", system-ui, sans-serif',
density: 'airy',
vibe: 'cinematic',
},
},
];