fix(web): fail closed on Turnstile, keep the social card, derive the sitemap

Five things that were each one line of wrong and are cheap to hold shut.

Turnstile failed OPEN. `lib/turnstile.ts` returned true whenever
TURNSTILE_SECRET_KEY was unset, and logged the visitor's token while doing it.
Lose that variable in a deploy and the contact form quietly stops checking
anything, the only trace a warning nobody reads. Missing configuration is now a
refusal; the dev bypass survives as an explicit TURNSTILE_DEV_BYPASS=1 that a
production build ignores, rather than as a side effect of an empty variable. The
caller now distinguishes "you failed the check" from "we cannot run the check",
because telling a real visitor to refresh, when the server has no key, sends them
round a loop that cannot succeed.

The social card was being dropped by the pages that cared enough to write their
own. Next merges metadata shallowly, so a page exporting `openGraph: { title,
description }` replaces the parent's whole block, image included. Seventeen pages
did that; five had an opengraph-image.tsx to fall back on and twelve shared as a
bare grey rectangle. `pageMetadata()` takes the parts that differ and fills in
the parts that must not go missing. Built output goes from 23/51 pages carrying
og:image and 12/51 carrying twitter:image to 51/51 on both. Canonical was already
51/51 and stays there.

The sitemap was a hand-kept list and had drifted nine routes behind the site,
among them /products/{vegan-iq,suchoice,bauhaus-echo,such-moon-launch}/privacy,
which Apple and Google fetch during store review. It now walks the route tree, so
a page is advertised because it exists. Dynamic segments still come from their
data (blog posts, service pillars), and /contact is excluded WITH its reason: it
redirects, and a sitemap should name the destination rather than the door.
Result: 37 static routes, up from 28, nothing removed.

`vegan-iq-data-pipeline.mdx` was `draft: true`, which kept the most substantial
post on the site out of the index, the sitemap and generateStaticParams.

And /contact was unreachable on a first visit. It redirects to /#contact, but the
splash owns the first screen and entering it pins scroll to 0, so every inbound
contact link played the intro and dropped the visitor at the top of the homepage
with the hash discarded. A fragment that names something real on the page now
skips the splash and is honoured once, on arrival.

