Skip to content
This repository was archived by the owner on Oct 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@
"product:truth": "node scripts/check-product-truth.mjs",
"mobbin:run": "node scripts/run-design-system-proving-lab.mjs",
"mobbin:check": "node scripts/run-design-system-proving-lab.mjs --dry-run",
"ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check",
"ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-soft-404.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check",
"check": "npm test",
"test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check"
"test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-brand-disambiguation.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && node scripts/test-public-social-preview.mjs && node scripts/test-public-soft-404.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check"
},
"engines": {
"node": ">=20"
Expand Down
133 changes: 133 additions & 0 deletions public/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page not found • Tiny Studio</title>
<meta
name="description"
content="The page you were looking for could not be found on the Tiny Studio site. Start from the home page or contact support for help."
>
<meta name="robots" content="noindex">
<meta name="theme-color" content="#f3efe7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#111824" media="(prefers-color-scheme: dark)">
<link rel="apple-touch-icon" href="/apple-touch-icon.svg">
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<div class="shell">
<a class="brand" href="/">
<span class="brand-mark" aria-hidden="true">TS</span>
<span class="brand-copy">
<strong>Tiny Studio</strong>
<span>Independent iPhone apps</span>
</span>
</a>
<nav class="top-nav" aria-label="Primary">
<a href="/">Home</a>
<a href="/#products">Apps</a>
<a href="/contact/">Contact</a>
<a href="/privacy/">Privacy</a>
<a href="/privacy-choices/">Privacy choices</a>
</nav>
</div>
</header>

<main id="main" class="page-main">
<div class="shell page-shell">
<section class="page-hero-card reveal">
<div>
<p class="eyebrow">404</p>
<h1>This page could not be found.</h1>
<p class="page-lead">
The address you tried does not match a page on this site. The
link may be out of date, or the page may have moved.
</p>
<div class="action-row">
<a class="button" href="/">Back to home</a>
<a class="ghost-button" href="/contact/">Contact support</a>
</div>
<div class="meta-row">
<span class="chip accent">Start from the home page</span>
<span class="chip">Or use the support pages below</span>
</div>
</div>

<aside class="page-card dark">
<p class="eyebrow">What still works</p>
<ul class="plain-list">
<li>Check the URL for typos and try again.</li>
<li>Return to the <a href="/">home page</a> and follow a current link.</li>
<li>Email <a href="mailto:support@tinystudio.in">support@tinystudio.in</a> if you need help.</li>
</ul>
</aside>
</section>

<section class="app-strip reveal delay-2">
<div class="app-strip-grid">
<article>
<p class="eyebrow">Real destinations</p>
<ul class="plain-list">
<li><a href="/">Home</a> — Tiny Studio, Promptly, Drishti, and 0509</li>
<li><a href="/support/">Support</a> — one shared inbox for every app</li>
<li><a href="/contact/">Contact</a> — studio questions and feedback</li>
</ul>
</article>
<article>
<p class="eyebrow">App pages</p>
<ul class="plain-list">
<li><a href="/promptly/">Promptly</a> for bookings, reminders, and client links</li>
<li><a href="/drishti/">Drishti</a> for mindful screen time</li>
</ul>
</article>
</div>
</section>
</div>
</main>

<footer class="footer">
<div class="shell footer-grid">
<section class="footer-panel">
<p class="eyebrow">Tiny Studio</p>
<h2>Focused apps. Clear public pages. Thoughtful support.</h2>
<p class="footer-copy" style="margin-top: 14px;">
Tiny Studio builds independent iPhone apps and gives each product a
clean public foundation before launch.
</p>
</section>

<section class="footer-panel">
<div class="footer-columns">
<div>
<h3>Apps</h3>
<ul class="footer-links">
<li><a href="/promptly/">Promptly</a></li>
<li><a href="/drishti/">Drishti</a></li>
</ul>
</div>
<div>
<h3>Support</h3>
<ul class="footer-links">
<li><a href="/support/">Support</a></li>
<li><a href="/contact/">Contact</a></li>
<li><a href="mailto:support@tinystudio.in">support@tinystudio.in</a></li>
</ul>
</div>
<div>
<h3>Legal</h3>
<ul class="footer-links">
<li><a href="/privacy/">Privacy</a></li>
<li><a href="/privacy-choices/">Privacy choices</a></li>
<li><a href="/terms/">Terms</a></li>
</ul>
</div>
</div>
<p class="copyright">© 2026 Tiny Studio</p>
</section>
</div>
</footer></body>
</html>
1 change: 1 addition & 0 deletions scripts/prepare-static-site-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const appleIconPath = path.join(root, "apple-touch-icon.svg");

const htmlFiles = [
"index.html",
"404.html",
"support/index.html",
"contact/index.html",
"privacy/index.html",
Expand Down
1 change: 1 addition & 0 deletions scripts/test-public-link-targets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const ok = (cond, msg) => {
// Every public page that carries the shared header/footer.
const PUBLIC_PAGES = [
"public/index.html",
"public/404.html",
"public/contact/index.html",
"public/promptly/index.html",
"public/promptly/support/index.html",
Expand Down
113 changes: 113 additions & 0 deletions scripts/test-public-soft-404.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { existsSync, readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, join } from "node:path"

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
const read = (p) => readFileSync(join(ROOT, p), "utf8")

let failures = 0
let checks = 0
const ok = (cond, msg) => {
checks++
if (cond) console.log(` ok ${msg}`)
else {
failures++
console.error(` FAIL ${msg}`)
}
}

// Cloudflare Pages treats a top-level 404.html as the not-found page for
// unmatched routes (404 status). Without it, Pages assumes single-page
// application rendering and serves index.html with HTTP 200 for every
// unknown URL - the soft-404 this test guards against.
const NOT_FOUND_PAGE = "public/404.html"
const HOME_PAGE = "public/index.html"
const NOT_FOUND_TITLE = "Page not found • Tiny Studio"

const internalHrefsOf = (html) =>
[...html.matchAll(/\bhref="([^"]+)"/gi)]
.map((m) => m[1])
.filter(
(href) =>
!/^(mailto:|tel:|#|https?:\/\/)/i.test(href) &&
!href.startsWith("/cdn-cgi/")
)

// Resolve a repo-style absolute href (e.g. "/", "/contact/", "/#products")
// to one or more candidate paths under public/ and report the first hit.
const resolvesUnderPublic = (href) => {
const clean = href.split("#")[0].split("?")[0]
if (clean === "" || clean === "/") {
return existsSync(join(ROOT, "public/index.html"))
}
const noLeading = clean.replace(/^\//, "")
const candidates = []
if (noLeading.endsWith("/")) {
candidates.push(`${noLeading}index.html`)
} else if (!noLeading.includes(".")) {
candidates.push(noLeading, `${noLeading}.html`)
} else {
candidates.push(noLeading)
}
return candidates.some((c) => existsSync(join(ROOT, "public", c)))
}

console.log("test-public-soft-404: unknown URLs must not soft-404 as the homepage")

console.log("A. public/404.html is a real not-found page, not a homepage clone")
ok(existsSync(join(ROOT, NOT_FOUND_PAGE)), "public/404.html exists")
if (existsSync(join(ROOT, NOT_FOUND_PAGE))) {
const html = read(NOT_FOUND_PAGE)
const home = read(HOME_PAGE)
const title = html.match(/<title>([^<]*)<\/title>/i)?.[1] ?? ""
const homeTitle = home.match(/<title>([^<]*)<\/title>/i)?.[1] ?? ""
ok(title.toLowerCase().includes("not found"), "the page title marks it as not found")
ok(title !== homeTitle, "the 404 page does not reuse the homepage title")
ok(!/href="https:\/\/tinystudio\.in\/"/.test(html), "the 404 page has no canonical pointing at the homepage")
ok(/<meta name="robots" content="noindex">/.test(html), "the 404 page is noindex as a belt-and-suspenders guard")
const levels = [...html.matchAll(/<h([1-6])\b[^>]*>/gi)].map((m) => Number(m[1]))
ok(levels.filter((l) => l === 1).length === 1, "the 404 page has exactly one H1")
ok(levels[0] === 1, "the H1 is the first heading in the outline")
const h1 = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? ""
ok(/not be found|could not be found/i.test(h1), "the H1 tells visitors the page could not be found")
ok(!html.includes(home.slice(0, 200)), "the 404 page does not embed the homepage content")
}

console.log("B. every internal link on the 404 page resolves to a real public destination")
ok(existsSync(join(ROOT, NOT_FOUND_PAGE)), "public/404.html exists")
if (existsSync(join(ROOT, NOT_FOUND_PAGE))) {
const hrefs = internalHrefsOf(read(NOT_FOUND_PAGE))
ok(hrefs.length >= 6, `the 404 page offers real escape links (${hrefs.length} internal links)`)
let broken = 0
for (const href of hrefs) {
if (!resolvesUnderPublic(href)) {
broken++
console.error(` broken target: ${href}`)
}
}
ok(broken === 0, "no internal link on the 404 page points at a missing destination")
}

console.log("C. the 404 page carries the shared header/footer chrome")
ok(existsSync(join(ROOT, NOT_FOUND_PAGE)), "public/404.html exists")
if (existsSync(join(ROOT, NOT_FOUND_PAGE))) {
const html = read(NOT_FOUND_PAGE)
ok(/class="site-header"/.test(html), "the 404 page keeps the site header")
ok(/class="top-nav"/.test(html), "the 404 page keeps the primary navigation")
ok(/class="footer"/.test(html), "the 404 page keeps the site footer")
ok(/class="footer-links"/.test(html), "the 404 page keeps footer link lists")
}

console.log("D. npm test/ci wiring")
const pkg = JSON.parse(read("package.json"))
ok(
pkg.scripts.test.includes("test-public-soft-404.mjs"),
"npm test runs the public soft-404 test"
)
ok(
pkg.scripts.ci.includes("test-public-soft-404.mjs"),
"npm run ci runs the public soft-404 test"
)

console.log(`\n${checks} checks, ${failures} failures`)
process.exit(failures === 0 ? 0 : 1)