Quellcode durchsuchen

Create the landing page and the registration page

Lee Morgan vor 4 Tagen
Ursprung
Commit
ba59c7206a

+ 2 - 0
.env.example

@@ -0,0 +1,2 @@
+MONGODB_URI=mongodb://127.0.0.1:27017
+MONGODB_DB=torus

+ 2 - 0
.gitignore

@@ -24,3 +24,5 @@ vite.config.ts.timestamp-*
 
 *swp
 *swo
+
+.tmp

+ 5 - 0
AGENTS.md

@@ -100,3 +100,8 @@ CSS should be included in the svelte file that it applies to except in cases of
 - Direct, short, gym-floor language.
 - No emoji. No motivational filler.
 - Labels like "Log set", "Rest", "Finish", "PR" — not "Crush it" or similar.
+
+## Other
+
+- Never run the server, user will start it manually
+- Always ensure that UI works well on desktop, mobile, and everything in between

+ 153 - 0
package-lock.json

@@ -7,6 +7,9 @@
 		"": {
 			"name": "torus",
 			"version": "0.0.1",
+			"dependencies": {
+				"mongodb": "^7.6.0"
+			},
 			"devDependencies": {
 				"@sveltejs/adapter-auto": "^7.0.1",
 				"@sveltejs/kit": "^2.63.0",
@@ -65,6 +68,15 @@
 				"@jridgewell/sourcemap-codec": "^1.4.14"
 			}
 		},
+		"node_modules/@mongodb-js/saslprep": {
+			"version": "1.5.4",
+			"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.4.tgz",
+			"integrity": "sha512-05UC0jQsjKAOuXQ0H9Ud9vUTJpZIg+n/FinpR30tI5I8pY2inTfPOZ5OF/cg3Ce/N9MoD1xhRCeOsJtuTbFYlw==",
+			"license": "MIT",
+			"dependencies": {
+				"sparse-bitfield": "^3.0.3"
+			}
+		},
 		"node_modules/@oxc-project/types": {
 			"version": "0.149.0",
 			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
@@ -475,6 +487,21 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/@types/webidl-conversions": {
+			"version": "7.0.3",
+			"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+			"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+			"license": "MIT"
+		},
+		"node_modules/@types/whatwg-url": {
+			"version": "13.0.0",
+			"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
+			"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
+			"license": "MIT",
+			"dependencies": {
+				"@types/webidl-conversions": "*"
+			}
+		},
 		"node_modules/acorn": {
 			"version": "8.18.0",
 			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -508,6 +535,15 @@
 				"node": ">= 0.4"
 			}
 		},
+		"node_modules/bson": {
+			"version": "7.3.2",
+			"resolved": "https://registry.npmjs.org/bson/-/bson-7.3.2.tgz",
+			"integrity": "sha512-1w0ra+ho1cuE+w8jzwgzTFIimFtCfZeCoOsvIPQg6uyFCsp8M29U7bNNf5GrFh88TXbYg1g3TyKUGpd6O7q6zA==",
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
 		"node_modules/clsx": {
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -923,6 +959,71 @@
 				"@jridgewell/sourcemap-codec": "^1.5.5"
 			}
 		},
+		"node_modules/memory-pager": {
+			"version": "1.5.0",
+			"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+			"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+			"license": "MIT"
+		},
+		"node_modules/mongodb": {
+			"version": "7.6.0",
+			"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.6.0.tgz",
+			"integrity": "sha512-WbZ6OCjYw2c53LOjfkQa+reXr7kIiOVpXXglnASFuiMtif0BvsMwHe3ClJHLm1/r7wJFwaasfFtD6iYIktB01g==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@mongodb-js/saslprep": "^1.4.11",
+				"bson": "^7.2.0",
+				"mongodb-connection-string-url": "^7.0.1"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			},
+			"peerDependencies": {
+				"@aws-sdk/credential-providers": "^3.806.0",
+				"@mongodb-js/zstd": "^7.0.0",
+				"gcp-metadata": "^7.0.1",
+				"kerberos": "^7.0.0",
+				"mongodb-client-encryption": "^7.2.0",
+				"snappy": "^7.3.2",
+				"socks": "^2.8.6"
+			},
+			"peerDependenciesMeta": {
+				"@aws-sdk/credential-providers": {
+					"optional": true
+				},
+				"@mongodb-js/zstd": {
+					"optional": true
+				},
+				"gcp-metadata": {
+					"optional": true
+				},
+				"kerberos": {
+					"optional": true
+				},
+				"mongodb-client-encryption": {
+					"optional": true
+				},
+				"snappy": {
+					"optional": true
+				},
+				"socks": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/mongodb-connection-string-url": {
+			"version": "7.0.2",
+			"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz",
+			"integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==",
+			"license": "Apache-2.0",
+			"dependencies": {
+				"@types/whatwg-url": "^13.0.0",
+				"whatwg-url": "^14.1.0"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
 		"node_modules/mrmime": {
 			"version": "2.0.1",
 			"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -1015,6 +1116,15 @@
 				"node": "^10 || ^12 || >=14"
 			}
 		},
+		"node_modules/punycode": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+			"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=6"
+			}
+		},
 		"node_modules/rolldown": {
 			"version": "1.2.8",
 			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
@@ -1081,6 +1191,15 @@
 				"node": ">=0.10.0"
 			}
 		},
