Merge branch 'feat/hash-bags-ios-live-20260906'

This commit is contained in:
jwinterm
2026-09-06 01:10:36 -04:00
31 changed files with 1120 additions and 332 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.
+3 -1
View File
@@ -9,7 +9,9 @@
"lint": "eslint .",
"test": "playwright test",
"test:a11y": "playwright test tests/a11y.spec.ts",
"test:ui": "playwright test --ui"
"test:ui": "playwright test --ui",
"check:metadata": "node scripts/check-metadata.mjs",
"check": "pnpm run lint && pnpm run build && pnpm run check:metadata"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.4.1",
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* Assert the social and canonical tags on the pages we actually built.
*
* Metadata in Next is composed at build time from a parent object and whatever each page
* exports, and it merges shallowly: a page that declares its own `openGraph` block replaces the
* parent's, image included. That is not visible in a diff, in a type error, or in lint. It is
* only visible in the HTML, which is why this reads the HTML.
*
* Seventeen pages had done it before `pageMetadata()` landed, twelve of them with no
* `opengraph-image.tsx` to fall back on, so those shared to Slack and LinkedIn as a bare grey
* rectangle for however long it had been true. This is the check that would have said so.
*
* node scripts/check-metadata.mjs # after `pnpm build`
*/
import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const BUILD = join(process.cwd(), ".next", "server", "app");
/** Every tag a page must carry, and why it is not optional. */
const REQUIRED = [
{
name: "og:image",
test: /property="og:image"/,
why: "a shared link renders as a blank card without it",
},
{
name: "twitter:image",
test: /name="twitter:image"/,
why: "X and iMessage read this one, not og:image",
},
{
name: "canonical",
test: /rel="canonical"/,
why: "without it a page competes with its own query-string variants",
},
{
name: "description",
test: /name="description"/,
why: "the search snippet is otherwise chosen for us",
},
];
function htmlFiles(dir, out = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) htmlFiles(full, out);
else if (entry.name.endsWith(".html")) out.push(full);
}
return out;
}
if (!existsSync(BUILD) || !statSync(BUILD).isDirectory()) {
console.error(
`check-metadata: no build output at ${relative(process.cwd(), BUILD)}. ` +
"This reads the HTML Next actually emitted, so it needs `pnpm build` to have run. " +
"Refusing rather than reporting a pass over zero pages.",
);
process.exit(2);
}
const pages = htmlFiles(BUILD);
if (pages.length === 0) {
console.error(
`check-metadata: ${relative(process.cwd(), BUILD)} exists but contains no .html, so the ` +
"build produced nothing to check. Refusing rather than passing vacuously.",
);
process.exit(2);
}
let failed = 0;
for (const file of pages.sort()) {
const html = readFileSync(file, "utf8");
const route = "/" + relative(BUILD, file).replace(/\.html$/, "").replace(/^index$/, "");
for (const { name, test, why } of REQUIRED) {
if (!test.test(html)) {
console.error(`check-metadata: ${route} has no ${name}: ${why}`);
failed += 1;
}
}
}
if (failed) {
console.error(
`\ncheck-metadata: ${failed} missing tag(s) across ${pages.length} built pages. ` +
"Build the page's metadata with `pageMetadata()` from src/lib/page-metadata.ts, which " +
"fills these in, rather than hand-writing an openGraph block that drops them.",
);
process.exit(1);
}
console.log(`check-metadata: ${pages.length} built pages carry og:image, twitter:image, canonical and description`);
+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() {
+1 -1
View File
@@ -30,7 +30,7 @@ const apps = [
href: "/products/hash-bags",
links: [
{ platform: "google" as const, href: "https://play.google.com/store/apps/details?id=com.suchsoftware.hashwallet" },
{ platform: "apple" as const, href: null },
{ platform: "apple" as const, href: "https://apps.apple.com/us/app/hash-bags/id6770757859" },
],
},
{
+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() {
+12 -6
View File
@@ -1,3 +1,8 @@
/* The generated brand tokens. Source of truth is such-graphics
packs/such-software/pack.json; regenerate with `such-graphics project-brand` and copy
the result here. Never hand-edit styles/brand.css: the pack gate compares it. */
@import "../styles/brand.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -290,10 +295,11 @@
and the symmetric cubic-bezier easing removes the hard turnaround the old ease-in-out had
at each end of the travel. */
/* The brand's four-colour system (S green, U magenta, C blue, H teal), one per glyph.
Light theme uses the light palette; .dark swaps in the dark palette, which is the
same hues lifted for contrast on near-black -- from packs/such-software/pack.json. */
These were four hand-typed hexes per theme, copied out of the pack and free to drift
from it. They now come from the generated tokens above, so the pack is the only place
the values exist. */
.hero-title {
background-image: linear-gradient(110deg, #1a5c38 0%, #b83670 30%, #176a92 55%, #12857e 80%, #1a5c38 100%);
background-image: linear-gradient(110deg, var(--sg-glyph-s) 0%, var(--sg-glyph-u) 30%, var(--sg-glyph-c) 55%, var(--sg-glyph-h) 80%, var(--sg-glyph-s) 100%);
background-size: 300% auto;
-webkit-background-clip: text;
background-clip: text;
@@ -301,16 +307,16 @@
animation: hero-sheen 18s cubic-bezier(0.37, 0, 0.63, 1) infinite;
}
.dark .hero-title {
background-image: linear-gradient(110deg, #35925c 0%, #e8689e 30%, #4aade0 55%, #3ad4c6 80%, #35925c 100%);
background-image: linear-gradient(110deg, var(--sg-glyph-s-dark) 0%, var(--sg-glyph-u-dark) 30%, var(--sg-glyph-c-dark) 55%, var(--sg-glyph-h-dark) 80%, var(--sg-glyph-s-dark) 100%);
}
.brand-gradient-text {
background-image: linear-gradient(90deg, #1a5c38, #b83670, #176a92, #12857e);
background-image: linear-gradient(90deg, var(--sg-glyph-s), var(--sg-glyph-u), var(--sg-glyph-c), var(--sg-glyph-h));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.dark .brand-gradient-text {
background-image: linear-gradient(90deg, #35925c, #e8689e, #4aade0, #3ad4c6);
background-image: linear-gradient(90deg, var(--sg-glyph-s-dark), var(--sg-glyph-u-dark), var(--sg-glyph-c-dark), var(--sg-glyph-h-dark));
}
@keyframes hero-sheen {
0%, 100% { background-position: 0% center; }
+4 -4
View File
@@ -33,13 +33,13 @@ export default function Home() {
</h1>
<p className="max-w-2xl text-xl text-muted-foreground mb-8">
Custom e-commerce, apps, games, and cryptocurrency tools, built in Kennett Square,
PA. End to end delivery or consulting engagements. Plus a shelf of mobile apps
&amp; games of our own.
PA. Web services can be provided on revenue share: we build, host and run it, and
earn a share once you are live. Plus a shelf of mobile apps &amp; games of our own.
</p>
<div className="flex flex-wrap justify-center gap-4 mb-14">
<Link href="/products">
<Button size="lg" className="rounded-full shadow-lg shadow-[#12857e]/25 hover:shadow-[#3ad4c6]/40 hover:scale-[1.03] transition-all">View Portfolio</Button>
<Link href="/services">
<Button size="lg" className="rounded-full shadow-lg shadow-[#12857e]/25 hover:shadow-[#3ad4c6]/40 hover:scale-[1.03] transition-all">See What We Build</Button>
</Link>
<Link href="#contact">
<Button variant="outline" size="lg" className="rounded-full hover:scale-[1.03] transition-all">Start a Project</Button>
+11 -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() {
@@ -86,6 +84,13 @@ export default function PrivacyPage() {
collected from your visit. We do not use Google Analytics, advertising pixels, or similar
third-party tracking tools on the Site.
</p>
<p className="mt-3">
Alongside page views, we count two things you do on purpose: that someone began filling in
the contact form, and that someone sent it. These are counters, not contents. They record
which of the two forms it was and nothing you typed, and like everything else here they are
not collected at all if Do Not Track or Global Privacy Control is on. We use them to tell
whether a change to the site helped or hurt, rather than guessing.
</p>
<h3 className="text-xl font-medium mt-4 mb-2">Server logs</h3>
<p>
+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>
+5 -6
View File
@@ -8,7 +8,7 @@ import Link from "next/link";
import type { Metadata } from "next";
const DESCRIPTION =
"Open-source, non-custodial wallet for Monero, Wownero, Bitcoin, and nine more chains. Wownero-first, with Ledger, Trezor, and BitBox support. Free on Android; iOS and desktop coming soon.";
"Open-source, non-custodial wallet for Monero, Wownero, Bitcoin, and nine more chains. Wownero-first, with Ledger, Trezor, and BitBox support. Free on Android and iOS; desktop coming soon.";
export const metadata: Metadata = {
title: "Hash Bags: Non-Custodial Multi-Chain Crypto Wallet | Such Software",
@@ -54,7 +54,7 @@ export default function HashBagsPage() {
path: "/products/hash-bags",
description: DESCRIPTION,
category: "FinanceApplication",
operatingSystem: "Android",
operatingSystem: "Android, iOS",
price: "0",
})} />
<Header />
@@ -80,7 +80,7 @@ export default function HashBagsPage() {
<p className="text-xl text-muted-foreground leading-relaxed">
A wallet for your coins. Hash Bags is an open-source, non-custodial, multi-chain
crypto wallet: your keys and your funds stay on your device, and we never hold
either. Free on Android today, with iOS and desktop on the way.
either. Free on Android and iOS today, with desktop on the way.
</p>
</div>
@@ -88,13 +88,12 @@ export default function HashBagsPage() {
<StoreButtons
links={[
{ platform: "google", href: "https://play.google.com/store/apps/details?id=com.suchsoftware.hashwallet" },
{ platform: "apple", href: null },
{ platform: "apple", href: "https://apps.apple.com/us/app/hash-bags/id6770757859" },
]}
extraLinks={[{ href: "https://hash.boats", label: "hash.boats" }]}
/>
<p className="mt-4 text-sm text-muted-foreground">
Live on Google Play. The iOS build is in review, and macOS, Linux, and Windows
follow after that.
Live on Google Play and the App Store. macOS, Linux and Windows follow.
</p>
</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>
+5 -7
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[] = [
@@ -34,7 +32,7 @@ const products: Product[] = [
{
slug: "hash-bags",
title: "Hash Bags",
description: "Open-source, non-custodial wallet for Monero, Wownero, Bitcoin, and nine more chains. Wownero-first, with Ledger, Trezor, and BitBox support. Android now; iOS and desktop coming.",
description: "Open-source, non-custodial wallet for Monero, Wownero, Bitcoin, and nine more chains. Wownero-first, with Ledger, Trezor, and BitBox support. Android and iOS now; desktop coming.",
color: "emerald",
icon: "/images/products/hash-bags.png",
releaseDate: "2026-08-17",
+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() {
@@ -1,10 +1,11 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { submitContactForm } from '@/actions/submit-contact';
import { TurnstileWidget } from './turnstile-widget';
import { useState, useEffect, useRef } from 'react';
import { Button } from '@repo/ui/components/button';
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { submitContactForm } from "@/actions/submit-contact";
import { TurnstileWidget } from "./turnstile-widget";
import { useState, useEffect, useRef } from "react";
import { Button } from "@repo/ui/components/button";
import { EVENTS, track } from "@/lib/analytics";
function SubmitButton({ label = "Send Message" }: { label?: string }) {
const { pending } = useFormStatus();
@@ -21,50 +22,68 @@ function SubmitButton({ label = "Send Message" }: { label?: string }) {
}
interface ContactFormProps {
source?: 'inquiry' | 'support';
source?: "inquiry" | "support";
}
export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
const [state, formAction] = useActionState(submitContactForm, { success: false, message: '' });
const [token, setToken] = useState<string>('');
export function ContactForm({ source = "inquiry" }: ContactFormProps) {
const [state, formAction] = useActionState(submitContactForm, {
success: false,
message: "",
});
const [token, setToken] = useState<string>("");
const formRef = useRef<HTMLFormElement>(null);
// Fires once per mounted form, on the first real edit. A focus would count a visitor who
// tabbed through on the way somewhere else; typing is intent.
const startedRef = useRef(false);
const isSupport = source === 'support';
const noteStart = () => {
if (startedRef.current) return;
startedRef.current = true;
track(EVENTS.contactStarted, { source });
};
const isSupport = source === "support";
// Reset form on successful submission
useEffect(() => {
if (state.success && formRef.current) {
formRef.current.reset();
setToken('');
setToken("");
startedRef.current = false;
track(EVENTS.contactSubmitted, { source });
}
}, [state.success]);
}, [state.success, source]);
return (
<form
ref={formRef}
action={formAction}
onInput={noteStart}
className="space-y-8 w-full max-w-6xl mx-auto section-container"
>
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl -mr-32 -mt-32" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-blue-500/5 rounded-full blur-3xl -ml-32 -mb-32" />
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl -mr-32 -mt-32" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-blue-500/5 rounded-full blur-3xl -ml-32 -mb-32" />
<div className="text-left mb-10 relative z-10">
<h3 className="text-3xl font-bold tracking-tight">
{isSupport ? 'Send Feedback' : 'Project Inquiry'}
</h3>
<p className="text-muted-foreground text-lg mt-3 max-w-2xl text-balance">
{isSupport
? 'Found a bug? Have a feature request? We read every message and typically respond within 24 hours.'
: 'Tell us about your technical challenges. From Medusa v2 migrations to real-time custom engines, we\'re ready to architect your solution.'}
</p>
</div>
<div className="text-left mb-10 relative z-10">
<h3 className="text-3xl font-bold tracking-tight">
{isSupport ? "Send Feedback" : "Project Inquiry"}
</h3>
<p className="text-muted-foreground text-lg mt-3 max-w-2xl text-balance">
{isSupport
? "Found a bug? Have a feature request? We read every message and typically respond within 24 hours."
: "Tell us about your technical challenges. From Medusa v2 migrations to real-time custom engines, we're ready to architect your solution."}
</p>
</div>
<input type="hidden" name="source" value={source} />
<div className="grid md:grid-cols-2 gap-8 relative z-10">
<div className="space-y-3">
<label htmlFor="name" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1">
{isSupport ? 'Your Name' : 'Full Name'}
<label
htmlFor="name"
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
>
{isSupport ? "Your Name" : "Full Name"}
</label>
<input
name="name"
@@ -75,12 +94,23 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
aria-invalid={state?.errors?.name ? "true" : undefined}
className="w-full p-4 border-2 border-border/50 rounded-2xl bg-background/50 focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg"
/>
{state?.errors?.name && <p id="name-error" className="text-red-500 text-sm px-1" role="alert">{state.errors.name}</p>}
{state?.errors?.name && (
<p
id="name-error"
className="text-red-500 text-sm px-1"
role="alert"
>
{state.errors.name}
</p>
)}
</div>
<div className="space-y-3">
<label htmlFor="email" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1">
{isSupport ? 'Email' : 'Work Email'}
<label
htmlFor="email"
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
>
{isSupport ? "Email" : "Work Email"}
</label>
<input
name="email"
@@ -92,30 +122,53 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
aria-invalid={state?.errors?.email ? "true" : undefined}
className="w-full p-4 border-2 border-border/50 rounded-2xl bg-background/50 focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg"
/>
{state?.errors?.email && <p id="email-error" className="text-red-500 text-sm px-1" role="alert">{state.errors.email}</p>}
{state?.errors?.email && (
<p
id="email-error"
className="text-red-500 text-sm px-1"
role="alert"
>
{state.errors.email}
</p>
)}
</div>
</div>
<div className="space-y-3 relative z-10">
<label htmlFor="message" className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1">
{isSupport ? 'Your Message' : 'Project Details'}
<label
htmlFor="message"
className="text-sm font-semibold tracking-wide uppercase text-muted-foreground ml-1"
>
{isSupport ? "Your Message" : "Project Details"}
</label>
<textarea
name="message"
id="message"
placeholder={isSupport
? "Describe the issue or suggestion. Include your device, OS version, and game version if reporting a bug..."
: "What are you building? Mention any specific constraints, timelines, or technologies..."}
placeholder={
isSupport
? "Describe the issue or suggestion. Include your device, OS version, and game version if reporting a bug..."
: "What are you building? Mention any specific constraints, timelines, or technologies..."
}
required
aria-describedby={state?.errors?.message ? "message-error" : undefined}
aria-describedby={
state?.errors?.message ? "message-error" : undefined
}
aria-invalid={state?.errors?.message ? "true" : undefined}
className="w-full p-6 border-2 border-border/50 rounded-2xl bg-background/50 h-56 resize-none focus:ring-4 focus:ring-primary/10 focus:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary transition-all text-lg leading-relaxed"
/>
{state?.errors?.message && <p id="message-error" className="text-red-500 text-sm px-1" role="alert">{state.errors.message}</p>}
{state?.errors?.message && (
<p
id="message-error"
className="text-red-500 text-sm px-1"
role="alert"
>
{state.errors.message}
</p>
)}
</div>
<input type="hidden" name="token" value={token} />
<div className="flex flex-col md:flex-row items-center justify-between gap-8 pt-4 relative z-10 border-t border-border/50">
<div className="scale-100 flex items-center gap-3">
<TurnstileWidget onVerify={setToken} />
@@ -127,7 +180,7 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
<SubmitButton label={isSupport ? "Send Feedback" : "Send Message"} />
</div>
</div>
{state?.message && (
<div
role="status"
@@ -139,7 +192,9 @@ export function ContactForm({ source = 'inquiry' }: ContactFormProps) {
}`}
>
<div className="flex items-center gap-3">
<span className="text-xl" aria-hidden="true">{state.success ? "\u2713" : "\u26A0"}</span>
<span className="text-xl" aria-hidden="true">
{state.success ? "\u2713" : "\u26A0"}
</span>
<p className="font-medium">{state.message}</p>
</div>
</div>
@@ -5,8 +5,31 @@ import { useEffect, useRef, useState } from "react";
// How long the splash holds before it hands over. This MUST match the pack's
// motion.splash.ms: the morph asset is generated to end exactly on this number, so a gate
// that closes early cuts the last letter mid-draw and one that closes late holds a still
// frame. See such-graphics packs/such-software/pack.json.
const SPLASH_MS = 1337;
// frame.
//
// It used to say that, and then hardcode 1337 next to a comment asking the next person to
// keep two repositories in agreement by hand. The pack has always been able to emit the
// number: it reaches us as `--sg-dur-splash` in the generated brand.css, so now we read it.
// The literal below is a fallback for the case where the stylesheet did not load, not a
// second copy of the value.
const SPLASH_FALLBACK_MS = 1337;
function splashMs(): number {
if (typeof window === "undefined") return SPLASH_FALLBACK_MS;
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--sg-dur-splash")
.trim();
const value = Number.parseFloat(raw);
if (!Number.isFinite(value) || value <= 0) {
console.warn(
`hero-splash: --sg-dur-splash is ${raw || "unset"}, so the generated brand tokens are ` +
`not loaded; holding for ${SPLASH_FALLBACK_MS}ms instead.`,
);
return SPLASH_FALLBACK_MS;
}
// CSS times are "1337ms" or "1.337s"; only the latter needs scaling.
return raw.endsWith("ms") ? value : value * 1000;
}
// Long enough to read as a cross-fade, short enough that it does not feel like a second
// gate stacked on the first.
@@ -27,7 +50,7 @@ const BURST_CELLS = [
*
* Four dots become the four pillar symbols become the word SUCH, then the page takes over.
* The whole thing is one generated SVG (morph_splash*.svg) whose CSS timeline is scaled to
* land on SPLASH_MS, so this component only has to hold it and then get out of the way.
* land on that duration, so this component only has to hold it and then get out of the way.
*
* It is a FIXED overlay from its first paint, never a block in document flow. That is not
* cosmetic: in flow, removing it pulled a viewport of height out of the page and shifted
@@ -63,7 +86,7 @@ export function HeroSplash({ onEnter, leaving }: { onEnter: () => void; leaving?
// The gate: hand over when the morph ends.
useEffect(() => {
if (leaving) return;
const id = window.setTimeout(() => enterRef.current(), SPLASH_MS);
const id = window.setTimeout(() => enterRef.current(), splashMs());
return () => window.clearTimeout(id);
}, [leaving]);
+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.
+35
View File
@@ -0,0 +1,35 @@
type Umami = { track: (name: string, data?: Record<string, unknown>) => void };
/**
* Record a named event, if and only if analytics are already running.
*
* There is no event in this codebase today, which is why every argument about the site's copy
* has been an argument about taste. Two events (a visitor starting the contact form, and one
* completing it) turn the next rewrite into a before-and-after.
*
* This deliberately does no consent checking of its own. `UmamiAnalytics` already declines to
* load the script under Global Privacy Control, and the script itself honours Do Not Track, so
* `window.umami` is simply absent for a visitor who opted out and every call here is a no-op.
* Re-implementing the gate would give it a second place to be wrong, and the two could disagree.
*
* Never throws: an analytics call is not worth breaking a form submission over.
*/
export function track(name: string, data?: Record<string, unknown>): void {
if (typeof window === "undefined") return;
const umami = (window as unknown as { umami?: Umami }).umami;
if (!umami?.track) return;
try {
umami.track(name, data);
} catch {
// Swallowed on purpose. See above.
}
}
/**
* The event names, in one place so a typo cannot quietly create a second funnel that looks
* empty. Values are the strings that appear in the dashboard.
*/
export const EVENTS = {
contactStarted: "contact-started",
contactSubmitted: "contact-submitted",
} as const;
+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;
+64 -14
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 res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const body = new FormData();
body.append("secret", secretKey);
body.append("response", token);
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" };
}
}
+88
View File
@@ -0,0 +1,88 @@
/* pack=such-software version=3.0.0 source=02fb904f79a118d7e33d470cbbf51aa1a3ed0123 */
:root {
/* GENERATED by such_graphics.style.tokens from the style pack -- do not edit */
--sg-accent: #e8b23a;
--sg-bg: #f7f8fa;
--sg-bg-dark: #0a0a0a;
--sg-crit: #b83670;
--sg-faint: #aab2bf;
--sg-field: #1a5c38;
--sg-field-dark: #45c99e;
--sg-field-hi: #2f8f5e;
--sg-field-hi-dark: #7ddfbe;
--sg-frame: #1a5c38;
--sg-frame-dark: #45c99e;
--sg-glyph-c: #176a92;
--sg-glyph-c-dark: #4aade0;
--sg-glyph-h: #12857e;
--sg-glyph-h-dark: #3ad4c6;
--sg-glyph-s: #1a5c38;
--sg-glyph-s-dark: #35925c;
--sg-glyph-u: #b83670;
--sg-glyph-u-dark: #e8689e;
--sg-ink: #0a0a14;
--sg-ink-dark: #f7f8fa;
--sg-muted: #5b6472;
--sg-ok: #237456;
--sg-spark: #0a0a14;
--sg-spark-dark: #ffffff;
--sg-warn: #b8863a;
--sg-font-body: 'Manrope', 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--sg-font-display: 'Manrope', 'DM Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--sg-font-mono: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
--sg-fs-body: 13px;
--sg-fs-caption: 11px;
--sg-fs-label: 12px;
--sg-fs-subhead: 15px;
--sg-fs-title: 34px;
--sg-fs-wordmark: 78px;
--sg-dur-draw: 1600ms;
--sg-motion-draw-curve-name: ease-in-out;
--sg-motion-draw-curve: ease-in-out;
--sg-dur-fade: 400ms;
--sg-motion-fade-curve-name: ease-out;
--sg-motion-fade-curve: ease-out;
--sg-dur-frame: 900ms;
--sg-motion-frame-curve-name: ease-out;
--sg-motion-frame-curve: ease-out;
--sg-dur-grow: 520ms;
--sg-motion-grow-curve-name: ease-out-back;
--sg-motion-grow-curve: cubic-bezier(0.34, 1.56, 0.64, 1);
--sg-dur-morph: 900ms;
--sg-motion-morph-curve-name: ease-in-out;
--sg-motion-morph-curve: ease-in-out;
--sg-dur-pulse: 3000ms;
--sg-motion-pulse-curve-name: linear;
--sg-motion-pulse-curve: linear;
--sg-dur-settle: 420ms;
--sg-motion-settle-curve-name: ease-in-out;
--sg-motion-settle-curve: ease-in-out;
--sg-dur-splash: 1337ms;
--sg-motion-splash-curve-name: ease-in-out;
--sg-motion-splash-curve: ease-in-out;
--sg-dur-symbol-hold: 1100ms;
--sg-motion-symbol-hold-curve-name: linear;
--sg-motion-symbol-hold-curve: linear;
--sg-corner-lg: 24px;
--sg-corner-md: 16px;
--sg-corner-sm: 8px;
--sg-touch-min: 48px;
--sg-role-accent-color: var(--sg-glyph-u);
--sg-role-accent-font-family: var(--sg-font-display);
--sg-role-accent-font-size: var(--sg-fs-subhead);
--sg-role-body-color: var(--sg-ink);
--sg-role-body-font-family: var(--sg-font-body);
--sg-role-body-font-size: var(--sg-fs-body);
--sg-role-caption-color: var(--sg-muted);
--sg-role-caption-font-family: var(--sg-font-mono);
--sg-role-caption-font-size: var(--sg-fs-caption);
--sg-role-subtitle-color: var(--sg-muted);
--sg-role-subtitle-font-family: var(--sg-font-display);
--sg-role-subtitle-font-size: var(--sg-fs-subhead);
--sg-role-title-color: var(--sg-ink);
--sg-role-title-font-family: var(--sg-font-display);
--sg-role-title-font-size: var(--sg-fs-title);
--sg-role-wordmark-color: var(--sg-ink);
--sg-role-wordmark-font-family: var(--sg-font-display);
--sg-role-wordmark-font-size: var(--sg-fs-wordmark);
}
+3 -2
View File
@@ -7,7 +7,8 @@
"start": "turbo run start",
"lint": "turbo run lint",
"clean": "turbo run clean && rm -rf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check": "turbo run check"
},
"devDependencies": {
"eslint": "^9.0.0",
@@ -39,4 +40,4 @@
"fast-uri": "^3.1.2"
}
}
}
}
+25 -6
View File
@@ -1,14 +1,27 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**", ".docusaurus/**"],
"env": ["NEXT_PUBLIC_TURNSTILE_SITE_KEY"]
"dependsOn": [
"^build"
],
"outputs": [
".next/**",
"!.next/cache/**",
"dist/**",
".docusaurus/**"
],
"env": [
"NEXT_PUBLIC_TURNSTILE_SITE_KEY"
]
},
"lint": {
"dependsOn": ["^lint"]
"dependsOn": [
"^lint"
]
},
"dev": {
"cache": false,
@@ -17,6 +30,12 @@
"start": {
"cache": false,
"persistent": true
},
"check": {
"dependsOn": [
"^build"
],
"cache": false
}
}
}
}