Design pass: fix dead site config, add hero image support, polish cards
Deploy to Dev / Deploy & Smoke Test (push) Successful in 21s

- Fix applyConfig self-recursion (const _origApply captured the hoisted
  wrapper itself): site.json config — theme, portfolio, socials, bio —
  was never applied; site silently fell back to hardcoded brown defaults
- Strengthen theme test to assert the configured primary color exactly
- Portfolio covers: styled placeholder on missing image (like locations),
  restore img when Immich carousel kicks in; overlay always visible on touch
- Optional hero photo via site.heroImage with soft scrim (activates only
  when the image loads); site.json points at /images/hero.jpg
- Option cards: equal height, centered content
- /#book deep link also works via hashchange

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 00:39:36 -06:00
parent a67fdba170
commit 9dcc73ecfe
3 changed files with 84 additions and 23 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"tagline": "Capturing life's beautiful moments", "tagline": "Capturing life's beautiful moments",
"description": "Senior portraits, engagement sessions, and milestone photography", "description": "Senior portraits, engagement sessions, and milestone photography",
"logo": "/images/logo.png", "logo": "/images/logo.png",
"favicon": "/images/favicon.ico" "favicon": "/images/favicon.ico",
"heroImage": "/images/hero.jpg"
}, },
"photographer": { "photographer": {
"name": "Elysse Hodgkin", "name": "Elysse Hodgkin",
+72 -10
View File
@@ -198,6 +198,19 @@
opacity: 0.5; opacity: 0.5;
} }
/* Optional hero photo (config: site.heroImage) with a soft scrim for text legibility */
.hero-bg.hero-bg--image {
background-size: cover;
background-position: center;
opacity: 1;
}
.hero-bg.hero-bg--image::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0.72) 100%);
}
.hero-pattern { .hero-pattern {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -413,6 +426,25 @@
letter-spacing: 0.1em; letter-spacing: 0.1em;
} }
/* Styled stand-in when a category cover image is missing (same idea as location cards) */
.portfolio-card-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-bg-warm) 0%, var(--color-accent) 100%);
font-family: var(--font-display);
font-style: italic;
font-size: 1.3rem;
color: var(--color-primary);
}
/* Touch devices have no hover — keep category titles visible */
@media (hover: none) {
.portfolio-card-overlay { opacity: 1; }
}
/* Client Access Section */ /* Client Access Section */
#client-access { #client-access {
background: var(--color-bg); background: var(--color-bg);
@@ -867,6 +899,10 @@
text-align: center; text-align: center;
transition: all 0.3s var(--transition-smooth); transition: all 0.3s var(--transition-smooth);
user-select: none; user-select: none;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 6.5rem;
} }
.option-card:hover { border-color: var(--color-primary); } .option-card:hover { border-color: var(--color-primary); }
@@ -1402,7 +1438,7 @@
<!-- Hero Section --> <!-- Hero Section -->
<section class="hero"> <section class="hero">
<div class="hero-bg"></div> <div class="hero-bg" id="hero-bg"></div>
<div class="hero-pattern"></div> <div class="hero-pattern"></div>
<div class="hero-content"> <div class="hero-content">
<p class="hero-eyebrow" id="hero-eyebrow">Photography</p> <p class="hero-eyebrow" id="hero-eyebrow">Photography</p>
@@ -1699,6 +1735,14 @@
return []; return [];
} }
// Swap a missing category cover for a styled placeholder (keeps the card elegant)
function portfolioCoverFallback(img) {
const fallback = document.createElement('div');
fallback.className = 'portfolio-card-fallback';
fallback.textContent = img.alt || 'Coming soon';
img.replaceWith(fallback);
}
// Start carousel for a portfolio card // Start carousel for a portfolio card
function startCarousel(cardElement, images) { function startCarousel(cardElement, images) {
if (images.length <= 1) return; if (images.length <= 1) return;
@@ -1729,8 +1773,9 @@
} }
async function applyConfig(config) { async function applyConfig(config) {
// Store immich config globally first (needed for thumbnail fetching) // Store config globally: immich for thumbnails, siteConfig for the booking form
window.immichConfig = config.immich || {}; window.immichConfig = config.immich || {};
window.siteConfig = config;
// Apply theme colors // Apply theme colors
if (config.theme) { if (config.theme) {
@@ -1752,6 +1797,16 @@
if (config.site.tagline) { if (config.site.tagline) {
document.getElementById('hero-tagline').textContent = config.site.tagline; document.getElementById('hero-tagline').textContent = config.site.tagline;
} }
if (config.site.heroImage) {
// Only activate the photo treatment once the image actually loads
const probe = new Image();
probe.onload = () => {
const heroBg = document.getElementById('hero-bg');
heroBg.style.backgroundImage = `url('${config.site.heroImage}')`;
heroBg.classList.add('hero-bg--image');
};
probe.src = config.site.heroImage;
}
} }
// Apply portfolio categories // Apply portfolio categories
@@ -1763,7 +1818,7 @@
// First render the cards with placeholder/fallback images // First render the cards with placeholder/fallback images
grid.innerHTML = config.portfolio.categories.map(cat => ` grid.innerHTML = config.portfolio.categories.map(cat => `
<div class="portfolio-card reveal" data-album-id="${cat.immichAlbumId || ''}" onclick="openPortfolio('${cat.id}', '${cat.immichAlbumId || ''}')"> <div class="portfolio-card reveal" data-album-id="${cat.immichAlbumId || ''}" onclick="openPortfolio('${cat.id}', '${cat.immichAlbumId || ''}')">
<img src="${cat.coverImage || '/images/placeholder.jpg'}" alt="${cat.name}" loading="lazy"> <img src="${cat.coverImage || '/images/placeholder.jpg'}" alt="${cat.name}" loading="lazy" onerror="portfolioCoverFallback(this)">
<div class="portfolio-card-overlay"> <div class="portfolio-card-overlay">
<h3 class="portfolio-card-title">${cat.name}</h3> <h3 class="portfolio-card-title">${cat.name}</h3>
<p class="portfolio-card-desc">${cat.description || ''}</p> <p class="portfolio-card-desc">${cat.description || ''}</p>
@@ -1778,7 +1833,15 @@
const images = await getImmichAlbumImages(baseUrl, prefix, cat.immichAlbumId); const images = await getImmichAlbumImages(baseUrl, prefix, cat.immichAlbumId);
if (images.length > 0) { if (images.length > 0) {
const card = grid.children[index]; const card = grid.children[index];
const img = card.querySelector('img'); let img = card.querySelector('img');
// The cover may have been swapped for the styled fallback — restore an <img>
if (!img) {
const fallback = card.querySelector('.portfolio-card-fallback');
img = document.createElement('img');
img.alt = cat.name;
if (fallback) fallback.replaceWith(img); else card.prepend(img);
}
// Set the first image // Set the first image
img.src = images[0]; img.src = images[0];
@@ -1889,12 +1952,7 @@
reveals.forEach(el => observer.observe(el)); reveals.forEach(el => observer.observe(el));
} }
// Store config globally for the booking form to use // (window.siteConfig is set at the top of applyConfig for the booking form)
const _origApply = applyConfig;
async function applyConfig(config) {
window.siteConfig = config;
return _origApply(config);
}
// ── Date Picker ─────────────────────────────────────── // ── Date Picker ───────────────────────────────────────
@@ -2613,6 +2671,10 @@ A complete service agreement and model release will be provided at your session
setTimeout(openBooking, 400); setTimeout(openBooking, 400);
} }
}); });
window.addEventListener('hashchange', function() {
if (window.location.hash === '#book') openBooking();
});
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" crossorigin="anonymous"></script>
</body> </body>
+10 -12
View File
@@ -45,19 +45,17 @@ test.describe('Portfolio site', () => {
expect(json.ok).toBe(true); expect(json.ok).toBe(true);
}); });
test('pink theme is applied (primary color is rose/pink)', async ({ page }) => { test('configured theme is applied (primary color matches site.json)', async ({ page, request }) => {
const config = await (await request.get('/config/site.json')).json();
const expected = (config.theme?.primaryColor || '').toLowerCase();
expect(expected).toMatch(/^#[a-f0-9]{6}$/);
await page.goto('/'); await page.goto('/');
const primary = await page.evaluate(() => // Config is fetched on window load; wait until the var flips from the CSS default
getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim() await page.waitForFunction(
exp => getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim().toLowerCase() === exp,
expected,
{ timeout: 8_000 }
); );
// Should be a pink/rose hex — starts with #B or #b (our dusty rose #B06A7A)
expect(primary.toLowerCase()).toMatch(/^#[a-f0-9]{6}$/i);
// Hue should be in the red-pink range: R > G and R > B
const hex = primary.replace('#', '');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
expect(r).toBeGreaterThan(g);
expect(r).toBeGreaterThan(b);
}); });
}); });