From 776ee11962bc8278008af0047048778c49354e3e Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 16 Oct 2026 02:44:57 +0000
Subject: [PATCH] feat(ui): add a first-visit welcome and a crawlable what-is
page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A first visit landed on a screen of controls for a machine that is not connected
yet, with nothing saying what the app is or what it needs. The static intro in
index.html says it, but main.tsx removes it the moment the app mounts.
Two pieces, because the app has no URL of its own to give a crawler:
- A welcome sheet that opens on its own once, for a browser with no stored walks
and no record of having seen it. It is short on purpose: what it does, what it
needs, and the safety rule. A "What is Belt Control?" button in the footer
reopens it from any screen.
- /what-is-belt-control, a written page like the other four in public/, which the
sheet's "Read more" links to. It carries the long version, is in the sitemap,
is excluded from the SPA rewrite and given the revalidate header, and is linked
from the static intro and the footer of every written page — the five edits
docs/deploying.md lists for a new page.
The seen flag is its own storage key, so Recovery's dump and clear pick it up.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01XyxvATqpdrFdrGbkCMayk6
---
apps/web/index.html | 2 +
.../public/compatible-treadmills/index.html | 1 +
apps/web/public/sitemap.xml | 6 +
apps/web/public/troubleshooting/index.html | 1 +
.../walkingpad-bluetooth-protocol/index.html | 1 +
.../walkingpad-without-the-app/index.html | 1 +
.../public/what-is-belt-control/index.html | 131 ++++++++++++++++++
apps/web/src/app.tsx | 3 +
apps/web/src/components/Disclaimer.tsx | 5 +
apps/web/src/components/WelcomeSheet.tsx | 112 +++++++++++++++
apps/web/src/lib/analytics.ts | 4 +
apps/web/src/lib/storage.ts | 1 +
apps/web/src/styles/app.css | 5 +
docs/deploying.md | 7 +-
vercel.json | 4 +-
15 files changed, 279 insertions(+), 5 deletions(-)
create mode 100644 apps/web/public/what-is-belt-control/index.html
create mode 100644 apps/web/src/components/WelcomeSheet.tsx
diff --git a/apps/web/index.html b/apps/web/index.html
index 44ae8c5..5081721 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -128,6 +128,8 @@
diff --git a/apps/web/src/components/WelcomeSheet.tsx b/apps/web/src/components/WelcomeSheet.tsx
new file mode 100644
index 0000000..88ab46f
--- /dev/null
+++ b/apps/web/src/components/WelcomeSheet.tsx
@@ -0,0 +1,112 @@
+import { signal } from '@preact/signals';
+import { useEffect } from 'preact/hooks';
+import { Sheet } from './Sheet.js';
+import { sessions } from '../state/session.js';
+import { STORAGE_KEYS, readRaw, writeRaw } from '../lib/storage.js';
+import { trackEvent } from '../lib/analytics.js';
+
+/** The written page this sheet summarises. A real path rather than a hash route, so
+ * the long version is something a crawler can read too — see docs/deploying.md. */
+export const ABOUT_PATH = '/what-is-belt-control';
+
+// Shown on its own once, on a first visit. A browser that already holds walks belongs
+// to somebody who knows what this is, which covers everyone who used the app before
+// the sheet existed. Unreadable storage reads as "not seen", the same safe direction
+// DesktopOnlyNotice takes: the cost is seeing a short explanation twice.
+const firstVisit = readRaw(STORAGE_KEYS.welcome) !== '1' && sessions.value.length === 0;
+
+/** Open the sheet. Exported so the footer can bring it back on any screen. */
+export const welcomeOpen = signal(firstVisit);
+
+// The app mounts this in either layout, and crossing the desktop breakpoint swaps one
+// for the other — so "first" is reported once per load, not once per mount.
+let firstReported = false;
+
+// Private mode may refuse the write, and then it simply comes back next load.
+const markSeen = () => writeRaw(STORAGE_KEYS.welcome, '1');
+
+function close() {
+ welcomeOpen.value = false;
+ markSeen();
+}
+
+/**
+ * What Belt Control is, for someone who has just landed on a page full of controls for
+ * a machine that is not connected yet.
+ *
+ * Deliberately short: three things it does, the one thing it needs, and the one safety
+ * rule. Everything longer lives on the written page it links to, which is also the
+ * copy search engines index — the hash-routed app has no URL of its own to give them.
+ *
+ * Rendered once at the top of the app rather than by the footer that reopens it, so a
+ * first visit that lands on the legal page (which has no footer) still gets it.
+ */
+export function WelcomeSheet() {
+ // From a mount effect rather than at module load: ES imports run before main.tsx
+ // installs the analytics provider, so an event fired at import goes nowhere.
+ useEffect(() => {
+ if (!firstVisit || firstReported) return;
+ firstReported = true;
+ trackEvent('welcome_shown', { first: true });
+ }, []);
+
+ if (!welcomeOpen.value) return null;
+
+ return (
+
+
+ A remote control for your walking pad, in a browser tab. It connects to a KingSmith
+ or WalkingPad treadmill over Bluetooth — no phone app, no account, nothing to
+ install.
+
+
+
+
+ Control the belt. Start, stop and set the speed from the computer
+ you're already working at.
+
+
+ See it as you walk. Time, distance, steps and calories, as far as
+ your treadmill reports them.
+
+
+ Keep a record. Every walk is saved automatically, with daily
+ goals and a 30-day history. It stays in this browser and is never uploaded.
+
+
+
+
+ You'll need Chrome, Edge or Opera on a computer, or Chrome on Android, and the
+ phone app closed — the treadmill talks to one device at a time. Only start the belt
+ when you can see it, and keep the treadmill's own stop within reach.
+
+
+
+
+ {/* Leaving for the long version counts as having seen it, so coming back
+ from that page does not open the short one on top of the app again. */}
+
+ Read more
+
+
+
+ );
+}
+
+/** The footer's way back to the sheet. A button, not an anchor: it opens a dialog
+ * rather than a page, the same reasoning as `FeedbackLink` beside it. */
+export function WelcomeLink() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/lib/analytics.ts b/apps/web/src/lib/analytics.ts
index 6c8c551..91a5f01 100644
--- a/apps/web/src/lib/analytics.ts
+++ b/apps/web/src/lib/analytics.ts
@@ -35,6 +35,10 @@ export interface AnalyticsEvents {
* ordinary page view; this covers movement the URL bar alone cannot show. */
route_viewed: { route: string };
ambient_entered: undefined;
+ /** The welcome sheet came up. `first` separates the automatic first-visit showing
+ * from someone reopening it from the footer — the second is the signal that it is
+ * worth keeping a way back to. */
+ welcome_shown: { first: boolean };
// --- connection funnel: attempted → connected, or cancelled/failed ---------
connect_attempted: { filtered: boolean };
diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts
index 5b7bbc2..23bfa7a 100644
--- a/apps/web/src/lib/storage.ts
+++ b/apps/web/src/lib/storage.ts
@@ -29,6 +29,7 @@ export const STORAGE_KEYS = {
openSession: 'wp.session.open.v1',
settings: 'wp.settings.v1',
desktopNotice: 'wp.desktopNotice.dismissed.v1',
+ welcome: 'wp.welcome.seen.v1',
} as const;
export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 506b2fc..b1390cb 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -878,6 +878,11 @@ a { color: var(--accent); }
}
.disclaimer-links .table-toggle { font-size: inherit; }
+/* The welcome sheet: the one dialog that is prose rather than a form. */
+.welcome-lede { margin: 0 0 0.75rem; }
+.welcome-list { margin: 0 0 0.75rem; padding-left: 1.1rem; }
+.welcome-list li { margin-bottom: 0.4rem; }
+
/* --- legal page ------------------------------------------------------------
Long-form prose, which exists nowhere else in this app — every other screen is
numbers and controls. Measure is capped well under the desktop content width:
diff --git a/docs/deploying.md b/docs/deploying.md
index 5de90c9..0b37772 100644
--- a/docs/deploying.md
+++ b/docs/deploying.md
@@ -112,18 +112,19 @@ either.
## The written pages
-Four ordinary HTML files live in `apps/web/public/`, copied to `apps/web/dist/` verbatim and served as
+Five ordinary HTML files live in `apps/web/public/`, copied to `apps/web/dist/` verbatim and served as
themselves:
| Path | What it answers |
|---|---|
+| `/what-is-belt-control` | What this is, what it is for, and what you need — the page the in-app welcome links to |
| `/compatible-treadmills` | Will it work with my pad, and which numbers will it show |
| `/walkingpad-without-the-app` | What you give up by not installing KS+Fit, and what you don't |
| `/troubleshooting` | Why the chooser is empty, why a start is refused, why the link drops |
| `/walkingpad-bluetooth-protocol` | The four BLE protocols, frame by frame — the one page written for developers |
-They exist because a hash-routed app is one URL, and one URL cannot answer four different
-questions to four different people arriving from four different searches. Every answer on
+They exist because a hash-routed app is one URL, and one URL cannot answer five different
+questions to five different people arriving from five different searches. Every answer on
them was already written down — in the README, in `docs/protocols.md`, in the code
comments — just not anywhere a crawler could reach it.
diff --git a/vercel.json b/vercel.json
index d85cc03..3119f87 100644
--- a/vercel.json
+++ b/vercel.json
@@ -5,7 +5,7 @@
"trailingSlash": false,
"rewrites": [
{
- "source": "/((?!assets/|compatible-treadmills|troubleshooting|walkingpad-|.*\\.).*)",
+ "source": "/((?!assets/|compatible-treadmills|troubleshooting|walkingpad-|what-is-belt-control|.*\\.).*)",
"destination": "/index.html"
}
],
@@ -23,7 +23,7 @@
"headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }]
},
{
- "source": "/(compatible-treadmills|troubleshooting|walkingpad-bluetooth-protocol|walkingpad-without-the-app)",
+ "source": "/(compatible-treadmills|troubleshooting|walkingpad-bluetooth-protocol|walkingpad-without-the-app|what-is-belt-control)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }]
},
{