home: drop Selected Works card + juice hero; per-pillar OG cards; sitemap freshness

Home: the big empty 'Selected Works' section-container card is gone in favour of a compact
centered heading, pulling the bento up ~300px so the work leads directly under the hero.
Hero juice (all LCP-safe + motion-safe): the H1 gets an animated gradient sheen (only the
gradient POSITION animates, so the largest paint is never delayed; verified opacity stays 1),
a soft breathing cyan aura sits behind it (aria-hidden), and the CTAs get a cyan glow + hover
lift.

Services: each pillar page (and the hub) now generates its own OG social card via the shared
gameOgImage helper with a 'Services' badge and the pillar accent, so sharing a pillar shows
a distinct card instead of the generic banner. Verified: distinct per-slug image URLs, valid
1200x630 PNGs.

Sitemap: bump lastModified 2026-06-01 -> 2026-07-12 (we've meaningfully changed the site).
This commit is contained in:
jwinterm
2026-07-12 14:15:46 -04:00
parent 1e5719a1da
commit 651be20100
5 changed files with 79 additions and 15 deletions
+20 -1
View File
@@ -281,4 +281,23 @@
100% { width: 0; } 100% { width: 0; }
} }
@keyframes nebula-caret { 0%, 50% { border-color: currentColor; } 50.01%, 100% { border-color: transparent; } } @keyframes nebula-caret { 0%, 50% { border-color: currentColor; } 50.01%, 100% { border-color: transparent; } }
@keyframes nebula-term-out { to { opacity: 0; } } @keyframes nebula-term-out { to { opacity: 0; } }
/* Hero title: an animated gradient sheen. LCP-safe — the text paints immediately with the
gradient; only the gradient POSITION animates, which never delays the largest paint. The
global reduced-motion rule freezes it to the base gradient. */
.hero-title {
background-image: linear-gradient(110deg, #22d3ee 0%, #3b82f6 30%, #7dd3fc 50%, #3b82f6 70%, #22d3ee 100%);
background-size: 220% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: hero-sheen 9s ease-in-out infinite;
}
@keyframes hero-sheen {
0%, 100% { background-position: 0% center; }
50% { background-position: 100% center; }
}
/* Soft aura behind the hero title (decorative). Gentle breathing scale/opacity. */
@keyframes hero-aura { 0%, 100% { opacity: 0.55; transform: translateX(-50%) scale(1); } 50% { opacity: 0.8; transform: translateX(-50%) scale(1.08); } }
.hero-aura { animation: hero-aura 7s ease-in-out infinite; }
+13 -13
View File
@@ -16,11 +16,16 @@ export default function Home() {
<HomeShell> <HomeShell>
<div <div
id="main-content" id="main-content"
className="z-10 w-full max-w-7xl mx-auto flex flex-col items-center pt-44 sm:pt-32 pb-10 px-4 text-center scroll-mt-28" className="relative z-10 w-full max-w-7xl mx-auto flex flex-col items-center pt-40 sm:pt-28 pb-10 px-4 text-center scroll-mt-28"
> >
{/* Decorative aura behind the title (aria-hidden, LCP-safe, motion-safe). */}
<div
aria-hidden="true"
className="hero-aura pointer-events-none absolute left-1/2 top-24 sm:top-16 -z-10 h-64 w-[46rem] max-w-[92vw] rounded-full bg-cyan-400/25 blur-3xl"
/>
<h1 <h1
id="hero-title" id="hero-title"
className="text-4xl sm:text-5xl md:text-7xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-blue-600 pb-2" className="hero-title text-4xl sm:text-5xl md:text-7xl font-bold mb-6 pb-2"
style={{ maxWidth: "min(calc(90vw - 120px), 100%)" }} style={{ maxWidth: "min(calc(90vw - 120px), 100%)" }}
> >
<span className="block">Precision Engineering</span> <span className="block">Precision Engineering</span>
@@ -30,24 +35,19 @@ export default function Home() {
Custom e-commerce, websites, apps, and consulting. Plus our own mobile apps and games. A software studio in Kennett Square, PA. Custom e-commerce, websites, apps, and consulting. Plus our own mobile apps and games. A software studio in Kennett Square, PA.
</p> </p>
<div className="flex gap-4 mb-10"> <div className="flex flex-wrap justify-center gap-4 mb-14">
<Link href="/products"> <Link href="/products">
<Button size="lg" className="rounded-full">View Portfolio</Button> <Button size="lg" className="rounded-full shadow-lg shadow-cyan-500/25 hover:shadow-cyan-400/40 hover:scale-[1.03] transition-all">View Portfolio</Button>
</Link> </Link>
<Link href="#contact"> <Link href="#contact">
<Button variant="outline" size="lg" className="rounded-full">Start a Project</Button> <Button variant="outline" size="lg" className="rounded-full hover:scale-[1.03] transition-all">Start a Project</Button>
</Link> </Link>
</div> </div>
{/* Show before tell: the visual work leads, the services copy supports it below. {/* Show, don't tell: the bento leads directly under the hero (the Selected Works
(The old order opened with two screens of text before the first image.) */} header card was dropped to pull the work up higher). */}
<ScrollReveal className="w-full max-w-6xl mx-auto mb-16 px-4"> <ScrollReveal className="w-full max-w-6xl mx-auto mb-16 px-4">
<div className="text-left mb-8 section-container border-l-4 border-l-blue-500"> <h2 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground mb-6">Selected Works</h2>
<h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Selected Works</h2>
<p className="text-muted-foreground text-lg max-w-3xl leading-relaxed">
Recent projects spanning e-commerce, games, payment infrastructure, and interactive web applications.
</p>
</div>
<PortfolioGrid /> <PortfolioGrid />
</ScrollReveal> </ScrollReveal>
@@ -0,0 +1,31 @@
import { gameOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";
import { pillars, pillarBySlug } from "@/lib/services";
export const alt = "Such Software services";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
// Prebuild one card per pillar (matches the page's dynamicParams=false).
export function generateStaticParams() {
return pillars.map((p) => ({ pillar: p.slug }));
}
// Hex accents for the OG card (the pages use Tailwind stems; ImageResponse needs literal colours).
const ogAccent: Record<string, string> = {
commerce: "#3b82f6", // blue
games: "#a855f7", // purple
crypto: "#22d3ee", // cyan
education: "#f59e0b", // amber
};
export default async function Image({ params }: { params: Promise<{ pillar: string }> }) {
const { pillar: slug } = await params;
const pillar = pillarBySlug(slug);
if (!pillar) return gameOgImage({ title: "Services", tagline: "Such Software", accent: "#34d399", badge: "Services" });
return gameOgImage({
title: pillar.title,
tagline: pillar.card,
accent: ogAccent[pillar.slug] ?? "#34d399",
badge: "Services",
});
}
@@ -0,0 +1,14 @@
import { gameOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";
export const alt = "Such Software — four things, done well";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default function Image() {
return gameOgImage({
title: "Four things, done well",
tagline: "Commerce & web, games & apps, crypto & payments, and PhD-led science education.",
accent: "#34d399",
badge: "Services",
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
// every page as "modified now" on every build, which is noise that dilutes the freshness // every page as "modified now" on every build, which is noise that dilutes the freshness
// signal. Bump this when the site's static content meaningfully changes. (Blog posts carry // signal. Bump this when the site's static content meaningfully changes. (Blog posts carry
// their own per-post dates below.) // their own per-post dates below.)
const updated = new Date("2026-06-01"); const updated = new Date("2026-07-12");
const staticPages: MetadataRoute.Sitemap = [ const staticPages: MetadataRoute.Sitemap = [
{ url: base, lastModified: updated, changeFrequency: "weekly", priority: 1.0 }, { url: base, lastModified: updated, changeFrequency: "weekly", priority: 1.0 },