+		"node_modules/sparse-bitfield": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+			"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+			"license": "MIT",
+			"dependencies": {
+				"memory-pager": "^1.0.2"
+			}
+		},
 		"node_modules/svelte": {
 			"version": "5.57.0",
 			"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz",
@@ -1135,6 +1254,18 @@
 				"node": ">=6"
 			}
 		},
+		"node_modules/tr46": {
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+			"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+			"license": "MIT",
+			"dependencies": {
+				"punycode": "^2.3.1"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/vite": {
 			"version": "8.3.0",
 			"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
@@ -1233,6 +1364,28 @@
 				}
 			}
 		},
+		"node_modules/webidl-conversions": {
+			"version": "7.0.0",
+			"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+			"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+			"license": "BSD-2-Clause",
+			"engines": {
+				"node": ">=12"
+			}
+		},
+		"node_modules/whatwg-url": {
+			"version": "14.2.0",
+			"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+			"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+			"license": "MIT",
+			"dependencies": {
+				"tr46": "^5.1.0",
+				"webidl-conversions": "^7.0.0"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/zimmerframe": {
 			"version": "1.1.5",
 			"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",

+ 3 - 0
package.json

@@ -15,5 +15,8 @@
 		"@sveltejs/vite-plugin-svelte": "^7.1.2",
 		"svelte": "^5.56.1",
 		"vite": "^8.0.16"
+	},
+	"dependencies": {
+		"mongodb": "^7.6.0"
 	}
 }

+ 7 - 0
src/hooks.server.js

@@ -0,0 +1,7 @@
+import { getDb } from '$lib/server/db.js';
+
+/** @type {import('@sveltejs/kit').Handle} */
+export async function handle({ event, resolve }) {
+	event.locals.db = await getDb();
+	return resolve(event);
+}

+ 61 - 0
src/lib/server/db.js

@@ -0,0 +1,61 @@
+import { MongoClient } from 'mongodb';
+import { env } from '$env/dynamic/private';
+
+const uri = env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017';
+const dbName = env.MONGODB_DB ?? 'torus';
+
+/** @type {import('mongodb').MongoClient | undefined} */
+let client;
+
+/** @type {Promise<import('mongodb').MongoClient> | undefined} */
+let connecting;
+
+/** @type {import('mongodb').Db | undefined} */
+let db;
+
+/**
+ * Shared MongoDB client for the SvelteKit server.
+ * Reuses one connection across hot reloads in development.
+ * @returns {Promise<import('mongodb').MongoClient>}
+ */
+async function getClient() {
+	if (client) return client;
+
+	const globalKey = '__torusMongoClientPromise';
+	if (globalThis[globalKey]) {
+		client = await globalThis[globalKey];
+		return client;
+	}
+
+	if (!connecting) {
+		const next = new MongoClient(uri);
+		connecting = next.connect().then((connected) => {
+			client = connected;
+			return connected;
+		});
+		globalThis[globalKey] = connecting;
+	}
+
+	client = await connecting;
+	return client;
+}
+
+/**
+ * Database handle for the `torus` database (or MONGODB_DB).
+ * @returns {Promise<import('mongodb').Db>}
+ */
+export async function getDb() {
+	if (db) return db;
+	const connected = await getClient();
+	db = connected.db(dbName);
+	return db;
+}
+
+/**
+ * Ping MongoDB to confirm the connection is alive.
+ * @returns {Promise<{ ok: number }>}
+ */
+export async function pingDb() {
+	const database = await getDb();
+	return database.command({ ping: 1 });
+}

