Commit: 1c31cae
Parent: a0c8550

Add initial UI: curved arc nav and mic toggle button

Mårten Åsberg committed on 2026-05-23 at 18:14
- Layout with fixed bottom nav; arc shape via ::before pseudo-element
  (140vw wide, border-radius: 70vw 70vw 0 0 / 5vw 5vw 0 0)
- Home page with 5rem round mic button, pulse animation when listening
- SPA mode (ssr = false), viewport-fit=cover for notch support
- Fix deno task check to use npm:@sveltejs/kit and npm:svelte-check
- Remove scaffold demo routes
- Add relative-units rule to CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLAUDE.md +3 -2
diff --git a/CLAUDE.md b/CLAUDE.md
index 23f2967..586fbe3 100644
@@ -30,7 +30,8 @@ deno run -A npm:playwright test src/routes/path/to/page.svelte.e2e.ts
- **TypeScript everywhere, full types.** No `any`, no type assertions unless truly unavoidable.
- **Nested CSS inside `<style>` blocks.** Use element and hierarchy selectors (`nav a`, `section > h2`) over classes wherever the HTML structure makes the selector unambiguous. Classes only when hierarchy isn't enough.
- **Semantic HTML.** Use `<main>`, `<nav>`, `<section>`, `<article>`, `<button>`, `<dialog>`, etc. correctly.
- **No premature component extraction.** Keep markup inline until a piece is genuinely reused in two or more places. A component for one caller is noise.
- **Extract components when reusable.** If a piece of UI is used in two or more places, make it a component. If it's only used once, keep it inline.
- **Prefer relative units.** Use `rem` for component sizing, `vw`/`vh`/`dvh` for layout and viewport-relative values. Reserve `px` for borders and shadows only.
- **Simplest possible implementation.** No abstractions for hypothetical futures. Three similar lines beat a premature helper.
- **Svelte 5 runes only.** `$state()`, `$derived()`, `$effect()`, `$props()`. The Options API is disabled globally in `svelte.config.js`.
@@ -43,7 +44,7 @@ This is a **SvelteKit** SPA using **Svelte 5** with **Deno** as the runtime and
- **`src/routes/`** — file-based routing. `+page.svelte` is the page, `+layout.svelte` wraps child routes.
- **`src/lib/`** — shared code only. Accessed via the `$lib` alias. Subdivide as needed: `$lib/audio/`, `$lib/db/`, `$lib/model/`.
- **Audio pipeline** — Web Audio API → BirdNET (TensorFlow.js TFJS model) → species result. Runs entirely client-side.
- **Persistence** — IndexedDB for the bird collection and user state. No backend.
- **Persistence** — `localStorage` for the bird collection and user state. Sighting data is small (species, timestamp, coordinates) so localStorage is simpler and sufficient. Migrate to IndexedDB only if binary storage (audio blobs, images) is needed.
- **Offline** — PWA / service worker (not yet implemented).
### Config files
deno.json +1 -1
diff --git a/deno.json b/deno.json
index 1cd361f..eb190d3 100644
@@ -9,7 +9,7 @@
"dev": "deno run -A npm:vite dev",
"build": "deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview",
"check": "deno run -A npm:svelte-kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json",
"check": "deno run -A npm:@sveltejs/kit sync && deno run -A npm:svelte-check --tsconfig ./tsconfig.json",
"lint": "deno run -A npm:eslint .",
"test:e2e": "deno run -A npm:playwright install && deno run -A npm:playwright test",
"test": "deno task test:e2e"
src/app.html +5 -2
diff --git a/src/app.html b/src/app.html
index aa3be3b..00b3155 100644
@@ -2,8 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="theme-color" content="#162419" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
src/routes/+layout.svelte +99 -2
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index ccc155c..640d140 100644
@@ -1,6 +1,5 @@
<script lang="ts">
import favicon from "$lib/assets/favicon.svg";
let { children } = $props();
</script>
@@ -8,4 +7,102 @@
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
<main>
{@render children()}
</main>
<nav>
<ul>
<li>
<a href="/" aria-label="Home">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M21 8.5c-2.1-.7-4.5.1-6.1 1.8L12 12 9.1 10.3C7.5 8.6 5.1 7.8 3 8.5c.9 1.9 2.7 3.4 4.8 3.8l-2.3.8C6.8 15 9.2 15.4 11 14l1-.5 1 .5c1.8 1.4 4.2 1 5.5-.9l-2.3-.8C18.3 11.9 20.1 10.4 21 8.5z"
/>
</svg>
</a>
</li>
</ul>
</nav>
<style>
:global(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(html) {
--bg: #0e1a10;
--nav-bg: #162419;
--accent: #5cb85c;
--text: #e8f0e9;
--nav-height: 5rem;
}
:global(body) {
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
overscroll-behavior: none;
-webkit-tap-highlight-color: transparent;
}
main {
min-height: 100dvh;
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom));
}
nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(var(--nav-height) + env(safe-area-inset-bottom));
overflow: hidden;
&::before {
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 140vw;
height: 100%;
border-radius: 70vw 70vw 0 0 / 5vw 5vw 0 0;
background: var(--nav-bg);
}
ul {
position: relative;
z-index: 1;
height: var(--nav-height);
display: flex;
align-items: center;
justify-content: center;
list-style: none;
a {
display: flex;
align-items: center;
justify-content: center;
color: var(--text);
text-decoration: none;
padding: 0.75rem;
opacity: 0.6;
transition: opacity 0.2s;
&:hover,
&:focus-visible,
&[aria-current="page"] {
opacity: 1;
}
svg {
width: 1.75rem;
height: 1.75rem;
}
}
}
}
</style>
src/routes/+layout.ts +1 -0
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts
new file mode 100644
index 0000000..a3d1578
@@ -0,0 +1 @@
export const ssr = false;
src/routes/+page.svelte +84 -5
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 3d70440..32b9fe2 100644
@@ -1,5 +1,84 @@
<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 lang="ts">
let listening = $state(false);
</script>
<section>
<button
class:listening
onclick={() => (listening = !listening)}
aria-label={listening ? "Stop listening" : "Start listening"}
aria-pressed={listening}
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"
/>
</svg>
</button>
</section>
<style>
section {
height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: calc(
var(--nav-height) + env(safe-area-inset-bottom) + 2rem
);
button {
position: relative;
width: 5rem;
height: 5rem;
border-radius: 50%;
border: none;
background: color-mix(in srgb, var(--accent) 20%, var(--bg));
color: var(--text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.1s;
svg {
width: 2rem;
height: 2rem;
}
&:hover {
background: color-mix(in srgb, var(--accent) 30%, var(--bg));
}
&:active {
transform: scale(0.95);
}
&.listening {
background: var(--accent);
&::after {
content: "";
position: absolute;
inset: -0.5rem;
border-radius: 50%;
border: 2px solid var(--accent);
pointer-events: none;
animation: pulse 1.5s ease-out infinite;
}
}
}
}
@keyframes pulse {
from {
transform: scale(1);
opacity: 0.7;
}
to {
transform: scale(1.6);
opacity: 0;
}
}
</style>
src/routes/demo/+page.svelte +0 -5
diff --git a/src/routes/demo/+page.svelte b/src/routes/demo/+page.svelte
deleted file mode 100644
index af49d66..0000000
@@ -1,5 +0,0 @@
<script lang="ts">
import { resolve } from "$app/paths";
</script>
<a href={resolve("/demo/playwright")}>playwright</a>
src/routes/demo/playwright/+page.svelte +0 -1
diff --git a/src/routes/demo/playwright/+page.svelte b/src/routes/demo/playwright/+page.svelte
deleted file mode 100644
index 5f0f2c9..0000000
@@ -1 +0,0 @@
<h1>Playwright e2e test demo</h1>
src/routes/demo/playwright/page.svelte.e2e.ts +0 -6
diff --git a/src/routes/demo/playwright/page.svelte.e2e.ts b/src/routes/demo/playwright/page.svelte.e2e.ts
deleted file mode 100644
index d450498..0000000
@@ -1,6 +0,0 @@
import { expect, test } from "@playwright/test";
test("has expected h1", async ({ page }) => {
await page.goto("/demo/playwright");
await expect(page.locator("h1")).toBeVisible();
});