Verified: pnpm build green, lint clean (8 pre-existing no-img-element warnings,
none from this change), og/twitter/canonical counted in the built HTML.
This commit is contained in:
jwinterm
2026-09-05 23:40:46 -04:00
parent e906e7884d
commit d1d56ef744
19 changed files with 719 additions and 259 deletions
@@ -2,7 +2,7 @@
title: "Building a Data-Driven Trivia Engine: No LLMs, Just Verified Facts"
date: "2026-04-09"
excerpt: "How we built Vegan IQ's question pipeline: deterministic generation from USDA, Oxford, and FAOSTAT data, quality filtering from 2,461 to 1,066 questions, and a greedy anti-repetition algorithm that makes every quiz feel fresh."
draft: true
draft: false
---
Vegan IQ ships with 1,066 trivia questions. None of them were written by an LLM. Every question traces back to a specific data source: USDA FoodData Central, Poore & Nemecek (Science, 2018), NIH, or FAOSTAT. The pipeline that produces them is a deterministic Python script that generates questions from structured data, filters them for quality, and outputs a single JSON file that the Flutter app bundles as an asset.
+12 -4
View File
@@ -23,10 +23,18 @@ export async function submitContactForm(prevState: any, formData: FormData) {
return { success: false, errors: validated.error.flatten().fieldErrors };
}
// 2. Turnstile Verification
const isHuman = await verifyTurnstile(validated.data.token);
if (!isHuman) {
return { success: false, message: "Security check failed. Please refresh." };
// 2. Turnstile Verification. Distinguish "you failed the check" from "we cannot run the
// check": telling a real visitor to refresh, when the server is missing its key, sends them
// round a loop that cannot succeed.
const check = await verifyTurnstile(validated.data.token);
if (!check.ok) {
return {
success: false,
message:
check.reason === 'rejected'
? "Security check failed. Please refresh."
: "We could not run the security check just now. Please email sales@such.software.",
};
}
// 3. Send email via Resend
+4 -8
View File
@@ -6,18 +6,14 @@ import { ScrollReveal } from "@/components/scroll-reveal";
import { FaMapMarkerAlt, FaPhone, FaBuilding, FaExternalLinkAlt } from "react-icons/fa";
import { FaXTwitter, FaTelegram, FaDiscord, FaBluesky, FaTiktok, FaYoutube, FaInstagram, FaLinkedinIn, FaFacebookF, FaPinterestP } from "react-icons/fa6";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "About: A Kennett Square, PA Software Studio | Such Software",
description: "Such Software is a Kennett Square, PA studio founded by John Winter Murphy, building accessible web apps, crypto and payment integrations, and games, with front-ends engineered to meet WCAG 2.2 AA.",
alternates: {
canonical: "/about",
},
openGraph: {
...pageMetadata({
title: "About: A Kennett Square, PA Software Studio | Such Software",
description: "Such Software is a Kennett Square, PA studio founded by John Winter Murphy, building accessible web apps, crypto and payment integrations, and games, with front-ends engineered to meet WCAG 2.2 AA.",
type: "website",
},
path: "/about",
}),
};
export default function AboutPage() {
+9 -15
View File
@@ -14,6 +14,7 @@ import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import { getAllSlugs, getPost } from "@/lib/blog";
import { pageMetadata } from "@/lib/page-metadata";
import { JsonLd, articleLd, breadcrumbLd } from "@/components/seo/json-ld";
type Props = { params: Promise<{ slug: string }> };
@@ -26,23 +27,16 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
return {
return pageMetadata({
title: `${post.title} | Such Software Blog`,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
url: `/blog/${slug}`,
siteName: "Such Software",
},
twitter: {
card: "summary",
title: post.title,
description: post.excerpt,
},
};
path: `/blog/${slug}`,
type: "article",
// The tab wants the site name; a shared link already shows the domain, so the post's own
// title is the whole card.
socialTitle: post.title,
publishedTime: post.date || undefined,
});
}
export default async function BlogPost({ params }: Props) {
+4 -6
View File
@@ -3,17 +3,15 @@ import { MobileNav } from "@/components/layout/mobile-nav";
import { Card } from "@repo/ui/components/card";
import Link from "next/link";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
import { getAllPosts } from "@/lib/blog";
export const metadata: Metadata = {
title: "Blog: Build Notes on Crypto, Commerce & Apps | Such Software",
description: "Technical write-ups from Such Software on provably-fair games, Monero tooling, custom e-commerce, and the apps we ship.",
alternates: { canonical: "/blog" },
openGraph: {
...pageMetadata({
title: "Blog: Build Notes on Crypto, Commerce & Apps | Such Software",
description: "Technical write-ups from Such Software on provably-fair games, Monero tooling, custom e-commerce, and the apps we ship.",
type: "website",
},
path: "/blog",
}),
};
export default function BlogPage() {
+5 -9
View File
@@ -1,18 +1,14 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Evaluetron Privacy Policy | Such Software",
description:
"What Evaluetron stores, for how long, and who else sees it, including which prompts leave for upstream model providers.",
alternates: { canonical: "/evaluetron/privacy" },
openGraph: {
...pageMetadata({
title: "Evaluetron Privacy Policy | Such Software",
description:
"What Evaluetron stores, for how long, and who else sees it, including which prompts leave for upstream model providers.",
type: "website",
},
description: "What Evaluetron stores, for how long, and who else sees it, including which prompts leave for upstream model providers.",
path: "/evaluetron/privacy",
}),
};
export default function EvaluetronPrivacyPage() {
+5 -9
View File
@@ -1,18 +1,14 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Evaluetron Terms of Service | Such Software",
description:
"The terms governing Evaluetron, the generation service reached on the web and through chat platforms including Discord and Telegram.",
alternates: { canonical: "/evaluetron/terms" },
openGraph: {
...pageMetadata({
title: "Evaluetron Terms of Service | Such Software",
description:
"The terms governing Evaluetron, the generation service reached on the web and through chat platforms including Discord and Telegram.",
type: "website",
},
description: "The terms governing Evaluetron, the generation service reached on the web and through chat platforms including Discord and Telegram.",
path: "/evaluetron/terms",
}),
};
export default function EvaluetronTermsPage() {
+4 -6
View File
@@ -2,16 +2,14 @@ import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Privacy Policy | Such Software",
description: "The umbrella privacy policy for the such.software website and Such Software LLC. Individual apps and games may have their own privacy policies.",
alternates: { canonical: "/privacy" },
openGraph: {
...pageMetadata({
title: "Privacy Policy | Such Software",
description: "The umbrella privacy policy for the such.software website and Such Software LLC. Individual apps and games may have their own privacy policies.",
type: "website",
},
path: "/privacy",
}),
};
export default function PrivacyPage() {
+143 -48
View File
@@ -6,12 +6,18 @@ import Image from "next/image";
import { JsonLd, breadcrumbLd, faqLd } from "@/components/seo/json-ld";
import { StoreButtons } from "@/components/store-buttons";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Bloomword: Daily Word Game with a Living Tree | Such Software",
description:
"Bloomword is a daily word game where everyone gets the same letters and every word you find grows a living 3D typographic tree. Five biomes, streaks, and a bloom to share. Free on iOS and Android.",
alternates: { canonical: "/products/bloomword" },
...pageMetadata({
title: "Bloomword: Daily Word Game with a Living Tree | Such Software",
description:
"Bloomword is a daily word game where everyone gets the same letters and every word you find grows a living 3D typographic tree. Five biomes, streaks, and a bloom to share. Free on iOS and Android.",
path: "/products/bloomword",
socialTitle: "Bloomword: Find the Day's Words, Grow a Living Tree",
socialDescription:
"A daily word game where every word you find grows a biome-specific, living 3D typographic tree. Free on iOS and Android from Such Software.",
}),
keywords: [
"daily word game",
"word puzzle game",
@@ -21,21 +27,29 @@ export const metadata: Metadata = {
"wordle alternative",
"bloomword",
],
openGraph: {
title: "Bloomword: Find the Day's Words, Grow a Living Tree",
description:
"A daily word game where every word you find grows a biome-specific, living 3D typographic tree. Free on iOS and Android from Such Software.",
type: "website",
url: "/products/bloomword",
},
};
const faqs = [
{ q: "What is Bloomword?", a: "A daily word game. Everyone gets the same set of letters each day and races to find words before the sunset, and every valid word you find grows a living 3D tree made of typography. It is designed as a calm daily habit: find your words, watch your tree bloom, keep your streak." },
{ q: "How does the tree work?", a: "Each word you find sprouts new growth on a 3D typographic tree rendered by our in-house typograph-engine. The tree's look changes with the day's biome, so your finished board is both a score and a little piece of generative art you can share." },
{ q: "Is it the same puzzle for everyone?", a: "Yes. Like the best daily games, the letters are shared worldwide each day, so leaderboards and streaks are apples-to-apples. Results are server-replayed for fairness." },
{ q: "Is it free?", a: "Yes, free to play, supported by light ads and optional in-app purchases. A privacy-conscious build is a priority." },
{ q: "What platforms is it on?", a: "Built in Godot for iOS and Android, and those are the only places you can play it. Download it free on the App Store or Google Play; learn more at bloomword.earth." },
{
q: "What is Bloomword?",
a: "A daily word game. Everyone gets the same set of letters each day and races to find words before the sunset, and every valid word you find grows a living 3D tree made of typography. It is designed as a calm daily habit: find your words, watch your tree bloom, keep your streak.",
},
{
q: "How does the tree work?",
a: "Each word you find sprouts new growth on a 3D typographic tree rendered by our in-house typograph-engine. The tree's look changes with the day's biome, so your finished board is both a score and a little piece of generative art you can share.",
},
{
q: "Is it the same puzzle for everyone?",
a: "Yes. Like the best daily games, the letters are shared worldwide each day, so leaderboards and streaks are apples-to-apples. Results are server-replayed for fairness.",
},
{
q: "Is it free?",
a: "Yes, free to play, supported by light ads and optional in-app purchases. A privacy-conscious build is a priority.",
},
{
q: "What platforms is it on?",
a: "Built in Godot for iOS and Android, and those are the only places you can play it. Download it free on the App Store or Google Play; learn more at bloomword.earth.",
},
];
const jsonLd = {
@@ -53,32 +67,67 @@ const jsonLd = {
};
const features = [
{ title: "One shared daily puzzle", body: "Everyone gets the same letters each day and races the sunset. Server-replayed results keep streaks and leaderboards honest." },
{ title: "Words become a living tree", body: "Every word you find grows a 3D typographic tree, rendered in real time by our in-house typograph-engine. Your finished board is a shareable piece of generative art." },
{ title: "Five biomes", body: "Forest, desert, mushroom, kelp, and prairie; each biome gives the day's tree its own palette and character, so no two blooms look alike." },
{ title: "Built for the daily habit", body: "Streaks and biome collections reward showing up. It is a calm, one-a-day ritual, not an attention trap." },
{ title: "Play with anyone", body: "A shared daily seed and Nakama-backed leaderboards mean you can compare with friends or the whole world on the exact same board." },
{ title: "Free and privacy-conscious", body: "Free to play with light ads and optional purchases, built to respect your data." },
{
title: "One shared daily puzzle",
body: "Everyone gets the same letters each day and races the sunset. Server-replayed results keep streaks and leaderboards honest.",
},
{
title: "Words become a living tree",
body: "Every word you find grows a 3D typographic tree, rendered in real time by our in-house typograph-engine. Your finished board is a shareable piece of generative art.",
},
{
title: "Five biomes",
body: "Forest, desert, mushroom, kelp, and prairie; each biome gives the day's tree its own palette and character, so no two blooms look alike.",
},
{
title: "Built for the daily habit",
body: "Streaks and biome collections reward showing up. It is a calm, one-a-day ritual, not an attention trap.",
},
{
title: "Play with anyone",
body: "A shared daily seed and Nakama-backed leaderboards mean you can compare with friends or the whole world on the exact same board.",
},
{
title: "Free and privacy-conscious",
body: "Free to play with light ads and optional purchases, built to respect your data.",
},
];
export default function BloomwordPage() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<JsonLd data={breadcrumbLd([
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
{ name: "Bloomword", path: "/products/bloomword" },
])} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd
data={breadcrumbLd([
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
{ name: "Bloomword", path: "/products/bloomword" },
])}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={faqLd(faqs)} />
<Header />
<div id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">
<div
id="main-content"
className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20"
>
<Link
href="/products"
className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block"
>
← Back to Products
</Link>
<div className="flex items-center gap-4 mb-2">
<Image src="/images/products/bloomword.png" alt="Bloomword icon" width={64} height={64} className="rounded-[22%]" />
<Image
src="/images/products/bloomword.png"
alt="Bloomword icon"
width={64}
height={64}
className="rounded-[22%]"
/>
<div>
<span className="inline-flex items-center rounded-full bg-green-400/15 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.2em] text-green-600 dark:text-green-400 mb-1">
Now Live
@@ -88,23 +137,43 @@ export default function BloomwordPage() {
</h1>
</div>
</div>
<p className="text-base text-muted-foreground mb-6">Find the day&rsquo;s words. Grow a living tree.</p>
<p className="text-base text-muted-foreground mb-6">
Find the day&rsquo;s words. Grow a living tree.
</p>
<div className="prose dark:prose-invert max-w-none mb-12">
<p className="text-xl text-muted-foreground leading-relaxed">
Bloomword is a daily word game with a twist you can watch grow. Everyone gets the same
letters each day and races the sunset to find words, and every word you find sprouts new
growth on a living 3D typographic tree. Keep your streak, collect the biomes, and share the
bloom you grew today. It is free on iOS and Android. Learn more at{" "}
<a href="https://bloomword.earth" target="_blank" rel="noopener noreferrer" className="text-green-600 dark:text-green-400 hover:underline">bloomword.earth</a>.
Bloomword is a daily word game with a twist you can watch grow.
Everyone gets the same letters each day and races the sunset to find
words, and every word you find sprouts new growth on a living 3D
typographic tree. Keep your streak, collect the biomes, and share
the bloom you grew today. It is free on iOS and Android. Learn more
at{" "}
<a
href="https://bloomword.earth"
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline"
>
bloomword.earth
</a>
.
</p>
</div>
<div className="mb-12">
<StoreButtons links={[
{ platform: "apple", href: "https://apps.apple.com/us/app/bloomword-grow-your-vocab/id6781658542" },
{ platform: "google", href: "https://play.google.com/store/apps/details?id=com.suchsoftware.bloomword" },
]} />
<StoreButtons
links={[
{
platform: "apple",
href: "https://apps.apple.com/us/app/bloomword-grow-your-vocab/id6781658542",
},
{
platform: "google",
href: "https://play.google.com/store/apps/details?id=com.suchsoftware.bloomword",
},
]}
/>
</div>
<h2 className="text-2xl font-bold mb-6">What makes it different</h2>
@@ -120,10 +189,22 @@ export default function BloomwordPage() {
<h2 className="text-2xl font-bold mb-6">Under the Hood</h2>
<div className="section-container border-l-4 border-green-500 mb-12">
<ul className="space-y-2 text-muted-foreground">
<li><strong>Engine:</strong> Godot 4 with a Rust GDExtension, one codebase for iOS and Android.</li>
<li><strong>The tree:</strong> a custom typograph-engine renders words as living 3D type in real time.</li>
<li><strong>Fair daily play:</strong> a shared daily seed with Nakama-backed, server-replayed leaderboards.</li>
<li><strong>Free to play:</strong> light ads and optional in-app purchases, built privacy-first.</li>
<li>
<strong>Engine:</strong> Godot 4 with a Rust GDExtension, one
codebase for iOS and Android.
</li>
<li>
<strong>The tree:</strong> a custom typograph-engine renders words
as living 3D type in real time.
</li>
<li>
<strong>Fair daily play:</strong> a shared daily seed with
Nakama-backed, server-replayed leaderboards.
</li>
<li>
<strong>Free to play:</strong> light ads and optional in-app
purchases, built privacy-first.
</li>
</ul>
</div>
@@ -131,16 +212,30 @@ export default function BloomwordPage() {
<div className="space-y-6 mb-12">
{faqs.map((f) => (
<div key={f.q}>
<h3 className="font-bold mb-1 text-green-600 dark:text-green-400">{f.q}</h3>
<h3 className="font-bold mb-1 text-green-600 dark:text-green-400">
{f.q}
</h3>
<p className="text-muted-foreground">{f.a}</p>
</div>
))}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
<Link href="/products" className="hover:text-foreground transition-colors">All Products</Link>
<Link
href="/products"
className="hover:text-foreground transition-colors"
>
All Products
</Link>
<span>•</span>
<a href="https://bloomword.earth" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">bloomword.earth</a>
<a
href="https://bloomword.earth"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
bloomword.earth
</a>
<span>•</span>
<span>Contact: apps@such.software</span>
</div>
+127 -44
View File
@@ -5,12 +5,18 @@ import Link from "next/link";
import Image from "next/image";
import { JsonLd, breadcrumbLd, faqLd } from "@/components/seo/json-ld";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Meatspace: A Deduction Game After the Upload | Such Software",
description:
"Meatspace is a deduction game set in the ruins of the Ingestion: humanity's failed mass upload into the Lattice. Reconstruct a lost person from fragments, through a living meat-vs-cyber crossfade. In development.",
alternates: { canonical: "/products/meatspace" },
...pageMetadata({
title: "Meatspace: A Deduction Game After the Upload | Such Software",
description:
"Meatspace is a deduction game set in the ruins of the Ingestion: humanity's failed mass upload into the Lattice. Reconstruct a lost person from fragments, through a living meat-vs-cyber crossfade. In development.",
path: "/products/meatspace",
socialTitle: "Meatspace: Find Who You Lost in the Wreck of the Upload",
socialDescription:
"A deduction game in the ruins of a failed mass mind-upload, with a living meat-vs-cyber crossfade and an optional multiplayer mode. In development by Such Software.",
}),
keywords: [
"deduction game",
"narrative mystery game",
@@ -20,21 +26,29 @@ export const metadata: Metadata = {
"multiplayer deduction",
"meatspace",
],
openGraph: {
title: "Meatspace: Find Who You Lost in the Wreck of the Upload",
description:
"A deduction game in the ruins of a failed mass mind-upload, with a living meat-vs-cyber crossfade and an optional multiplayer mode. In development by Such Software.",
type: "website",
url: "/products/meatspace",
},
};
const faqs = [
{ q: "What is Meatspace?", a: "A deduction game set after the Ingestion: humanity's failed attempt to upload itself en masse into a digital substrate called the Lattice. You sift through the fragments of the dead to reconstruct one specific lost person, and the way you piece them together shapes an ending you won't fully understand until you reach it." },
{ q: "What is the Ingestion?", a: "The fiction's central catastrophe: the mass upload that was supposed to save everyone and instead left a ruin of half-copied minds. The game lives in that wreckage: part meat, part cyber, neither settled." },
{ q: "Is it single-player or multiplayer?", a: "Primarily a single-player deduction experience, with an optional realtime multiplayer mode called The Wake, built on Nakama, for playing the reconstruction together." },
{ q: "What's the meat-vs-cyber look?", a: "The world renders in two layers, organic 'meat' and digital 'cyber', with a continuous post-process crossfade we call the Seam Director. The balance shifts as you play, so the image is never fully one or the other." },
{ q: "What platforms and when?", a: "Built in Godot for desktop and mobile; the marketing site is live at meatspace.place and the game is in active development. Follow Such Software for release news." },
{
q: "What is Meatspace?",
a: "A deduction game set after the Ingestion: humanity's failed attempt to upload itself en masse into a digital substrate called the Lattice. You sift through the fragments of the dead to reconstruct one specific lost person, and the way you piece them together shapes an ending you won't fully understand until you reach it.",
},
{
q: "What is the Ingestion?",
a: "The fiction's central catastrophe: the mass upload that was supposed to save everyone and instead left a ruin of half-copied minds. The game lives in that wreckage: part meat, part cyber, neither settled.",
},
{
q: "Is it single-player or multiplayer?",
a: "Primarily a single-player deduction experience, with an optional realtime multiplayer mode called The Wake, built on Nakama, for playing the reconstruction together.",
},
{
q: "What's the meat-vs-cyber look?",
a: "The world renders in two layers, organic 'meat' and digital 'cyber', with a continuous post-process crossfade we call the Seam Director. The balance shifts as you play, so the image is never fully one or the other.",
},
{
q: "What platforms and when?",
a: "Built in Godot for desktop and mobile; the marketing site is live at meatspace.place and the game is in active development. Follow Such Software for release news.",
},
];
const jsonLd = {
@@ -52,31 +66,63 @@ const jsonLd = {
};
const features = [
{ title: "Deduction that dawns on you", body: "You reconstruct a lost person from unreliable fragments, and you get it wrong in ways you won't see until the ending makes you feel it." },
{ title: "The Seam Director", body: "A continuous meat-vs-cyber crossfade rendered as a post-process. The world is never fully organic or fully digital; the balance moves as you do." },
{ title: "The Wake", body: "An optional realtime multiplayer mode, on Nakama, for working through the reconstruction alongside others." },
{ title: "A world built to unsettle", body: "The ruins of the Ingestion: half-copied minds, a substrate called the Lattice, rendered with an intentionally uneasy, in-between aesthetic." },
{ title: "AI-assisted, human-directed pipeline", body: "3D (Meshy), audio (ElevenLabs), and imagery (nano-gpt) feed an engine that owns the composition, the same 'AI makes ingredients, the engine makes the content' approach we use across the studio." },
{
title: "Deduction that dawns on you",
body: "You reconstruct a lost person from unreliable fragments, and you get it wrong in ways you won't see until the ending makes you feel it.",
},
{
title: "The Seam Director",
body: "A continuous meat-vs-cyber crossfade rendered as a post-process. The world is never fully organic or fully digital; the balance moves as you do.",
},
{
title: "The Wake",
body: "An optional realtime multiplayer mode, on Nakama, for working through the reconstruction alongside others.",
},
{
title: "A world built to unsettle",
body: "The ruins of the Ingestion: half-copied minds, a substrate called the Lattice, rendered with an intentionally uneasy, in-between aesthetic.",
},
{
title: "AI-assisted, human-directed pipeline",
body: "3D (Meshy), audio (ElevenLabs), and imagery (nano-gpt) feed an engine that owns the composition, the same 'AI makes ingredients, the engine makes the content' approach we use across the studio.",
},
];
export default function MeatspacePage() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<JsonLd data={breadcrumbLd([
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
{ name: "Meatspace", path: "/products/meatspace" },
])} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd
data={breadcrumbLd([
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
{ name: "Meatspace", path: "/products/meatspace" },
])}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={faqLd(faqs)} />
<Header />
<div id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">
<div
id="main-content"
className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20"
>
<Link
href="/products"
className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block"
>
← Back to Products
</Link>
<div className="flex items-center gap-4 mb-2">
<Image src="/images/products/meatspace.svg" alt="Meatspace icon" width={64} height={64} className="rounded-[22%]" />
<Image
src="/images/products/meatspace.svg"
alt="Meatspace icon"
width={64}
height={64}
className="rounded-[22%]"
/>
<div>
<span className="inline-flex items-center rounded-full bg-pink-400/15 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.2em] text-pink-600 dark:text-pink-400 mb-1">
In Development
@@ -86,16 +132,28 @@ export default function MeatspacePage() {
</h1>
</div>
</div>
<p className="text-base text-muted-foreground mb-6">Find who you lost in the wreck of the upload.</p>
<p className="text-base text-muted-foreground mb-6">
Find who you lost in the wreck of the upload.
</p>
<div className="prose dark:prose-invert max-w-none mb-12">
<p className="text-xl text-muted-foreground leading-relaxed">
Meatspace is a deduction game set in the ruins of the Ingestion: humanity&rsquo;s failed
mass upload into the Lattice. You sift a lost person&rsquo;s fragments out of the noise of
the dead, reconstructing who they were, and you get it wrong in ways you won&rsquo;t see
until the end. It renders in a continuous meat-vs-cyber crossfade, with an optional
multiplayer mode. Its home is at{" "}
<a href="https://meatspace.place" target="_blank" rel="noopener noreferrer" className="text-pink-600 dark:text-pink-400 hover:underline">meatspace.place</a>.
Meatspace is a deduction game set in the ruins of the Ingestion:
humanity&rsquo;s failed mass upload into the Lattice. You sift a
lost person&rsquo;s fragments out of the noise of the dead,
reconstructing who they were, and you get it wrong in ways you
won&rsquo;t see until the end. It renders in a continuous
meat-vs-cyber crossfade, with an optional multiplayer mode. Its home
is at{" "}
<a
href="https://meatspace.place"
target="_blank"
rel="noopener noreferrer"
className="text-pink-600 dark:text-pink-400 hover:underline"
>
meatspace.place
</a>
.
</p>
</div>
@@ -112,10 +170,21 @@ export default function MeatspacePage() {
<h2 className="text-2xl font-bold mb-6">Under the Hood</h2>
<div className="section-container border-l-4 border-pink-500 mb-12">
<ul className="space-y-2 text-muted-foreground">
<li><strong>Engine:</strong> Godot with a Rust GDExtension, one codebase for desktop and mobile.</li>
<li><strong>The Seam Director:</strong> a post-process that crossfades an organic and a digital render of the same world.</li>
<li><strong>Multiplayer:</strong> The Wake, a realtime mode on Nakama.</li>
<li><strong>Asset pipeline:</strong> Meshy (3D), ElevenLabs (audio), and nano-gpt (imagery) feed an engine that owns the composition.</li>
<li>
<strong>Engine:</strong> Godot with a Rust GDExtension, one
codebase for desktop and mobile.
</li>
<li>
<strong>The Seam Director:</strong> a post-process that crossfades
an organic and a digital render of the same world.
</li>
<li>
<strong>Multiplayer:</strong> The Wake, a realtime mode on Nakama.
</li>
<li>
<strong>Asset pipeline:</strong> Meshy (3D), ElevenLabs (audio),
and nano-gpt (imagery) feed an engine that owns the composition.
</li>
</ul>
</div>
@@ -123,16 +192,30 @@ export default function MeatspacePage() {
<div className="space-y-6 mb-12">
{faqs.map((f) => (
<div key={f.q}>
<h3 className="font-bold mb-1 text-pink-600 dark:text-pink-400">{f.q}</h3>
<h3 className="font-bold mb-1 text-pink-600 dark:text-pink-400">
{f.q}
</h3>
<p className="text-muted-foreground">{f.a}</p>
</div>
))}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
<Link href="/products" className="hover:text-foreground transition-colors">All Products</Link>
<Link
href="/products"
className="hover:text-foreground transition-colors"
>
All Products
</Link>
<span>•</span>
<a href="https://meatspace.place" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">meatspace.place</a>
<a
href="https://meatspace.place"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
meatspace.place
</a>
<span>•</span>
<span>Contact: apps@such.software</span>
</div>
@@ -5,12 +5,18 @@ import Link from "next/link";
import Image from "next/image";
import { JsonLd, breadcrumbLd, faqLd } from "@/components/seo/json-ld";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Mushroom Capital: Cozy 3D Idle Mushroom Game | Such Software",
description:
"Mushroom Capital is a cozy 3D idle and async-social game where you build and tend a living fungal ecosystem. Grow your colony, bank Capital, and prestige with Heirloom Spores. Coming to mobile, web, and desktop.",
alternates: { canonical: "/products/mushroom-capital" },
...pageMetadata({
title: "Mushroom Capital: Cozy 3D Idle Mushroom Game | Such Software",
description:
"Mushroom Capital is a cozy 3D idle and async-social game where you build and tend a living fungal ecosystem. Grow your colony, bank Capital, and prestige with Heirloom Spores. Coming to mobile, web, and desktop.",
path: "/products/mushroom-capital",
socialTitle: "Mushroom Capital: Tend a Living Fungal Ecosystem",
socialDescription:
"A cozy 3D idle and async-social game with a tilt-shift toybox aesthetic. Grow a colony, earn Capital, prestige with Heirloom Spores. Coming soon from Such Software.",
}),
keywords: [
"idle game",
"cozy game",
@@ -20,21 +26,29 @@ export const metadata: Metadata = {
"tilt shift game",
"mushroom capital",
],
openGraph: {
title: "Mushroom Capital: Tend a Living Fungal Ecosystem",
description:
"A cozy 3D idle and async-social game with a tilt-shift toybox aesthetic. Grow a colony, earn Capital, prestige with Heirloom Spores. Coming soon from Such Software.",
type: "website",
url: "/products/mushroom-capital",
},
};
const faqs = [
{ q: "What is Mushroom Capital?", a: "A cozy 3D idle and async-social game. You build and tend a self-contained, living fungal ecosystem: a stylized, tilt-shift toybox that keeps growing while you're away. It's a calm, collect-and-prestige experience, not a hardcore optimizer." },
{ q: "What do you actually do?", a: "Grow and arrange your colony, keep the ecosystem healthy, and bank two currencies: Capital (the soft, moment-to-moment score) and Heirloom Spores (prestige you carry forward when you reset). The loop rewards curation and patience over grinding." },
{ q: "Is it multiplayer?", a: "It's async-social: your world is yours, but you connect with others between sessions. Cross-progression means one save follows you across mobile, web, and desktop." },
{ q: "What platforms?", a: "Mobile-first (iOS and Android), with web and desktop cross-progression. Built in Godot 4." },
{ q: "When is it out?", a: "A soft launch is planned for summer 2026, with a wider launch in September around the Kennett Square Mushroom Festival. Its home is mushroom.capital." },
{
q: "What is Mushroom Capital?",
a: "A cozy 3D idle and async-social game. You build and tend a self-contained, living fungal ecosystem: a stylized, tilt-shift toybox that keeps growing while you're away. It's a calm, collect-and-prestige experience, not a hardcore optimizer.",
},
{
q: "What do you actually do?",
a: "Grow and arrange your colony, keep the ecosystem healthy, and bank two currencies: Capital (the soft, moment-to-moment score) and Heirloom Spores (prestige you carry forward when you reset). The loop rewards curation and patience over grinding.",
},
{
q: "Is it multiplayer?",
a: "It's async-social: your world is yours, but you connect with others between sessions. Cross-progression means one save follows you across mobile, web, and desktop.",
},
{
q: "What platforms?",
a: "Mobile-first (iOS and Android), with web and desktop cross-progression. Built in Godot 4.",
},
{
q: "When is it out?",
a: "A soft launch is planned for summer 2026, with a wider launch in September around the Kennett Square Mushroom Festival. Its home is mushroom.capital.",
},
];
const jsonLd = {
@@ -52,31 +66,63 @@ const jsonLd = {
};
const features = [
{ title: "A living fungal ecosystem", body: "Build and tend a self-contained colony that keeps growing while you're away. Curation and care matter more than clicking." },
{ title: "Two currencies, one calm loop", body: "Bank Capital as you grow, then prestige with Heirloom Spores to start richer. A tidy, legible economy with no dark patterns." },
{ title: "Tilt-shift toybox", body: "A stylized miniature aesthetic: everything reads like a warm, hand-built diorama you can turn in your fingers." },
{ title: "Async-social", body: "Your world is yours, but it's better with others between sessions: light, unpressured social hooks, not a leaderboard grind." },
{ title: "Cross-progression", body: "One save follows you across mobile, web, and desktop, so your colony is always where you are." },
{
title: "A living fungal ecosystem",
body: "Build and tend a self-contained colony that keeps growing while you're away. Curation and care matter more than clicking.",
},
{
title: "Two currencies, one calm loop",
body: "Bank Capital as you grow, then prestige with Heirloom Spores to start richer. A tidy, legible economy with no dark patterns.",
},
{
title: "Tilt-shift toybox",
body: "A stylized miniature aesthetic: everything reads like a warm, hand-built diorama you can turn in your fingers.",
},
{
title: "Async-social",
body: "Your world is yours, but it's better with others between sessions: light, unpressured social hooks, not a leaderboard grind.",
},
{
title: "Cross-progression",
body: "One save follows you across mobile, web, and desktop, so your colony is always where you are.",
},
];
export default function MushroomCapitalPage() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<JsonLd data={breadcrumbLd([
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
{ name: "Mushroom Capital", path: "/products/mushroom-capital" },
])} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<JsonLd
data={breadcrumbLd([
{ name: "Home", path: "/" },
{ name: "Products", path: "/products" },
{ name: "Mushroom Capital", path: "/products/mushroom-capital" },
])}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<JsonLd data={faqLd(faqs)} />
<Header />
<div id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block">
<div
id="main-content"
className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20"
>
<Link
href="/products"
className="text-sm text-muted-foreground hover:text-foreground mb-8 inline-block"
>
← Back to Products
</Link>
<div className="flex items-center gap-4 mb-2">
<Image src="/images/products/mushroom-capital.png" alt="Mushroom Capital icon" width={64} height={64} className="rounded-[22%]" />
<Image
src="/images/products/mushroom-capital.png"
alt="Mushroom Capital icon"
width={64}
height={64}
className="rounded-[22%]"
/>
<div>
<span className="inline-flex items-center rounded-full bg-orange-400/15 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.2em] text-orange-600 dark:text-orange-400 mb-1">
Coming Soon
@@ -86,15 +132,26 @@ export default function MushroomCapitalPage() {
</h1>
</div>
</div>
<p className="text-base text-muted-foreground mb-6">Grow a colony. Bank Capital. Prestige with spores.</p>
<p className="text-base text-muted-foreground mb-6">
Grow a colony. Bank Capital. Prestige with spores.
</p>
<div className="prose dark:prose-invert max-w-none mb-12">
<p className="text-xl text-muted-foreground leading-relaxed">
Mushroom Capital is a cozy 3D idle game where you build and tend a living fungal
ecosystem: a stylized, tilt-shift toybox that keeps growing while you&rsquo;re away. Bank
Capital as you go, prestige with Heirloom Spores, and carry one save across mobile, web, and
Mushroom Capital is a cozy 3D idle game where you build and tend a
living fungal ecosystem: a stylized, tilt-shift toybox that keeps
growing while you&rsquo;re away. Bank Capital as you go, prestige
with Heirloom Spores, and carry one save across mobile, web, and
desktop. Its home is at{" "}
<a href="https://mushroom.capital" target="_blank" rel="noopener noreferrer" className="text-orange-600 dark:text-orange-400 hover:underline">mushroom.capital</a>.
<a
href="https://mushroom.capital"
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline"
>
mushroom.capital
</a>
.
</p>
</div>
@@ -111,10 +168,22 @@ export default function MushroomCapitalPage() {
<h2 className="text-2xl font-bold mb-6">Under the Hood</h2>
<div className="section-container border-l-4 border-orange-500 mb-12">
<ul className="space-y-2 text-muted-foreground">
<li><strong>Engine:</strong> Godot 4 (Vulkan) with Rust (gdext) for the perf-critical client.</li>
<li><strong>Server:</strong> self-hosted Nakama with TypeScript RPC modules and PostgreSQL.</li>
<li><strong>Cross-progression:</strong> one account across mobile, web, and desktop.</li>
<li><strong>Economy:</strong> a clean two-currency prestige loop: Capital and Heirloom Spores.</li>
<li>
<strong>Engine:</strong> Godot 4 (Vulkan) with Rust (gdext) for
the perf-critical client.
</li>
<li>
<strong>Server:</strong> self-hosted Nakama with TypeScript RPC
modules and PostgreSQL.
</li>
<li>
<strong>Cross-progression:</strong> one account across mobile,
web, and desktop.
</li>
<li>
<strong>Economy:</strong> a clean two-currency prestige loop:
Capital and Heirloom Spores.
</li>
</ul>
</div>
@@ -122,16 +191,30 @@ export default function MushroomCapitalPage() {
<div className="space-y-6 mb-12">
{faqs.map((f) => (
<div key={f.q}>
<h3 className="font-bold mb-1 text-orange-600 dark:text-orange-400">{f.q}</h3>
<h3 className="font-bold mb-1 text-orange-600 dark:text-orange-400">
{f.q}
</h3>
<p className="text-muted-foreground">{f.a}</p>
</div>
))}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
<Link href="/products" className="hover:text-foreground transition-colors">All Products</Link>
<Link
href="/products"
className="hover:text-foreground transition-colors"
>
All Products
</Link>
<span>•</span>
<a href="https://mushroom.capital" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">mushroom.capital</a>
<a
href="https://mushroom.capital"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
mushroom.capital
</a>
<span>•</span>
<span>Contact: apps@such.software</span>
</div>
+4 -6
View File
@@ -2,16 +2,14 @@ import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import { ProductsGrid, type Product } from "@/components/portfolio/products-grid";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Portfolio: Products & Projects | Such Software",
description: "Explore Such Software's products: Wownerogue Monero roguelike, Neroswap crypto aggregator, Smirk wallet, headless Webshops, our mobile games, and nuclear games in development.",
alternates: { canonical: "/products" },
openGraph: {
...pageMetadata({
title: "Portfolio: Products & Projects | Such Software",
description: "Explore Such Software's products: Wownerogue Monero roguelike, Neroswap crypto aggregator, Smirk wallet, headless Webshops, our mobile games, and nuclear games in development.",
type: "website",
},
path: "/products",
}),
};
const products: Product[] = [
+54 -33
View File
@@ -1,5 +1,43 @@
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/blog";
import { pillars } from "@/lib/services";
import { staticRoutes } from "@/lib/routes";
/**
* Per-route ranking. Only the routes that differ from the default appear here, so this is a
* short table of opinions rather than a second copy of the site map. A route absent from it
* is still in the sitemap; it just ranks at the default.
*/
const RANK: Record<
string,
{
changeFrequency: MetadataRoute.Sitemap[0]["changeFrequency"];
priority: number;
}
> = {
"/": { changeFrequency: "weekly", priority: 1.0 },
"/products": { changeFrequency: "weekly", priority: 0.9 },
"/services": { changeFrequency: "monthly", priority: 0.9 },
"/blog": { changeFrequency: "weekly", priority: 0.8 },
"/about": { changeFrequency: "monthly", priority: 0.7 },
"/apps": { changeFrequency: "monthly", priority: 0.5 },
"/support": { changeFrequency: "monthly", priority: 0.5 },
"/privacy": { changeFrequency: "yearly", priority: 0.3 },
"/terms": { changeFrequency: "yearly", priority: 0.3 },
};
/** Legal pages change rarely and rank low wherever they sit in the tree. */
function rankFor(route: string) {
if (RANK[route]) return RANK[route];
if (/\/(privacy|terms)$/.test(route)) {
return { changeFrequency: "yearly" as const, priority: 0.3 };
}
if (route.startsWith("/services/"))
return { changeFrequency: "monthly" as const, priority: 0.8 };
if (route.startsWith("/products/"))
return { changeFrequency: "weekly" as const, priority: 0.8 };
return { changeFrequency: "monthly" as const, priority: 0.5 };
}
export default function sitemap(): MetadataRoute.Sitemap {
const base = "https://such.software";
@@ -8,39 +46,22 @@ export default function sitemap(): MetadataRoute.Sitemap {
// 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
// their own per-post dates below.)
const updated = new Date("2026-07-12");
const updated = new Date("2026-09-05");
const staticPages: MetadataRoute.Sitemap = [
{ url: base, lastModified: updated, changeFrequency: "weekly", priority: 1.0 },
{ url: `${base}/products`, lastModified: updated, changeFrequency: "weekly", priority: 0.9 },
{ url: `${base}/products/wownerogue`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/neroswap`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/webshops`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/custom-websites`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/hash-bags`, lastModified: updated, changeFrequency: "weekly", priority: 0.8 },
{ url: `${base}/products/smirk-wallet`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/bauhaus-echo`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/vegan-iq`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/products/suchoice`, lastModified: updated, changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/products/such-moon-launch`, lastModified: updated, changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/products/barns-and-neutrons`, lastModified: updated, changeFrequency: "weekly", priority: 0.8 },
{ url: `${base}/products/alpha-doc`, lastModified: updated, changeFrequency: "weekly", priority: 0.7 },
{ url: `${base}/products/decay-theory`, lastModified: updated, changeFrequency: "weekly", priority: 0.7 },
{ url: `${base}/products/bloomword`, lastModified: updated, changeFrequency: "weekly", priority: 0.8 },
{ url: `${base}/products/meatspace`, lastModified: updated, changeFrequency: "weekly", priority: 0.7 },
{ url: `${base}/products/mushroom-capital`, lastModified: updated, changeFrequency: "weekly", priority: 0.7 },
{ url: `${base}/services`, lastModified: updated, changeFrequency: "monthly", priority: 0.9 },
{ url: `${base}/services/commerce`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/services/games`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/services/crypto`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/services/education`, lastModified: updated, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/about`, lastModified: updated, changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/support`, lastModified: updated, changeFrequency: "monthly", priority: 0.5 },
{ url: `${base}/privacy`, lastModified: updated, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/terms`, lastModified: updated, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/apps`, lastModified: updated, changeFrequency: "monthly", priority: 0.5 },
{ url: `${base}/blog`, lastModified: updated, changeFrequency: "weekly", priority: 0.8 },
];
const staticPages: MetadataRoute.Sitemap = staticRoutes().map((route) => ({
url: route === "/" ? base : `${base}${route}`,
lastModified: updated,
...rankFor(route),
}));
// The service pillars are a dynamic segment, so the tree cannot see them: like blog posts,
// their URLs come from the data that generates them.
const pillarPages: MetadataRoute.Sitemap = pillars.map((pillar) => ({
url: `${base}/services/${pillar.slug}`,
lastModified: updated,
changeFrequency: "monthly",
priority: 0.8,
}));
const blogPosts: MetadataRoute.Sitemap = getAllPosts().map((post) => ({
url: `${base}/blog/${post.slug}`,
@@ -49,5 +70,5 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 0.6,
}));
return [...staticPages, ...blogPosts];
return [...staticPages, ...pillarPages, ...blogPosts];
}
+4 -6
View File
@@ -3,16 +3,14 @@ import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Support & Feedback | Such Software",
description: "Get help with any Such Software app or game. Report a bug, request a feature, or send feedback. We read every message.",
alternates: { canonical: "/support" },
openGraph: {
...pageMetadata({
title: "Support & Feedback | Such Software",
description: "Get help with any Such Software app or game. Report a bug, request a feature, or send feedback. We read every message.",
type: "website",
},
path: "/support",
}),
};
export default function SupportPage() {
+4 -6
View File
@@ -2,16 +2,14 @@ import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata: Metadata = {
title: "Terms of Service | Such Software",
description: "The default terms of service governing the such.software website and any Such Software LLC software, app, game, or wallet that does not have its own terms.",
alternates: { canonical: "/terms" },
openGraph: {
...pageMetadata({
title: "Terms of Service | Such Software",
description: "The default terms of service governing the such.software website and any Such Software LLC software, app, game, or wallet that does not have its own terms.",
type: "website",
},
path: "/terms",
}),
};
export default function TermsPage() {
+19 -2
View File
@@ -27,6 +27,11 @@ export function HomeShell({ children }: { children: ReactNode }) {
const [splashUp, setSplashUp] = useState(true);
const [leaving, setLeaving] = useState(false);
const restoreScroll = useRef(0);
// A visitor who arrived on a deep link (`/#contact`, which is exactly where `/contact`
// redirects) named the place they wanted to be. The splash is the front door's intro, and
// entering it pins scroll to 0, so without this every inbound contact link on a first visit
// played the intro and then dropped the visitor at the top of the homepage, hash discarded.
const deepLink = useRef(false);
const [nebulaPosition, setNebulaPosition] = useState<{ x: number; y: number } | null>(null);
// Respect prefers-reduced-motion: skip the splash entirely and land straight on
@@ -37,7 +42,11 @@ export function HomeShell({ children }: { children: ReactNode }) {
// The splash is a one-time intro per session: once entered, a refresh (even
// from mid-page) skips it so you can't scroll back up into it.
const alreadyEntered = sessionStorage.getItem("such:entered") === "1";
if (reduced || alreadyEntered) {
// Only a hash that actually names something on this page counts. A stale or invented
// fragment should still get the intro rather than silently skipping it.
const target = window.location.hash.slice(1);
deepLink.current = target.length > 0 && document.getElementById(target) !== null;
if (reduced || alreadyEntered || deepLink.current) {
setEntered(true);
setSplashUp(false);
}
@@ -61,7 +70,15 @@ export function HomeShell({ children }: { children: ReactNode }) {
// After the splash unmounts, keep the visitor's position in the content stable
// (no jump). Runs before paint.
useIsoLayoutEffect(() => {
if (entered) window.scrollTo(0, restoreScroll.current);
if (!entered) return;
if (deepLink.current) {
// Honour the fragment once, on arrival. Native anchor scrolling already ran before we
// set `entered`, but the restore below would immediately undo it.
deepLink.current = false;
document.getElementById(window.location.hash.slice(1))?.scrollIntoView();
return;
}
window.scrollTo(0, restoreScroll.current);
}, [entered]);
// Place the nebula/monitor just to the left of the hero title, at any screen width.
+78
View File
@@ -0,0 +1,78 @@
import type { Metadata } from "next";
/**
* The site's social card. Declared once so a page cannot forget it.
*/
export const DEFAULT_OG_IMAGE = "/images/branding/OG_banner_v2_light.png";
type PageMetadataInput = {
title: string;
description: string;
/** Canonical path, root-relative and leading-slashed: "/about". */
path: string;
/** Defaults to the site card. Pass a path here, or omit it entirely on a route that ships
* its own `opengraph-image.tsx`, which Next attaches by file convention. */
image?: string | null;
type?: "website" | "article";
/** Title for the social card, when the tab title is too long or too keyworded to share well. */
socialTitle?: string;
/** Description for the social card. A search snippet and a shared link are read in different
* places by different people, so several pages already word them differently. */
socialDescription?: string;
publishedTime?: string;
modifiedTime?: string;
};
/**
* Build a page's metadata so the social card survives.
*
* Next merges metadata shallowly: a page that exports `openGraph: { title, description }`
* REPLACES the parent's whole `openGraph` object, image included. Every page here wants its own
* title, so every page was writing that block by hand, and seventeen of them dropped the card on
* the way through. Twelve had no `opengraph-image.tsx` to fall back on, so they shared to Slack,
* iMessage and LinkedIn as a bare grey rectangle.
*
* The fix is not to remember harder. It is to make the block impossible to write incompletely:
* pass the parts that differ, and the parts that must not go missing are filled in here.
*/
export function pageMetadata({
title,
description,
path,
image = DEFAULT_OG_IMAGE,
type = "website",
socialTitle,
socialDescription,
publishedTime,
modifiedTime,
}: PageMetadataInput): Metadata {
const cardTitle = socialTitle ?? title;
const cardDescription = socialDescription ?? description;
// `null` is a route saying "I have my own opengraph-image.tsx"; omitting the key entirely is
// what lets Next's file convention supply it, whereas an empty array would suppress it.
const images = image
? [{ url: image, width: 1200, height: 630, alt: "Such Software" }]
: undefined;
return {
title,
description,
alternates: { canonical: path },
openGraph: {
title: cardTitle,
description: cardDescription,
type,
url: path,
siteName: "Such Software",
...(images ? { images } : {}),
...(type === "article" && publishedTime ? { publishedTime } : {}),
...(type === "article" && modifiedTime ? { modifiedTime } : {}),
},
twitter: {
card: image ? "summary_large_image" : "summary",
title: cardTitle,
description: cardDescription,
...(image ? { images: [image] } : {}),
},
};
}
+53
View File
@@ -0,0 +1,53 @@
import fs from "fs";
import path from "path";
/** Where the App Router's pages live, resolved from the package root at build time. */
const APP_DIR = path.join(process.cwd(), "src", "app");
/**
* Routes that exist as pages but must NOT be advertised, each with the reason. A route is
* excluded because of something true about it, not because somebody forgot to add it: that is
* the difference between this list and the hand-kept sitemap it replaces.
*/
const EXCLUDED: Record<string, string> = {
"/contact":
"redirects to /#contact, and a sitemap should name the destination, not the door",
};
function walk(dir: string, segments: string[], out: string[]): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const name = entry.name;
// `[slug]` and `[...rest]` enumerate themselves through generateStaticParams, so their
// URLs come from the data, not from the tree. `(group)` and `_private` are not segments.
if (
name.startsWith("[") ||
name.startsWith("(") ||
name.startsWith("_") ||
name === "api"
) {
continue;
}
const next = [...segments, name];
if (fs.existsSync(path.join(dir, name, "page.tsx")))
out.push("/" + next.join("/"));
walk(path.join(dir, name), next, out);
}
}
/**
* Every static route the app actually builds, discovered from the tree.
*
* The sitemap used to be a list somebody maintained by hand, and it had drifted: nine built
* routes were missing, among them the per-app privacy and terms URLs that Apple and Google
* fetch during store review. A list of pages is derivable from the pages, so deriving it means
* a new page is advertised because it exists rather than because it was remembered.
*/
export function staticRoutes(): string[] {
const out: string[] = [];
if (fs.existsSync(path.join(APP_DIR, "page.tsx"))) out.push("/");
walk(APP_DIR, [], out);
return out.filter((r) => !(r in EXCLUDED)).sort();
}
export const EXCLUDED_ROUTES = EXCLUDED;
+63 -13
View File
@@ -1,19 +1,69 @@
export async function verifyTurnstile(token: string) {
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
export type TurnstileResult =
| { ok: true }
| { ok: false; reason: "unconfigured" | "rejected" | "unreachable" };
/**
* Verify a Turnstile token, failing CLOSED.
*
* This used to `return true` whenever TURNSTILE_SECRET_KEY was unset, and log the token while
* it did it. That is a capability enabled by the absence of configuration: lose the variable in
* a deploy and the contact form silently stops checking anything, with the only trace a warning
* in a log nobody reads. A guard that turns itself off when it cannot find its key is not a
* guard. Missing configuration is now a refusal, and the caller is told which kind, because
* "we are misconfigured" and "you look like a bot" need different answers.
*
* The local-development bypass survives, but as an explicit switch rather than as a side effect
* of an empty variable: set TURNSTILE_DEV_BYPASS=1, and it is ignored in a production build so
* that setting it by accident cannot follow the code into a deploy.
*/
export async function verifyTurnstile(token: string): Promise<TurnstileResult> {
const secretKey = process.env.TURNSTILE_SECRET_KEY;
if (!secretKey) {
console.warn("No TURNSTILE_SECRET_KEY set, skipping verification", token);
return true; // Dev mode bypass
if (
process.env.TURNSTILE_DEV_BYPASS === "1" &&
process.env.NODE_ENV !== "production"
) {
console.warn(
"turnstile: TURNSTILE_DEV_BYPASS=1, skipping verification (development only)",
);
return { ok: true };
}
// Never log the token: it is a single-use credential presented by the visitor.
console.error(
"turnstile: TURNSTILE_SECRET_KEY is not set, so submissions cannot be verified and are " +
"refused. Set it, or set TURNSTILE_DEV_BYPASS=1 for local development.",
);
return { ok: false, reason: "unconfigured" };
}
const formData = new FormData();
formData.append('secret', secretKey);
formData.append('response', token);
const body = new FormData();
body.append("secret", secretKey);
body.append("response", token);
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const data = await res.json();
return data.success;
try {
const res = await fetch(VERIFY_URL, { method: "POST", body });
if (!res.ok) {
console.error(
`turnstile: siteverify answered ${res.status} ${res.statusText}`,
);
return { ok: false, reason: "unreachable" };
}
const data = (await res.json()) as {
success?: boolean;
"error-codes"?: string[];
};
if (data.success) return { ok: true };
// The error codes name the cause (expired token, duplicate submission, bad secret) and
// contain no visitor data, so they are the useful half to keep.
console.warn(
`turnstile: rejected (${(data["error-codes"] ?? ["no reason given"]).join(", ")})`,
);
return { ok: false, reason: "rejected" };
} catch (e) {
console.error("turnstile: could not reach siteverify:", e);
return { ok: false, reason: "unreachable" };
}
}