+ 29 - 0
src/routes/+layout.svelte

@@ -1,11 +1,40 @@
 <script>
 	import favicon from '$lib/assets/favicon.svg';
+	import '$lib/torus.css';
 
 	let { children } = $props();
 </script>
 
 <svelte:head>
 	<link rel="icon" href={favicon} />
+	<meta name="description" content="Torus — log sets, rest, and PRs. Built for the gym floor." />
 </svelte:head>
 
 {@render children()}
+
+<style>
+	:global(*),
+	:global(*::before),
+	:global(*::after) {
+		box-sizing: border-box;
+	}
+
+	:global(html),
+	:global(body) {
+		margin: 0;
+		min-height: 100%;
+		background: var(--bg);
+		color: var(--text);
+		font-family: var(--font);
+		-webkit-font-smoothing: antialiased;
+	}
+
+	:global(a) {
+		color: inherit;
+		text-decoration: none;
+	}
+
+	:global(button) {
+		font: inherit;
+	}
+</style>

+ 569 - 2
src/routes/+page.svelte

@@ -1,2 +1,569 @@
-<h1>Welcome to SvelteKit</h1>
-<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
+<script>
+	const features = [
+		{
+			title: 'Log sets fast',
+			body: 'Load, reps, done. Numbers stay large and readable under gym lights.'
+		},
+		{
+			title: 'Rest between sets',
+			body: 'Timer sits on the current set so you know when to go again.'
+		},
+		{
+			title: 'Catch PRs',
+			body: 'Personal records get marked when you hit them. No digging later.'
+		},
+		{
+			title: 'Any modality',
+			body: 'Strength first. Conditioning, mobility, and sport work fit the same flow.'
+		}
+	];
+
+	const sets = [
+		{ n: 1, load: '135', reps: '8', status: 'done' },
+		{ n: 2, load: '155', reps: '6', status: 'done' },
+		{ n: 3, load: '175', reps: '5', status: 'active' },
+		{ n: 4, load: '175', reps: '5', status: 'next' }
+	];
+</script>
+
+<svelte:head>
+	<title>Torus — train with intent</title>
+</svelte:head>
+
+<div class="page">
+	<header class="top">
+		<a class="brand" href="/">
+			<span class="brand-mark" aria-hidden="true"></span>
+			<span class="brand-name">Torus</span>
+		</a>
+		<nav class="nav" aria-label="Primary">
+			<a class="nav-link" href="#features">Features</a>
+			<a class="nav-link" href="#preview">Preview</a>
+			<a class="btn btn-primary nav-cta" href="/register">Create account</a>
+		</nav>
+	</header>
+
+	<main>
+		<section class="hero">
+			<div class="hero-copy">
+				<p class="eyebrow"><span class="pill">Weightlifting first</span></p>
+				<h1>Train hard.<br />Log clean.</h1>
+				<p class="lede">
+					Torus is a gym-floor app for sets, rest, and PRs. Built for lifting — works for
+					conditioning and mobility too.
+				</p>
+				<div class="hero-actions">
+					<a class="btn btn-ghost btn-lg" href="#features">See features</a>
+				</div>
+			</div>
+
+			<div class="hero-preview" id="preview" aria-label="Workout preview">
+				<div class="session">
+					<div class="session-head">
+						<div>
+							<p class="session-label">Today</p>
+							<h2 class="session-title">Push A</h2>
+						</div>
+						<span class="pill">Live</span>
+					</div>
+
+					<div class="exercise">
+						<div class="exercise-head">
+							<div>
+								<p class="exercise-name">Bench press</p>
+								<p class="exercise-meta">Barbell · working sets</p>
+							</div>
+							<span class="pill pill-ok">PR zone</span>
+						</div>
+
+						<ul class="sets" role="list">
+							{#each sets as set}
+								<li
+									class="set"
+									class:set-done={set.status === 'done'}
+									class:set-active={set.status === 'active'}
+								>
+									<span class="set-n">Set {set.n}</span>
+									<span class="set-nums">
+										<span class="num">{set.load}</span>
+										<span class="unit">lb</span>
+										<span class="times" aria-hidden="true">×</span>
+										<span class="num">{set.reps}</span>
+										<span class="unit">reps</span>
+									</span>
+									{#if set.status === 'done'}
+										<span class="set-status ok">Logged</span>
+									{:else if set.status === 'active'}
+										<span class="set-status active">Current</span>
+									{:else}
+										<span class="set-status">Next</span>
+									{/if}
+								</li>
+							{/each}
+						</ul>
+
+						<div class="session-actions">
+							<button type="button" class="btn btn-primary btn-wide" tabindex="-1"
+								>Log set</button
+							>
+							<button type="button" class="btn btn-ghost" tabindex="-1">Rest 2:00</button>
+						</div>
+					</div>
+
+					<div class="rest-bar" aria-hidden="true">
+						<div class="rest-fill"></div>
+						<span class="rest-label">Rest · 1:24</span>
+					</div>
+				</div>
+			</div>
+		</section>
+
+		<section class="features" id="features">
+			<div class="features-head">
+				<h2>Built for the floor</h2>
+				<p>Short labels. Big numbers. No noise between you and the next set.</p>
+			</div>
+			<ul class="feature-grid" role="list">
+				{#each features as feature}
+					<li class="feature-card">
+						<h3>{feature.title}</h3>
+						<p>{feature.body}</p>
+					</li>
+				{/each}
+			</ul>
+		</section>
+
+		<section class="cta-band">
+			<div class="cta-inner">
+				<h2>Ready when you are</h2>
+				<p>Open Torus, pick the session, log the work.</p>
+				<a class="btn btn-primary btn-lg" href="/register">Create account</a>
+			</div>
+		</section>
+	</main>
+
+	<footer class="foot">
+		<span class="brand-name foot-brand">Torus</span>
+		<span class="foot-note">Strength first. Any modality.</span>
+	</footer>
+</div>
+
+<style>
+	.page {
+		min-height: 100vh;
+		display: flex;
+		flex-direction: column;
+	}
+
+	.top {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: 16px;
+		padding: 16px 20px;
+		border-bottom: 1px solid var(--border);
+		position: sticky;
+		top: 0;
+		z-index: 10;
+		background: color-mix(in srgb, var(--void) 92%, transparent);
+		backdrop-filter: blur(10px);
+	}
+
+	.brand {
+		display: inline-flex;
+		align-items: center;
+		gap: 10px;
+		font-weight: 700;
+		letter-spacing: -0.02em;
+	}
+
+	.brand-mark {
+		width: 18px;
+		height: 18px;
+		border-radius: 50%;
+		background: var(--ember);
+		box-shadow: 0 0 0 3px var(--ember-soft);
+	}
+
+	.brand-name {
+		font-size: 15px;
+	}
+
+	.nav {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+	}
+
+	.nav-link {
+		display: none;
+		padding: 8px 12px;
+		color: var(--text-muted);
+		font-size: 14px;
+		font-weight: 600;
+		border-radius: 8px;
+	}
+
+	.nav-link:hover {
+		color: var(--text);
+		background: var(--steel);
+	}
+
+	.nav-cta {
+		padding: 9px 14px;
+	}
+
+	main {
+		flex: 1;
+		width: min(1120px, 100%);
+		margin: 0 auto;
+		padding: 0 20px 64px;
+	}
+
+	.hero {
+		display: grid;
+		gap: 40px;
+		padding: 48px 0 24px;
+		align-items: center;
+	}
+
+	.eyebrow {
+		margin: 0 0 16px;
+	}
+
+	h1 {
+		margin: 0;
+		font-size: clamp(2.4rem, 7vw, 3.6rem);
+		line-height: 1.05;
+		letter-spacing: -0.04em;
+		font-weight: 750;
+	}
+
+	.lede {
+		margin: 18px 0 0;
+		max-width: 34rem;
+		color: var(--text-muted);
+		font-size: 1.05rem;
+		line-height: 1.55;
+	}
+
+	.hero-actions {
+		display: flex;
+		flex-wrap: wrap;
+		gap: 10px;
+		margin-top: 28px;
+	}
+
+	.btn-lg {
+		padding: 12px 18px;
+		font-size: 15px;
+		border-radius: 11px;
+	}
+
+	.btn-wide {
+		flex: 1;
+	}
+
+	.hero-preview {
+		scroll-margin-top: 88px;
+	}
+
+	.session {
+		background: var(--surface);
+		border: 1px solid var(--border);
+		border-radius: var(--radius-lg);
+		padding: 18px;
+		box-shadow: 0 24px 60px color-mix(in srgb, black 45%, transparent);
+	}
+
+	.session-head {
+		display: flex;
+		justify-content: space-between;
+		align-items: flex-start;
+		gap: 12px;
+		margin-bottom: 16px;
+	}
+
+	.session-label {
+		margin: 0;
+		color: var(--text-muted);
+		font-size: 12px;
+		font-weight: 650;
+		text-transform: uppercase;
+		letter-spacing: 0.06em;
+	}
+
+	.session-title {
+		margin: 4px 0 0;
+		font-size: 1.35rem;
+		letter-spacing: -0.02em;
+	}
+
+	.exercise {
+		background: var(--surface-2);
+		border: 1px solid var(--border);
+		border-radius: var(--radius);
+		padding: 14px;
+	}
+
+	.exercise-head {
+		display: flex;
+		justify-content: space-between;
+		align-items: flex-start;
+		gap: 12px;
+		margin-bottom: 14px;
+	}
+
+	.exercise-name {
+		margin: 0;
+		font-size: 1.05rem;
+		font-weight: 700;
+	}
+
+	.exercise-meta {
+		margin: 4px 0 0;
+		color: var(--text-muted);
+		font-size: 13px;
+	}
+
+	.sets {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: grid;
+		gap: 8px;
+	}
+
+	.set {
+		display: grid;
+		grid-template-columns: 52px 1fr auto;
+		align-items: center;
+		gap: 10px;
+		padding: 10px 12px;
+		border-radius: var(--radius-sm);
+		background: var(--surface-3);
+		border: 1px solid transparent;
+	}
+
+	.set-done {
+		opacity: 0.72;
+	}
+
+	.set-active {
+		border-color: color-mix(in srgb, var(--ember) 55%, var(--border));
+		background: color-mix(in srgb, var(--ember) 10%, var(--surface-3));
+		box-shadow: inset 3px 0 0 var(--ember);
+	}
+
+	.set-n {
+		color: var(--text-muted);
+		font-size: 12px;
+		font-weight: 650;
+	}
+
+	.set-nums {
+		display: flex;
+		align-items: baseline;
+		gap: 4px;
+		font-variant-numeric: tabular-nums;
+	}
+
+	.num {
+		font-size: 1.15rem;
+		font-weight: 750;
+		letter-spacing: -0.02em;
+	}
+
+	.unit,
+	.times {
+		color: var(--text-muted);
+		font-size: 12px;
+		font-weight: 600;
+	}
+
+	.times {
+		margin: 0 2px;
+	}
+
+	.set-status {
+		font-size: 11px;
+		font-weight: 700;
+		color: var(--text-muted);
+		text-transform: uppercase;
+		letter-spacing: 0.04em;
+	}
+
+	.set-status.ok {
+		color: var(--ok);
+	}
+
+	.set-status.active {
+		color: var(--heat);
+	}
+
+	.session-actions {
+		display: flex;
+		gap: 8px;
+		margin-top: 14px;
+	}
+
+	.rest-bar {
+		position: relative;
+		margin-top: 14px;
+		height: 36px;
+		border-radius: 999px;
+		background: var(--plate);
+		overflow: hidden;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+	}
+
+	.rest-fill {
+		position: absolute;
+		inset: 0 auto 0 0;
+		width: 62%;
+		background: linear-gradient(
+			90deg,
+			color-mix(in srgb, var(--ember) 35%, transparent),
+			var(--ember)
+		);
+	}
+
+	.rest-label {
+		position: relative;
+		z-index: 1;
+		font-size: 13px;
+		font-weight: 700;
+		color: var(--ember-ink);
+		background: color-mix(in srgb, var(--bone) 88%, transparent);
+		padding: 4px 10px;
+		border-radius: 999px;
+		font-variant-numeric: tabular-nums;
+	}
+
+	.features {
+		padding: 56px 0 24px;
+		scroll-margin-top: 72px;
+	}
+
+	.features-head {
+		max-width: 36rem;
+		margin-bottom: 24px;
+	}
+
+	.features-head h2,
+	.cta-band h2 {
+		margin: 0;
+		font-size: clamp(1.6rem, 4vw, 2rem);
+		letter-spacing: -0.03em;
+	}
+
+	.features-head p,
+	.cta-band p {
+		margin: 10px 0 0;
+		color: var(--text-muted);
+		line-height: 1.5;
+	}
+
+	.feature-grid {
+		list-style: none;
+		margin: 0;
+		padding: 0;
+		display: grid;
+		gap: 12px;
+		grid-template-columns: 1fr;
+	}
+
+	.feature-card {
+		background: var(--surface);
+		border: 1px solid var(--border);
+		border-radius: var(--radius);
+		padding: 18px;
+	}
+
+	.feature-card h3 {
+		margin: 0;
+		font-size: 1rem;
+		letter-spacing: -0.01em;
+	}
+
+	.feature-card p {
+		margin: 8px 0 0;
+		color: var(--text-muted);
+		font-size: 0.95rem;
+		line-height: 1.5;
+	}
+
+	.cta-band {
+		margin-top: 40px;
+		padding: 28px;
+		border-radius: var(--radius-lg);
+		background:
+			radial-gradient(circle at top right, var(--ember-soft), transparent 45%),
+			var(--surface);
+		border: 1px solid var(--border);
+	}
+
+	.cta-inner {
+		display: flex;
+		flex-direction: column;
+		align-items: flex-start;
+		gap: 8px;
+		max-width: 28rem;
+	}
+
+	.cta-inner .btn {
+		margin-top: 10px;
+	}
+
+	.foot {
+		display: flex;
+		flex-wrap: wrap;
+		justify-content: space-between;
+		gap: 8px;
+		padding: 20px;
+		border-top: 1px solid var(--border);
+		color: var(--text-muted);
+		font-size: 13px;
+	}
+
+	.foot-brand {
+		color: var(--text);
+		font-weight: 700;
+	}
+
+	@media (min-width: 720px) {
+		.top {
+			padding: 16px 28px;
+		}
+
+		.nav-link {
+			display: inline-block;
+		}
+
+		main {
+			padding: 0 28px 80px;
+		}
+
+		.hero {
+			grid-template-columns: 1.05fr 0.95fr;
+			gap: 48px;
+			padding: 72px 0 32px;
+		}
+
+		.feature-grid {
+			grid-template-columns: repeat(2, 1fr);
+		}
+
+		.cta-band {
+			padding: 36px;
+		}
+	}
+
+	@media (min-width: 980px) {
+		.feature-grid {
+			grid-template-columns: repeat(4, 1fr);
+		}
+	}
+</style>

+ 23 - 0
src/routes/api/health/+server.js

@@ -0,0 +1,23 @@
+import { json } from '@sveltejs/kit';
+import { pingDb } from '$lib/server/db.js';
+
+/** @type {import('./$types').RequestHandler} */
+export async function GET({ locals }) {
+	try {
+		const ping = await pingDb();
+		return json({
+			ok: true,
+			mongo: ping?.ok === 1,
+			db: locals.db?.databaseName ?? null
+		});
+	} catch (error) {
+		return json(
+			{
+				ok: false,
+				mongo: false,
+				error: error instanceof Error ? error.message : 'MongoDB unreachable'
+			},
+			{ status: 503 }
+		);
+	}
+}

+ 243 - 0
src/routes/register/+page.svelte

@@ -0,0 +1,243 @@
+<script>
+	let name = $state('');
+	let email = $state('');
+	let password = $state('');
+	let confirmPassword = $state('');
+
+	function handleSubmit(event) {
+		event.preventDefault();
+	}
+</script>
+
+<svelte:head>
+	<title>Register — Torus</title>
+</svelte:head>
+
+<div class="page">
+	<header class="top">
+		<a class="brand" href="/">
+			<span class="brand-mark" aria-hidden="true"></span>
+			<span class="brand-name">Torus</span>
+		</a>
+		<a class="btn btn-ghost" href="/login">Log in</a>
+	</header>
+
+	<main>
+		<section class="card" aria-labelledby="register-title">
+			<div class="card-head">
+				<h1 id="register-title">Create account</h1>
+				<p class="lede">Name, email, password. Then get to work.</p>
+			</div>
+
+			<form class="form" method="POST" onsubmit={handleSubmit}>
+				<label class="field">
+					<span class="label">Name</span>
+					<input
+						class="input"
+						type="text"
+						name="name"
+						autocomplete="name"
+						required
+						autofocus
+						bind:value={name}
+					/>
+				</label>
+
+				<label class="field">
+					<span class="label">Email</span>
+					<input
+						class="input"
+						type="email"
+						name="email"
+						autocomplete="email"
+						required
+						bind:value={email}
+					/>
+				</label>
+
+				<label class="field">
+					<span class="label">Password</span>
+					<input
+						class="input"
+						type="password"
+						name="password"
+						autocomplete="new-password"
+						required
+						bind:value={password}
+					/>
+				</label>
+
+				<label class="field">
+					<span class="label">Confirm password</span>
+					<input
+						class="input"
+						type="password"
+						name="confirmPassword"
+						autocomplete="new-password"
+						required
+						bind:value={confirmPassword}
+					/>
+				</label>
+
+				<button class="btn btn-primary btn-submit" type="submit">Create account</button>
+			</form>
+
+			<p class="switch">
+				Already have an account?
+				<a class="switch-link" href="/login">Log in</a>
+			</p>
+		</section>
+	</main>
+</div>
+
+<style>
+	.page {
+		min-height: 100vh;
+		display: flex;
+		flex-direction: column;
+	}
+
+	.top {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: 16px;
+		padding: 16px 20px;
+		border-bottom: 1px solid var(--border);
+	}
+
+	.brand {
+		display: inline-flex;
+		align-items: center;
+		gap: 10px;
+		font-weight: 700;
+		letter-spacing: -0.02em;
+	}
+
+	.brand-mark {
+		width: 18px;
+		height: 18px;
+		border-radius: 50%;
+		background: var(--ember);
+		box-shadow: 0 0 0 3px var(--ember-soft);
+	}
+
+	.brand-name {
+		font-size: 15px;
+	}
+
+	main {
+		flex: 1;
+		display: flex;
+		align-items: flex-start;
+		justify-content: center;
+		padding: 40px 20px 64px;
+	}
+
+	.card {
+		width: min(420px, 100%);
+		background: var(--surface);
+		border: 1px solid var(--border);
+		border-radius: var(--radius-lg);
+		padding: 24px;
+	}
+
+	.card-head {
+		margin-bottom: 24px;
+	}
+
+	h1 {
+		margin: 0;
+		font-size: 1.6rem;
+		letter-spacing: -0.03em;
+		font-weight: 750;
+	}
+
+	.lede {
+		margin: 8px 0 0;
+		color: var(--text-muted);
+		font-size: 0.95rem;
+		line-height: 1.45;
+	}
+
+	.form {
+		display: grid;
+		gap: 14px;
+	}
+
+	.field {
+		display: grid;
+		gap: 6px;
+	}
+
+	.label {
+		font-size: 13px;
+		font-weight: 650;
+		color: var(--text-muted);
+	}
+
+	.input {
+		width: 100%;
+		padding: 11px 12px;
+		border: 1px solid var(--border);
+		border-radius: var(--radius-sm);
+		background: var(--surface-3);
+		color: var(--text);
+		font: inherit;
+		font-size: 15px;
+	}
+
+	.input::placeholder {
+		color: color-mix(in srgb, var(--text-muted) 80%, transparent);
+	}
+
+	.input:hover {
+		border-color: color-mix(in srgb, var(--border) 70%, var(--ash));
+	}
+
+	.input:focus {
+		outline: 2px solid var(--focus);
+		outline-offset: 2px;
+		border-color: color-mix(in srgb, var(--ember) 45%, var(--border));
+	}
+
+	.btn-submit {
+		width: 100%;
+		margin-top: 6px;
+		padding: 12px 16px;
+		font-size: 15px;
+	}
+
+	.switch {
+		margin: 20px 0 0;
+		text-align: center;
+		color: var(--text-muted);
+		font-size: 14px;
+	}
+
+	.switch-link {
+		color: var(--heat);
+		font-weight: 650;
+		margin-left: 4px;
+	}
+
+	.switch-link:hover {
+		color: var(--ember);
+		text-decoration: underline;
+	}
+
+	@media (min-width: 720px) {
+		.top {
+			padding: 16px 28px;
+		}
+
+		main {
+			align-items: center;
+			padding: 48px 28px 80px;
+		}
+
+		.card {
+			padding: 28px;
+		}
+	}
+</style>