diff --git a/apps/web/content/blog/vegan-iq-data-pipeline.mdx b/apps/web/content/blog/vegan-iq-data-pipeline.mdx index 9bbdfef..f81f93a 100644 --- a/apps/web/content/blog/vegan-iq-data-pipeline.mdx +++ b/apps/web/content/blog/vegan-iq-data-pipeline.mdx @@ -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. diff --git a/apps/web/package.json b/apps/web/package.json index 02e1632..b3dce32 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/scripts/check-metadata.mjs b/apps/web/scripts/check-metadata.mjs new file mode 100644 index 0000000..58626ff --- /dev/null +++ b/apps/web/scripts/check-metadata.mjs @@ -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`); diff --git a/apps/web/src/actions/submit-contact.ts b/apps/web/src/actions/submit-contact.ts index bf9cf07..7df6237 100644 --- a/apps/web/src/actions/submit-contact.ts +++ b/apps/web/src/actions/submit-contact.ts @@ -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 diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx index 0a986fc..57ebc26 100644 --- a/apps/web/src/app/about/page.tsx +++ b/apps/web/src/app/about/page.tsx @@ -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() { diff --git a/apps/web/src/app/apps/page.tsx b/apps/web/src/app/apps/page.tsx index 578feb2..baf7446 100644 --- a/apps/web/src/app/apps/page.tsx +++ b/apps/web/src/app/apps/page.tsx @@ -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" }, ], }, { diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index 88a7695..abd787e 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -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 { 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) { diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index 3100cc6..e6477c1 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -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() { diff --git a/apps/web/src/app/evaluetron/privacy/page.tsx b/apps/web/src/app/evaluetron/privacy/page.tsx index b3b84ad..a481729 100644 --- a/apps/web/src/app/evaluetron/privacy/page.tsx +++ b/apps/web/src/app/evaluetron/privacy/page.tsx @@ -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() { diff --git a/apps/web/src/app/evaluetron/terms/page.tsx b/apps/web/src/app/evaluetron/terms/page.tsx index ffc19b2..d1270a7 100644 --- a/apps/web/src/app/evaluetron/terms/page.tsx +++ b/apps/web/src/app/evaluetron/terms/page.tsx @@ -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() { diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index ce205c7..09fbb8a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -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; } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 770f517..a380cd2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -33,13 +33,13 @@ export default function Home() {

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 - & 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 & games of our own.

- - + + diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index e6e5fe2..901e45c 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -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.

+

+ 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. +

Server logs

diff --git a/apps/web/src/app/products/bloomword/page.tsx b/apps/web/src/app/products/bloomword/page.tsx index 865a615..5ceb277 100644 --- a/apps/web/src/app/products/bloomword/page.tsx +++ b/apps/web/src/app/products/bloomword/page.tsx @@ -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 (

- -