Add visual polish and micro-interactions for editorial feel
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 34s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m15s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

Phase 1 - Critical Fixes:
- EmptyState: warm palette, coral button, Playfair Display title
- Pagination: design system colors, coral active state
- LoadingSkeleton: warm shimmer with coral tint

Phase 2 - Signature Patterns:
- Navigation: sliding underline hover effect on links
- globals.css: increased noise texture opacity for paper feel
- RankingsView: alternating row backgrounds
- HomeView: decorative coral bar under section headings

Phase 3 - Polish:
- SchoolCard: SVG trend icons replacing unicode arrows
- RankingsView: styled metallic rank badges replacing emoji medals

Phase 4 - Micro-interactions:
- Navigation badge: pop animation when count changes
- HomeView grid: staggered entry animation for cards

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-02-03 14:12:48 +00:00
parent d22275bfe0
commit 18964a34a2
10 changed files with 257 additions and 64 deletions

View File

@@ -67,7 +67,7 @@ body {
min-height: 100vh;
}
/* Subtle noise texture overlay */
/* Subtle noise texture overlay - editorial paper feel */
.noise-overlay {
position: fixed;
top: 0;
@@ -75,7 +75,7 @@ body {
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0.03;
opacity: 0.06;
z-index: 1000;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}

View File

@@ -5,43 +5,53 @@
justify-content: center;
padding: 4rem 2rem;
text-align: center;
background: white;
border: 2px dashed #e5e7eb;
border-radius: 12px;
background: var(--bg-card, white);
border: 2px solid var(--border-color, #e5dfd5);
border-radius: 16px;
min-height: 400px;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.icon {
color: #d1d5db;
color: var(--text-muted, #8a847a);
margin-bottom: 1.5rem;
opacity: 0.7;
}
.title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.message {
margin: 0 0 1.5rem 0;
margin: 0 0 2rem 0;
font-size: 1rem;
color: #6b7280;
color: var(--text-secondary, #5c564d);
max-width: 500px;
line-height: 1.6;
}
.button {
padding: 0.75rem 1.5rem;
padding: 0.875rem 2rem;
font-size: 1rem;
font-weight: 500;
background: #3b82f6;
font-weight: 600;
background: var(--accent-coral, #e07256);
color: white;
border: none;
border-radius: 6px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.button:hover {
background: #2563eb;
background: var(--accent-coral-dark, #c45a3f);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(224, 114, 86, 0.3);
}
.button:active {
transform: translateY(0);
}

View File

@@ -12,11 +12,12 @@
}
.heroTitle {
font-size: 2.75rem;
font-size: 3rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 1rem;
line-height: 1.2;
line-height: 1.1;
letter-spacing: -0.02em;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
@@ -58,11 +59,22 @@
.sectionHeader h2 {
font-size: 1.875rem;
font-weight: 700;
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
color: var(--text-primary, #1a1612);
font-family: var(--font-playfair), 'Playfair Display', serif;
}
/* Decorative coral bar under section headings */
.sectionHeader h2::after {
content: '';
display: block;
width: 60px;
height: 3px;
background: var(--accent-coral, #e07256);
border-radius: 2px;
margin-top: 0.75rem;
}
.sectionDescription {
font-size: 1rem;
color: var(--text-secondary, #5c564d);
@@ -102,6 +114,33 @@
margin-bottom: 2rem;
}
/* Staggered grid entry animation */
.grid > * {
animation: gridItemFadeIn 0.4s ease-out both;
}
.grid > *:nth-child(1) { animation-delay: 0ms; }
.grid > *:nth-child(2) { animation-delay: 50ms; }
.grid > *:nth-child(3) { animation-delay: 100ms; }
.grid > *:nth-child(4) { animation-delay: 150ms; }
.grid > *:nth-child(5) { animation-delay: 200ms; }
.grid > *:nth-child(6) { animation-delay: 250ms; }
.grid > *:nth-child(7) { animation-delay: 300ms; }
.grid > *:nth-child(8) { animation-delay: 350ms; }
.grid > *:nth-child(9) { animation-delay: 400ms; }
.grid > *:nth-child(n+10) { animation-delay: 450ms; }
@keyframes gridItemFadeIn {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.emptyState {
text-align: center;
padding: 4rem 2rem;

View File

@@ -5,17 +5,23 @@
}
.skeletonCard {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.skeleton {
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background: linear-gradient(
90deg,
var(--bg-secondary, #f3ede4) 25%,
rgba(224, 114, 86, 0.08) 50%,
var(--bg-secondary, #f3ede4) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 6px;
}
@keyframes shimmer {
@@ -50,8 +56,8 @@
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: #f9fafb;
border-radius: 6px;
background: var(--bg-secondary, #f3ede4);
border-radius: 8px;
}
.metric {
@@ -76,10 +82,11 @@
}
.skeletonListItem {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 12px;
padding: 1.5rem;
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
}
.listTitle {

View File

@@ -63,16 +63,39 @@
transition: all 0.2s ease;
}
/* Sliding underline effect */
.navLink::after {
content: '';
position: absolute;
bottom: 4px;
left: 1rem;
right: 1rem;
height: 2px;
background: var(--accent-coral, #e07256);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.25s ease;
border-radius: 1px;
}
.navLink:hover {
color: var(--text-primary, #1a1612);
background: var(--bg-secondary, #f3ede4);
}
.navLink:hover::after {
transform: scaleX(1);
}
.navLink.active {
color: var(--accent-coral, #e07256);
background: rgba(224, 114, 86, 0.1);
}
.navLink.active::after {
transform: scaleX(1);
}
.badge {
display: inline-flex;
align-items: center;
@@ -85,6 +108,22 @@
color: white;
background: var(--accent-coral, #e07256);
border-radius: 9999px;
animation: badgePop 0.3s ease-out;
box-shadow: 0 2px 6px rgba(224, 114, 86, 0.4);
}
@keyframes badgePop {
0% {
transform: scale(0.6);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 640px) {

View File

@@ -8,7 +8,7 @@
.info {
font-size: 0.875rem;
color: #6b7280;
color: var(--text-muted, #8a847a);
}
.controls {
@@ -21,17 +21,18 @@
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 6px;
background: var(--bg-card, white);
color: var(--text-secondary, #5c564d);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.navButton:hover:not(:disabled) {
background: #f9fafb;
border-color: #9ca3af;
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
color: var(--text-primary, #1a1612);
}
.navButton:disabled {
@@ -50,26 +51,32 @@
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
background: var(--bg-card, white);
border: 1px solid var(--border-color, #e5dfd5);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.pageButton {
color: #374151;
color: var(--text-secondary, #5c564d);
}
.pageButton:hover {
background: #f9fafb;
border-color: #9ca3af;
background: var(--bg-secondary, #f3ede4);
border-color: var(--accent-coral, #e07256);
color: var(--text-primary, #1a1612);
}
.pageButtonActive {
background: #3b82f6;
background: var(--accent-coral, #e07256);
color: white;
border-color: #3b82f6;
border-color: var(--accent-coral, #e07256);
}
.pageButtonActive:hover {
background: var(--accent-coral-dark, #c45a3f);
border-color: var(--accent-coral-dark, #c45a3f);
}
.ellipsis {
@@ -77,7 +84,7 @@
align-items: center;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: #9ca3af;
color: var(--text-muted, #8a847a);
}
@media (max-width: 640px) {

View File

@@ -143,6 +143,11 @@
border-bottom: none;
}
/* Alternating row backgrounds for visual rhythm */
.rankingsTable tbody tr:nth-child(even) {
background: rgba(243, 237, 228, 0.5);
}
.rankingsTable tbody tr:hover {
background: var(--bg-secondary, #f3ede4);
}
@@ -168,14 +173,51 @@
color: var(--text-primary, #1a1612);
}
.medal {
font-size: 1.5rem;
line-height: 1;
/* Styled rank badges for top 3 */
.rankBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1rem;
font-weight: 700;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
}
.rankBadge::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 50%;
border: 2px solid transparent;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent) border-box;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
}
.rankBadge1 {
background: linear-gradient(135deg, #c9a227 0%, #e8c547 50%, #c9a227 100%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.rankBadge2 {
background: linear-gradient(135deg, #8c8c8c 0%, #c0c0c0 50%, #8c8c8c 100%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.rankBadge3 {
background: linear-gradient(135deg, #a5673f 0%, #cd7f32 50%, #a5673f 100%);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.rankNumber {
font-size: 1rem;
color: var(--accent-gold, #c9a227);
color: var(--text-secondary, #5c564d);
}
.schoolCell {
@@ -280,8 +322,10 @@
padding: 0.75rem 0.5rem;
}
.medal {
font-size: 1.25rem;
.rankBadge {
width: 30px;
height: 30px;
font-size: 0.875rem;
}
.schoolHeader {

View File

@@ -188,14 +188,13 @@ export function RankingsView({
className={isTopThree ? styles[`rank${rank}`] : ''}
>
<td className={styles.rankCell}>
{isTopThree && (
<span className={styles.medal}>
{rank === 1 && '🥇'}
{rank === 2 && '🥈'}
{rank === 3 && '🥉'}
{isTopThree ? (
<span className={`${styles.rankBadge} ${styles[`rankBadge${rank}`]}`}>
{rank}
</span>
) : (
<span className={styles.rankNumber}>{rank}</span>
)}
<span className={styles.rankNumber}>{rank}</span>
</td>
<td className={styles.schoolCell}>
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>

View File

@@ -101,9 +101,38 @@
}
.trend {
font-size: 1rem;
font-weight: bold;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: help;
transition: transform 0.2s ease;
}
.trend:hover {
transform: scale(1.15);
}
.trendIcon {
width: 12px;
height: 12px;
}
.trendUp {
color: var(--accent-teal, #2d7d7d);
background: rgba(45, 125, 125, 0.15);
}
.trendDown {
color: var(--accent-coral, #e07256);
background: rgba(224, 114, 86, 0.15);
}
.trendStable {
color: var(--text-muted, #8a847a);
background: rgba(138, 132, 122, 0.15);
}
.actions {

View File

@@ -55,11 +55,30 @@ export function SchoolCard({ school, onAddToCompare, showDistance, distance }: S
<strong>{formatPercentage(school.rwm_expected_pct)}</strong>
{school.prev_rwm_expected_pct !== null && (
<span
className={styles.trend}
style={{ color: trendColor }}
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous: ${formatPercentage(school.prev_rwm_expected_pct)}`}
>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
{trend === 'up' && (
<svg viewBox="0 0 16 16" fill="none" className={styles.trendIcon}>
<path
d="M8 3L14 10H2L8 3Z"
fill="currentColor"
/>
</svg>
)}
{trend === 'down' && (
<svg viewBox="0 0 16 16" fill="none" className={styles.trendIcon}>
<path
d="M8 13L2 6H14L8 13Z"
fill="currentColor"
/>
</svg>
)}
{trend === 'stable' && (
<svg viewBox="0 0 16 16" fill="none" className={styles.trendIcon}>
<rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor" />
</svg>
)}
</span>
)}
</div>