This commit is contained in:
ponzischeme89
2026-04-18 07:23:55 +12:00
parent f210020772
commit 6d44e05de4
396 changed files with 75296 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+314
View File
@@ -0,0 +1,314 @@
# Goodwalk Mobile Architecture
> Developer reference for the dedicated mobile experience layer introduced in April 2026.
---
## Overview
Goodwalk's mobile experience is a **first-class application layer** built within the existing SvelteKit project. It is not a responsive version of the desktop — mobile users are automatically routed into dedicated mobile routes that use purpose-built layouts, components, and navigation patterns.
```
Desktop users → /admin/* /members/*
Mobile users → /admin/m/* /members/m/*
```
Both the Admin and Member mobile experiences share the same component library and design token system while keeping their routes, permissions, and business logic completely separate.
---
## Folder Structure
```
frontend/src/
├── lib/
│ └── mobile/
│ ├── utils/
│ │ └── device.js UA detection: isMobileUA(), isMobileDevice()
│ ├── stores/
│ │ └── mobile.js isMobile Svelte store + initMobileStore()
│ ├── guards/
│ │ └── routeGuard.js guardMobileRoute() — client-side desktop redirect
│ └── components/
│ ├── MobileAppShell.svelte
│ ├── MobilePageShell.svelte
│ ├── MobileHeader.svelte
│ ├── MobileBottomNav.svelte
│ ├── MobileSection.svelte
│ ├── MobileCard.svelte
│ ├── MobileListItem.svelte
│ ├── MobileStatCard.svelte
│ ├── MobileActionBar.svelte
│ ├── MobileSearchBar.svelte
│ ├── MobileFormField.svelte
│ ├── MobileEmptyState.svelte
│ ├── MobileFilterSheet.svelte
│ └── MobileTabs.svelte
└── routes/
├── +layout.server.js Exposes isMobile to client via page data
├── admin/
│ └── m/ Admin mobile routes
│ ├── +layout.js ssr: false (matches desktop admin)
│ ├── +layout.svelte Auth + theme + nav shell
│ ├── dashboard/+page.svelte
│ ├── bookings/+page.svelte
│ ├── customers/+page.svelte
│ ├── reporting/+page.svelte
│ └── settings/+page.svelte
└── members/
└── m/ Members mobile routes
├── +layout.server.js Cookie auth guard (mirrors desktop)
├── +layout.svelte Auth sync + polling + nav shell
├── home/+page.svelte
├── bookings/+page.svelte
├── walks/+page.svelte
├── messages/+page.svelte
└── profile/+page.svelte
```
---
## How Mobile Routing Works
### 1. Server-side detection (hooks.server.js)
On every GET request, `hooks.server.js` tests the `User-Agent` header against a mobile phone pattern:
```js
const MOBILE_UA_RE = /iPhone|Android.*Mobile|IEMobile|BlackBerry|Opera Mini/i;
```
**Tablets** do NOT match this pattern (Android tablets omit "Mobile" in the UA; iPad is excluded). Tablets receive the desktop experience.
If a phone-class UA is detected and the user is on a desktop route (`/admin/*` or `/members/*`), a `307` redirect is issued to the corresponding mobile entry point.
### 2. Redirect targets
The redirect target is calculated relative to the browser's visible URL, accounting for subdomain rewriting:
| Environment | User visits | Redirected to |
|---|---|---|
| Local dev | `/admin` | `/admin/m/dashboard` |
| Local dev | `/members/dashboard` | `/members/m/home` |
| Admin subdomain | `admin.goodwalk.co.nz/` | `admin.goodwalk.co.nz/m/dashboard` (→ internally `/admin/m/dashboard`) |
| Members subdomain | `members.goodwalk.co.nz/bookings` | `members.goodwalk.co.nz/m/home` |
### 3. Redirect exclusions
These paths are **never redirected** to mobile routes, even on a phone:
- `*/login`, `*/verify` — auth flows work on all platforms
- `*/claim`, `*/onboarding` — onboarding is currently desktop-only
- Paths already under `/m/` — prevents redirect loops
- Requests with `?d=1` — explicit desktop override (useful for QA, power users)
### 4. Client-side desktop guard (guardMobileRoute)
Mobile layout files call `guardMobileRoute()` in `onMount`. If a desktop browser somehow lands on a `/m/` route (e.g. shared link, direct URL entry), it is sent back to the desktop equivalent.
```js
// In any /admin/m/ or /members/m/ +layout.svelte:
import { guardMobileRoute } from '$lib/mobile/guards/routeGuard.js';
onMount(() => {
guardMobileRoute($page.url.pathname, $page.url.search);
});
```
The guard respects `?d=1` — if that param is present, the user stays on the mobile route regardless of device (for QA testing mobile on desktop).
### 5. Preventing redirect loops
Loop prevention is layered:
1. `hooks.server.js` never redirects paths that already contain `/m/`.
2. Mobile layouts never redirect mobile users.
3. `?d=1` stops both server and client redirects.
---
## Auth in Mobile Routes
### Admin mobile (`/admin/m/`)
- **SSR disabled** — `+layout.js` sets `ssr: false`, identical to the desktop admin.
- Auth is handled entirely **client-side** in `+layout.svelte` via `getAdminAccessToken()`.
- If no token is found, a login form is rendered in place of the page content.
- After login, `saveAdminTokens()` is called and the layout re-renders the shell.
- The admin theme preference is loaded from `localStorage` on mount.
### Members mobile (`/members/m/`)
- **SSR enabled** — `+layout.server.js` runs on the server and checks the `gw_member_session` cookie.
- If the cookie is missing, a server-side `307 redirect` to `/members/login?returnTo=<path>` is issued.
- After login (on the desktop login page), the session cookie is set and the member is returned to the mobile route via the `returnTo` param.
- Client-side auth state is further validated in `+layout.svelte` via `isLoggedIn()`.
---
## How to Use Shared Mobile Components
All components live in `src/lib/mobile/components/` and use the `--m-*` CSS custom property token layer provided by `MobileAppShell`.
### Basic page structure
```svelte
<!-- A mobile page file (+page.svelte inside /admin/m/ or /members/m/) -->
<script>
import MobilePageShell from '$lib/mobile/components/MobilePageShell.svelte';
import MobileSection from '$lib/mobile/components/MobileSection.svelte';
import MobileCard from '$lib/mobile/components/MobileCard.svelte';
</script>
<MobilePageShell title="My Page" showBack>
<svelte:fragment slot="header-action">
<!-- Optional icon button in the top-right -->
</svelte:fragment>
<MobileSection title="Section Heading">
<MobileCard>
Content here
</MobileCard>
</MobileSection>
</MobilePageShell>
```
### Key component contracts
**MobileAppShell** — set once per layout (`+layout.svelte`), never in individual pages.
- Props: `variant` (`'admin'|'members'`), `navItems`, `currentPath`
**MobilePageShell** — set once per page, wraps all page content.
- Props: `title`, `showBack`, `backHref`, `transparent`
- Slot: `header-action` for right-side icon buttons
**MobileBottomNav** — rendered automatically by `MobileAppShell`. Never add it manually to a page.
**MobileCard** — use `href` prop to make it a tappable link. Avoid `on:click` on cards when a simple link will do.
**MobileListItem** — always provide `divider={false}` on the last item in a list.
**MobileEmptyState** — always include all three variant cases in data-fetching pages:
```svelte
{#if loading}
<MobileEmptyState variant="loading" />
{:else if error}
<MobileEmptyState variant="error" icon="⚠️" title="Error" body={error} />
{:else if items.length === 0}
<MobileEmptyState icon="🐾" title="Nothing here yet" body="..." />
{:else}
<!-- list content -->
{/if}
```
**MobileFilterSheet** — use `bind:open` to control visibility. The sheet handles its own `Escape` key and overlay click dismissal.
---
## How Admin and Member Mobile Experiences Differ
| Concern | Admin mobile | Members mobile |
|---|---|---|
| Route prefix | `/admin/m/` | `/members/m/` |
| SSR | Disabled (`ssr: false`) | Enabled |
| Auth | Client-side localStorage JWT | Server cookie + localStorage |
| Theme | Admin theme switcher (5 variants) | Fixed green brand palette |
| Nav items | Dashboard, Clients, Add Client, Reporting, Settings | Home, Book, Walks, Messages, Account |
| Feature flags | `bookings_enabled`, `walks_enabled`, etc. | Same set, independently fetched |
| Business logic | Manages all clients/bookings/walks | Views own data only |
| Login screen | Rendered inline in the layout | Separate `/members/login` page |
---
## Extending the Mobile Shell
### Adding a new admin mobile page
1. Create `src/routes/admin/m/<section>/+page.svelte`.
2. Use `MobilePageShell` as the root component in the template.
3. If the page needs a back button, set `showBack` and optionally `backHref`.
4. Add an entry to `BASE_NAV` in `src/routes/admin/m/+layout.svelte` if it should appear in the bottom nav. Otherwise link to it from an existing page.
### Adding a new members mobile page
1. Create `src/routes/members/m/<section>/+page.svelte`.
2. Auth is handled by `+layout.server.js` — no per-page auth check needed.
3. Use `MobilePageShell` and shared components.
4. Add a nav entry in `ACTIVE_NAV` in `src/routes/members/m/+layout.svelte` if it needs a tab.
### Adding a new shared component
1. Create `src/lib/mobile/components/Mobile<Name>.svelte`.
2. Use only `--m-*` CSS custom properties — never hardcode colours.
3. Ensure all interactive elements have `min-height: var(--m-touch-min, 44px)`.
4. Annotate props with JSDoc comments at the top of the `<script>` block.
5. Export from the component directly — no barrel index needed.
---
## Avoiding Desktop/Mobile Concerns Mixing
**Never do this:**
```svelte
<!-- ❌ Don't add mobile breakpoints to desktop components -->
<style>
@media (max-width: 768px) {
.my-desktop-component { display: none; }
}
</style>
```
```svelte
<!-- ❌ Don't import desktop-specific values into mobile components -->
<script>
import { adminThemePresets } from '$lib/adminApi'; // belongs in admin layout, not mobile component
</script>
```
**Do this instead:**
- Keep mobile-specific logic in `src/lib/mobile/` and `src/routes/*/m/`.
- Pass data down via props from the mobile layout to mobile components.
- If a concern is shared (e.g. feature flags, API fetching), import from the existing shared lib (`$lib/featureSettings`, `$lib/memberApi`, etc.) — these are fine to use in mobile pages.
- The mobile components themselves (`MobileCard`, `MobileSection`, etc.) should contain zero business logic.
---
## Migration Notes
The following changes were made to existing files:
### `src/hooks.server.js`
- Added `MOBILE_UA_RE` constant and `isMobileUA()` function.
- Added `maybeMobileRedirect()` which returns a `307 Response` for mobile users on desktop routes.
- The redirect fires **after** subdomain rewriting so the canonical pathname is used for matching.
- Added `event.locals.isMobile` for downstream use.
- The subdomain rewriter now skips paths starting with `/m/` (prevents double-prefixing mobile routes on subdomains).
### `src/routes/+layout.server.js`
- Added `isMobile: locals.isMobile ?? false` to the returned data object.
### `src/routes/+layout.svelte`
- Added a comment clarifying that `/admin/m/*` and `/members/m/*` paths satisfy the existing `isAdmin`/`isMembers` checks and correctly bypass the marketing site chrome.
---
## Follow-up Recommendations
1. **Members login → mobile redirect.** After a member logs in via `/members/login`, `memberApi.js` currently does `goto('/members/dashboard')`. On mobile, this client-side navigation skips `hooks.server.js` and lands the user on the desktop dashboard. Fix: detect mobile in the post-login goto (use `isMobileDevice()` from `$lib/mobile/utils/device.js`) and redirect to `/members/m/home` instead.
2. **Onboarding mobile experience.** Onboarding (`/members/onboarding`) is currently excluded from mobile redirects. Consider building a dedicated `/members/m/onboarding` flow using `MobileFormField` and `MobilePageShell` with back navigation.
3. **Admin mobile — individual client detail page.** The customers list links to `/admin/members/:id` (desktop). Build `/admin/m/customers/:id/+page.svelte` for a mobile-optimised client profile.
4. **Offline / PWA support.** The mobile shell is PWA-ready (safe area support, overscroll contain, touch optimisations). Add a `manifest.json` and service worker to make it installable.
5. **Swipe-back gesture.** iOS-style swipe-back for stacked navigation can be layered on top using Svelte's `fly` transition with a touch gesture handler on the `MobilePageShell` container.
6. **Dark mode.** The `--m-*` token layer makes adding a dark theme straightforward. Add `prefers-color-scheme: dark` overrides in `MobileAppShell`'s `<style>` block.
7. **Pull-to-refresh.** The `m-shell__main` scroll region is a natural candidate for a pull-to-refresh implementation.
+43
View File
@@ -0,0 +1,43 @@
# Svelte + Vite
This template should help get you started developing with Svelte in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
+314
View File
@@ -0,0 +1,314 @@
# Goodwalk — Design Language & Theme Reference
> Source of truth for visual language, spacing, tone, interaction style, and component feel across the Goodwalk frontend.
---
## Brand Palette
| Token | Value | Usage |
|---|---|---|
| `--gw-accent-color-1` | `#FFD100` | Highlight / CTA yellow |
| `--gw-accent-color-2` | `#213021` | Deep forest green (primary brand) |
| `--gw-accent-color-3` | `#59606D` | Muted slate |
| `--gw-accent-color-4` | `#E5D6C2` | Warm sand |
| `--gw-accent-color-5` | `#FFFFFF` | White |
| `--gw-accent-color-6` | `#000000` | Black |
Background texture: warm cream gradient (`#f4f1ea``#f1ede5`).
Typography: **DM Sans** (body), system heading scale.
---
## Desktop Design Language
**Tone:** Premium, intentional, warm. Not corporate. Feels like a trusted local service with high craft.
**Spacing:** 8-point grid. Components breathe. Generous white-space is never wasted.
**Surfaces:** Semi-transparent white (`rgba(255,255,255,0.94)`) on warm cream background. Cards carry a subtle multi-layer shadow.
**Borders:** Warm tan `#e7ddd1`, never cold grey.
**Shadows:** Multi-stop warm shadows — `0 4px 8px rgba(17,16,14,0.04), 0 16px 34px rgba(17,16,14,0.08)`.
**Navigation:** Sticky header with glassmorphism on scroll. Bottom tab bar on mobile (see below).
**Radius scale:** `8px` (inputs), `12px` (cards), `16px` (modals/sheets), `28px` (pills/nav).
**Interactions:** Subtle hover lifts (`translateY(-1px)`), scale-down press (`scale(0.960.98)`), smooth 150ms ease transitions.
---
## Admin Design System
Admin uses a theming layer on top of the base brand. The active theme is stored in `localStorage` as `gw_admin_theme`.
| Theme | Accent | Highlight |
|---|---|---|
| main | `#213021` | `#ffd100` |
| pink | `#c54b8c` | `#ffcadf` |
| purple | `#6f55d9` | `#d8cbff` |
| yellow | `#9a7700` | `#ffe08a` |
Admin CSS variables (set via inline `style` on the shell element):
```
--admin-accent, --admin-accent-dark, --admin-accent-rgb
--admin-topnav-start, --admin-topnav-end
--admin-highlight, --admin-highlight-rgb
--admin-button-text
--admin-font-scale
```
The admin header uses a gradient from `--admin-topnav-start``--admin-topnav-end`.
---
## Members Design System
Members uses the core brand palette directly (no theme switching).
```css
--members-accent: #213021
--members-accent-dark: #172217
--members-highlight: #ffd100
--members-border: #e7ddd1
--members-surface: rgba(255, 255, 255, 0.94)
--members-surface-soft: #f8f4ed
--members-surface-muted: #f2ece3
--members-text: #1d261d
--members-text-muted: #6b635c
```
---
## Mobile Design Language
> Mobile users receive a **dedicated, purpose-built experience** — not a squeezed desktop page. All mobile screens live under `/admin/m/` (admin) or `/members/m/` (members).
### Philosophy
- **Native-app inspired.** Feel fast, responsive, and deliberate. Every tap should feel satisfying.
- **One-handed first.** Primary actions sit at thumb-reach (bottom half of screen). Navigation is bottom-anchored.
- **Dense but not cramped.** Maximum information per screen without feeling overwhelming.
- **No desktop patterns forced onto phones.** Never squeeze a data table onto a 390px screen. Use lists, cards, and sheets instead.
### Mobile vs Desktop Rules
| Rule | Desktop | Mobile |
|---|---|---|
| Navigation | Top sticky header + subnav tabs | Fixed bottom tab bar |
| Primary actions | Anywhere sensible | Bottom-anchored action bar or prominent nav item |
| Modals | Standard centred modal | Slide-up bottom sheet |
| Tables / data grids | Full table layout | Vertical list with disclosure rows |
| Forms | Multi-column layouts OK | Single column, full-width fields |
| Page titles | In header nav | Sticky per-page header (MobilePageShell) |
| Content width | Max-width capped at 1320px | Full bleed (16px horizontal padding) |
### Mobile Token Layer
All mobile screens inherit tokens from the `MobileAppShell` component. These tokens provide a consistent design system shared between Admin and Member mobile experiences:
```css
/* Palette */
--m-accent: #213021 /* primary action colour */
--m-accent-dark: #172217 /* hover/active state */
--m-accent-rgb: 33, 48, 33 /* for rgba() usage */
--m-highlight: #ffd100
--m-bg: #f4f1ea
--m-surface: rgba(255,255,255,0.96)
--m-surface-soft: #f8f4ed
--m-surface-muted: #f2ece3
--m-border: #e7ddd1
--m-border-soft: rgba(231,221,209,0.6)
--m-text: #1d261d
--m-text-muted: #6b635c
--m-text-faint: #a09890
/* Shadows */
--m-shadow-card: 0 2px 8px rgba(17,16,14,0.06), 0 8px 20px rgba(17,16,14,0.08)
--m-shadow-sheet: 0 -8px 40px rgba(17,16,14,0.18)
--m-shadow-button: 0 4px 16px rgba(33,48,33,0.28)
/* Radii */
--m-radius-xs: 6px
--m-radius-sm: 10px
--m-radius: 16px /* default card/button radius */
--m-radius-lg: 24px
--m-radius-xl: 32px /* bottom sheet corners */
/* Spacing scale (4-point base) */
--m-space-1: 4px
--m-space-2: 8px
--m-space-3: 12px
--m-space-4: 16px
--m-space-5: 20px
--m-space-6: 24px
--m-space-8: 32px
--m-space-10: 40px
/* Type scale */
--m-text-xs: 11px
--m-text-sm: 13px
--m-text-md: 15px /* body text */
--m-text-lg: 17px /* page titles */
--m-text-xl: 20px
--m-text-2xl: 24px
--m-text-3xl: 30px /* stat card values */
/* Touch */
--m-touch-min: 44px /* minimum touch target (iOS HIG / WCAG) */
/* Layout */
--m-bottom-nav-h: 72px
--m-header-h: 56px
--m-page-px: 16px /* horizontal page padding */
/* Safe areas (iOS notch/home bar) */
--m-safe-top: env(safe-area-inset-top, 0px)
--m-safe-bottom: env(safe-area-inset-bottom, 0px)
```
For the **Admin mobile** variant, `--m-accent`, `--m-accent-dark`, `--m-accent-rgb`, and `--m-highlight` are overridden by the admin's active theme variables (`--admin-accent` etc.), so the admin theme switcher works on mobile too.
### Touch Target Minimums
- Every interactive element must be at least **44 × 44px** (iOS HIG) / **48 × 48dp** (Material).
- Use `min-height: var(--m-touch-min)` on buttons and list rows.
- Pad tappable areas generously — don't rely on text size alone for target size.
### Bottom Navigation Principles
The bottom tab bar is the **primary navigation** on mobile. It is always visible and never hides during scroll.
- Max **5 items** per bar. Fewer is better.
- Icons must be recognisable without labels — labels are shown but should not be the only differentiator.
- **One prominent item** allowed per bar (filled background, accent colour). Use for the most important CTA (e.g. Add Client, Complete Onboarding).
- Badge dots use red `#e03e3e` for unread counts (messages).
- The bar uses glassmorphism: `rgba(251,247,240,0.92)` background, `backdrop-filter: blur(18px)`, `border-radius: 28px`. Never make it a plain opaque bar.
- Accounts for safe area: `padding-bottom: calc(12px + env(safe-area-inset-bottom))`.
- Active state: light green tint `rgba(33,48,33,0.08)` + accent text colour.
- Inactive state: `--m-text-muted` colour.
- Press: `scale(0.94)` transform with `transition: 0.15s`.
### Spacing and Rhythm (Small Screens)
- Page content padding: `16px` horizontal.
- Section vertical spacing: `24px` top (tight variant: `16px`).
- Section labels: `11px`, `800` weight, `0.08em` letter-spacing, uppercase, muted colour.
- Card internal padding: `16px`.
- List row minimum height: `44px`. Default padding: `12px 0`.
- Group gaps: `8px` between sibling cards/list items, `12px` between elements within a card.
- Never use horizontal padding smaller than `8px` inside a card.
### Mobile Card / List Patterns
**Cards:**
- `border-radius: 16px`, warm border `#e7ddd1`, multi-layer warm shadow.
- Default: white surface with shadow.
- Subtle: `#f8f4ed` surface, no shadow (for secondary content blocks).
- Highlight: `3px` left border in accent colour (for time-sensitive or important content).
- Press: `scale(0.98)`.
**List items (MobileListItem):**
- Full-width rows with leading slot (icon/avatar), label, sublabel, trailing area (meta, badge, chevron).
- Divider is a `1px` line at `60%` opacity — not a full-width separator.
- Never nest more than 2 lines of text in a list item on mobile.
- Chevron (``) indicates navigable rows.
### Mobile Form Behaviour
- Single-column layouts only.
- Labels above inputs (`13px`, `700` weight).
- Full-width inputs with `min-height: 44px`, `border-radius: 10px`.
- Focus ring: `3px` at `0.12` opacity using accent colour.
- Error messages below the field in red (`#991b1b`), not in a toast.
- Use `MobileFormField` to wrap every input — it ensures consistent spacing and error handling.
- Avoid more than 4-5 fields visible at once without sectioning.
### Hierarchy of Headers and Page Titles
1. **App header** (`MobileHeader`) — optional sticky brand strip at the very top. Use only when brand context is needed (e.g. on first entry to a section).
2. **Page header** (`MobilePageShell`) — per-page sticky title bar. Always present. Contains: back button (44px touch), centred title (`17px, 700`), right-side action slot.
3. **Section headings** (`MobileSection title=`) — `11px, 800, uppercase, muted`. Acts as a visual grouping label, not a navigational heading.
4. **Card titles / list labels**`15px, 600` for primary labels, `13px` for sublabels.
Do not repeat the page title in the body content. The sticky page header is the single source of truth for where you are.
### Motion and Transitions
- Duration: `150ms` for micro-interactions (hover, press), `260ms` for sheet entry.
- Easing: `cubic-bezier(0.25, 0.46, 0.45, 0.94)` (ease-out feel).
- Sheet entry: `fly` transition upward 320px, 260ms.
- Press scales: `0.940.98` depending on element size (buttons larger → less scale, small icons → more scale).
- No bouncing or spring physics on primary navigation (too distracting).
- Reserve spring easing (`cubic-bezier(0.34, 1.56, 0.64, 1)`) for playful one-off moments only.
- Skeleton loading: sinusoidal opacity pulse `0.45 → 0.85`, `1.4s` period, staggered delays.
### Empty / Loading / Error States
Use `MobileEmptyState` for all three variants:
- **Loading:** animated skeleton cards (3 staggered shimmer bars).
- **Empty:** emoji icon, title, optional body text, optional CTA button.
- **Error:** same as empty but `role="alert"`, red title, warning emoji.
Rules:
- Every list/data section must handle all three states explicitly.
- Never show a spinner in place of a full-page experience — use skeleton screens.
- Empty state CTAs should always provide a path forward (e.g. "Book a Walk").
### Admin and Member Mobile Alignment
Admin and Member mobile experiences are visually indistinguishable in structure — they share:
- The same `MobileAppShell` token layer.
- The same component library.
- The same bottom nav design, touch targets, motion, and type scale.
- The same card/list/section patterns.
They differ in:
- Admin inherits the admin theme (accent colour changes with theme switcher).
- Members always uses the green brand palette.
- Different nav items and routes.
- Different auth patterns (admin: CSR localStorage; members: server cookie + localStorage).
- Different feature sets and business logic.
### What Must Never Happen in Mobile Views
- **No horizontal scrolling** in page content. Horizontal scroll only inside explicit scroll containers (e.g. a chip row).
- **No data tables.** Replace with vertical lists with disclosure rows or summary cards.
- **No fixed-position elements other than the bottom nav and action bar.** Avoid top banners that take up screen space.
- **No touch targets smaller than 44px.** Not even "less important" actions.
- **No modals that cover the full screen** (except full-screen sheets on purpose). Prefer slide-up panels.
- **No desktop responsive hacks in mobile components.** Do not import mobile tokens into desktop components or add `@media (max-width)` patches to the mobile components file.
- **No mixing desktop and mobile layouts in the same file** (e.g., showing/hiding via CSS in a shared component). Use separate route/layout files.
- **No navigation items that do not exist on mobile.** If a feature isn't in the mobile nav, provide a clear path to the desktop version or a dedicated in-app screen.
---
## Component Library Summary
### Shared Desktop Components
Located in `src/components/` and `src/lib/`.
### Shared Mobile Components
Located in `src/lib/mobile/components/`. All use the `--m-*` token layer.
| Component | Purpose |
|---|---|
| `MobileAppShell` | Top-level layout wrapper, token layer, bottom nav integration |
| `MobilePageShell` | Per-page container with sticky title header |
| `MobileHeader` | Optional branded app header strip |
| `MobileBottomNav` | Fixed bottom tab bar |
| `MobileSection` | Content group with optional title and action link |
| `MobileCard` | Touchable surface card |
| `MobileListItem` | Single list row (leading/label/sublabel/trailing) |
| `MobileStatCard` | Metric display card for dashboards |
| `MobileActionBar` | Sticky bottom CTA button area |
| `MobileSearchBar` | Touch-friendly search input with clear button |
| `MobileFormField` | Form field wrapper (label + input slot + error/hint) |
| `MobileEmptyState` | Empty / loading skeleton / error state |
| `MobileFilterSheet` | Slide-up bottom sheet for filters |
| `MobileTabs` | Segmented tab switcher |
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
},
/**
* Use global.d.ts instead of compilerOptions.types
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}
+6342
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "gw-public",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node ./scripts/validate-experiments.mjs && vitest run",
"test:watch": "node ./scripts/validate-experiments.mjs && vitest",
"test:coverage": "node ./scripts/validate-experiments.mjs && vitest run --coverage"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^4.2.3",
"@testing-library/user-event": "^14.6.1",
"@vitest/coverage-v8": "^4.1.2",
"autoprefixer": "^10.4.27",
"jsdom": "^29.0.1",
"postcss": "^8.5.8",
"svelte": "^4.2.20",
"tailwindcss": "^3.4.19",
"vite": "^5.4.21",
"vitest": "^4.1.2"
},
"dependencies": {
"svelte-routing": "^2.13.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="687" viewBox="0 0 468 687" width="468"><rect fill-rule="evenodd" height="686" rx="234" transform="translate(0 .564)" width="468"></rect></svg>

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="100" viewBox="0 0 102 100" width="102"><path d="m98.315807 24.6658575s-3.250354-15.4021776-17.29135-20.8123473c-6.60299-2.6015434-18.6547464-3.7478875-30.4977492-3.8535102-11.8430028.1056851-23.8945497 1.2519668-30.4977494 3.7478875-14.04057721 5.4103778-17.29135101 20.8123473-17.29135101 20.8123473-2.0959212 8.7395209-2.8286553 17.5843523-2.7263743 25.3889826-.1064309 7.8046302.5239803 16.7560208 2.7263743 25.3889825 0 0 3.2503546 15.4021781 17.29135101 20.8123481 6.6029901 2.495817 18.6547466 3.747888 30.4977494 3.849452 11.8430028-.105685 23.8945492-1.247908 30.4977492-3.849452 14.040576-5.410379 17.29135-20.8123481 17.29135-20.8123481 2.095921-8.7395209 2.828655-17.5843523 2.726374-25.3889825.102334-7.6989036-.528077-16.6498779-2.726374-25.2838802z"></path></svg>

After

Width:  |  Height:  |  Size: 825 B

@@ -0,0 +1,43 @@
# Breed Image Attribution
These breed preview images are stored locally so the booking form does not depend on third-party image hosts at runtime.
They were downloaded from dog breed pages on Wikipedia / Wikimedia Commons that expose freely reusable media. Verify the exact attribution and license requirements again before a public production launch if you replace or add files.
## Files and source pages
- `beagle.jpg` -> https://en.wikipedia.org/wiki/Beagle
- `bichon-frise.jpg` -> https://en.wikipedia.org/wiki/Bichon_Fris%C3%A9
- `border-collie.jpg` -> https://en.wikipedia.org/wiki/Border_Collie
- `border-terrier.jpg` -> https://en.wikipedia.org/wiki/Border_Terrier
- `boston-terrier.JPG` -> https://en.wikipedia.org/wiki/Boston_Terrier
- `cairn-terrier.jpg` -> https://en.wikipedia.org/wiki/Cairn_Terrier
- `cavalier-king-charles-spaniel.jpg` -> https://en.wikipedia.org/wiki/Cavalier_King_Charles_Spaniel
- `cockapoo.jpg` -> https://en.wikipedia.org/wiki/Cockapoo
- `cocker-spaniel.jpg` -> https://en.wikipedia.org/wiki/Cocker_Spaniel
- `coton-de-tulear.jpg` -> https://en.wikipedia.org/wiki/Coton_de_Tulear
- `dachshund.jpg` -> https://en.wikipedia.org/wiki/Dachshund
- `french-bulldog.jpg` -> https://en.wikipedia.org/wiki/French_Bulldog
- `havanese.jpg` -> https://en.wikipedia.org/wiki/Havanese_dog
- `italian-greyhound.jpg` -> https://en.wikipedia.org/wiki/Italian_Greyhound
- `jack-russell-terrier.jpg` -> https://en.wikipedia.org/wiki/Jack_Russell_Terrier
- `japanese-spitz.jpg` -> https://en.wikipedia.org/wiki/Japanese_Spitz
- `labradoodle.jpg` -> https://en.wikipedia.org/wiki/Labradoodle
- `lhasa-apso.jpg` -> https://en.wikipedia.org/wiki/Lhasa_Apso
- `maltese.jpg` -> https://en.wikipedia.org/wiki/Maltese_dog
- `miniature-pinscher.jpg` -> https://en.wikipedia.org/wiki/Miniature_Pinscher
- `miniature-poodle.jpg` -> https://en.wikipedia.org/wiki/Poodle
- `miniature-schnauzer.jpg` -> https://en.wikipedia.org/wiki/Miniature_Schnauzer
- `norfolk-terrier.jpg` -> https://en.wikipedia.org/wiki/Norfolk_Terrier
- `norwich-terrier.jpg` -> https://en.wikipedia.org/wiki/Norwich_Terrier
- `papillon.jpeg` -> https://en.wikipedia.org/wiki/Continental_Toy_Spaniel
- `pembroke-welsh-corgi.jpg` -> https://en.wikipedia.org/wiki/Pembroke_Welsh_Corgi
- `pomeranian.JPG` -> https://en.wikipedia.org/wiki/Pomeranian_dog
- `pug.jpg` -> https://en.wikipedia.org/wiki/Pug
- `schnoodle.jpg` -> https://en.wikipedia.org/wiki/List_of_dog_crossbreeds
- `shih-tzu.jpg` -> https://en.wikipedia.org/wiki/Shih_Tzu
- `staffordshire-bull-terrier.jpg` -> https://en.wikipedia.org/wiki/Staffordshire_Bull_Terrier
- `tibetan-terrier.jpg` -> https://en.wikipedia.org/wiki/Tibetan_Terrier
- `welsh-terrier.jpg` -> https://en.wikipedia.org/wiki/Welsh_Terrier
- `west-highland-white-terrier.jpg` -> https://en.wikipedia.org/wiki/West_Highland_White_Terrier
- `whippet.jpg` -> https://en.wikipedia.org/wiki/Whippet
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 907 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="272" viewBox="0 0 230 272" width="230"><g fill="#090b0d" fill-rule="evenodd"><path d="m55.041157 52.8454917c.07422 0 .1484401.00782.2187501.02344 4.3867.2695 6.2851 3.8086 7.332 6.4609.8398 2.1289 2.2734 4.53519 3.57832 5.10169 4.0742 1.7736 6.5195201 6.0587001 5.9414 10.4220001-.5235 3.9726-3.418 6.82811-7.5547 7.4492-.47654.07422-.96482.10938-1.4531.10938-1.3398 0-2.7031-.27344-3.9453-.79688-.5508-.23437-1.05861-.5039-1.5508-.76953-.9063-.48441-1.76177-.94535-2.7188001-1.0391-.9532.09379-1.81255.55082-2.7188 1.0391-.4922.26562-1.00002.53906-1.5508.76953-1.6914.71485-3.6132.96094-5.3984.69141-4.1367-.6172-7.03126-3.4726-7.5547001-7.4492-.5742-4.3674 1.8672001-8.6526001 5.9414001-10.4260001 1.3047-.5664 2.73826-2.9727 3.5781-5.1016 1.0468-2.6562 2.9453-6.19528 7.3359-6.4609.07031-.01562.14062-.02344.21484-.02344.02344 0 .12891.00391.14844.00391zm22.6014702-7.0459001c.32813 0 .66016.03125.98438.08984 1.5469.2774 2.88284 1.1836 3.8594 2.6172 1.37105 2.0078001 1.83199 4.7695001 1.2734 7.5703001-.8985 4.4883-4.125 7.75-7.6719 7.75-.32813 0-.66016-.03125-.98438-.08984-1.5508-.2813-2.8828401-1.1836-3.8594001-2.6172-1.37106-2.0078-1.83199-4.7695-1.2734-7.5703.8985-4.4883001 4.1250001-7.7500001 7.6719001-7.7500001zm-45.7575203-.00016c3.5469 0 6.7734601 3.2578 7.6719001 7.7500001.56249 2.8008.09769 5.5625-1.26951 7.5664-.9766001 1.4336-2.3086001 2.33595-3.8594001 2.6172-.32421.05859-.65625.08984-.98437.08984-3.5469 0-6.77346-3.2578-7.6719-7.75-.5586-2.8008-.0938-5.5586 1.2734-7.5703001.9727-1.4297 2.3047-2.33596 3.8555-2.6133.32422-.05859.65626-.08984.98438-.08984zm12.4416001-16.0078301c3.8789-.3281 7.34372 3.6836001 7.7695 8.8555001.2032 2.4609-.2968 4.8398-1.4023 6.7031-1.1719 1.9727-2.8828 3.14454-4.8164 3.3047-.14453.01172-.29297.0197-.4375.0197-3.7032 0-6.92194-3.8984-7.3321-8.875-.4257001-5.1994 2.3633-9.6915901 6.2188-10.0080001zm13.1055001 8.8548601c.4218-5.1719 3.875-9.1836201 7.7695-8.8555001 3.8594.3166 6.64849 4.8088001 6.2188 10.0080001-.4101 4.9766-3.6328 8.875-7.332 8.875-.14453 0-.28906-.00781-.4375-.01953-1.9337-.15647-3.6446-1.33217-4.8165-3.30487-1.10542-1.8633-1.60542-4.2461-1.4023-6.7031z" transform="matrix(.66913061 -.74314483 .74314483 .66913061 -23.564829 59.258526)"></path><path d="m171.317237-14.5864733c0-1.1884124-.961619-2.1499998-2.15-2.1499998-1.18838 0-2.149999.9616196-2.149999 2.1499998v6.44161448c-.655084-.172172-1.322788-.3233384-2.011433-.4115207v-.5542914c0-7.70140698-5.135597-13.36654898-14.142699-15.71757378v-7.8860918c0-1.1884124-.961619-2.1499999-2.149999-2.1499999-1.188381 0-2.15.9616197-2.15 2.1499999v7.0042694c-5.093672-.7978542-10.225077-.7894585-15.310149.025198v-7.0421094c0-1.1884124-.96162-2.1499999-2.15-2.1499999-1.188412 0-2.15.9616197-2.15 2.1499999v7.9365093c-8.914974 2.3809098-13.987899 8.0246593-13.987899 15.68424878v.5458957c-.743265.096578-1.461354.2603542-2.170962.4535209v-6.49203198c0-1.1884124-.96162-2.1499998-2.15-2.1499998-1.188412 0-2.15.9616196-2.15 2.1499998v8.37747439c-4.543594 2.85541472-7.5754164 7.90716185-7.5754164 13.66002388l2.8591874 287.83223253c0 1.188413.961619 2.15 2.149999 2.15 1.188381 0 2.15-.961619 2.15-2.15l-2.854994-287.82793253c0-5.79908703 4.194972-10.62400916 9.746379-11.63149906v.2897447c0 1.1884124.96162 2.14999981 2.15 2.14999981s2.15-.96161961 2.15-2.14999981v-5.20278453c0-7.68452948 6.655754-10.69958168 12.261449-11.87982408.004203 0 .012599-.0042033.016802-.0042033s.004203 0 .008396-.0041925c2.380909-.5123019 4.770312-.8230414 7.168099-.9448281v4.8837246c0 1.1884124.96162 2.1499998 2.15 2.1499998s2.15-.9616197 2.15-2.1499998v-4.8921096c2.29276.1049737 4.585519.3905259 6.865702.8566459.243552.1007812.508109.1553697.785255.159573 5.547214 1.2093749 12.034624 4.2453896 12.034624 11.82929908v2.33898481c-.025198.1301717-.04199.2645467-.04199.3989217v2.64127472c0 1.18841241.96162 2.14999991 2.15 2.14999991s2.15-.9616197 2.15-2.14999991v-.4787082c5.551407.9742187 9.788412 5.82015703 9.788412 11.64869906l-1.63623 223.58267533c0 1.188413.96162 2.15 2.15 2.15s2.15-.96162 2.15-2.15l1.632026-223.59127533c0-5.81166453-3.094817-10.90587406-7.734947-13.74817388z" stroke="#090b0d" stroke-width="2" transform="matrix(.66913061 -.74314483 .74314483 .66913061 -51.568225 146.719871)"></path></g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="149" viewBox="0 0 159 149" width="159"><path d="m2.56834568 143.176455c-1.30009323 3.447792-5.1834291 6.029331-9.07532333 5.810615-3.67231605-.210234-7.13369865-3.229076-8.21430005-6.886974-.8611097-3.447792.4305548-7.10569 1.7306481-10.334766 5.40300727-13.135022 13.39772913-24.97583 21.18151759-36.8157763 8.21430011-12.268768 16.63953361-24.5366749 26.36451451-35.51984 18.8007365-20.8796382 43.0036158-36.5961987 69.5885735-45.4266462-5.622586-1.0763588-11.6670384 2.3714337-17.2896239.4288644-4.1028276-1.2949888-6.9141204-5.38179391-9.2949015-9.03969161-.4305549-.85772879-1.0806015-1.93408757-.4305549-2.58153891.2194918-.4288644.8611097-.64749439 1.3000933-.85772879 15.9894437-4.73425648 32.848123-4.95297259 49.707667-4.95297259 10.375503 0 22.042109.85772879 28.525718 8.82958639 1.730648 2.37143368 3.022226 5.38179391 1.941711 8.18204891-.430555 1.2949888-1.300093 2.3714337-2.380781 3.2290764-16.209022 18.2980994-32.197601 36.5961987-48.406623 54.8942981-1.080602 1.2949888-2.380782 2.800255-4.102828 3.0103602-3.241804.8577288-6.0530972-1.9340875-7.9947219-4.5241512-1.9417112-2.3714337-4.1028277-5.1630778-4.1028277-8.3921542 0-2.800255 1.9417112-5.3817939 3.672316-7.5345115 7.5642106-9.468513 14.9088426-18.7269207 22.4721876-28.2049056-17.720135 6.4581527-35.2297684 14.2113803-49.9281095 25.6130337-17.5092017 13.5638428-30.9073631 31.8623727-43.2240586 50.3692858-8.2058714 13.361918-16.42017145 26.48919-22.04210863 40.699278zm46.43165432-96.176455c-1.6015344.829261-2.999952 1.8698957-4.2031327 3.3333611-.3984337.4146305-.7968673 1.0406347-.7968673 1.6666389 2.1953649-1.0406347 3.7967393-2.9185641 4.99992-4.9998335zm1.60416-.5606154c1.0625067-.4411531 1.8645867-1.3320953 2.39584-2.4393846-1.8645867.2248946-3.4688 1.3320953-4 2.6555101.5312533.674706 1.3333333.2248947 1.60416-.2162362zm7.9269134-7.4393846c-1.4161016.6936808-2.8229755 1.6125923-3.5310734 3 1.6459981-.4594557 3.0621469-1.6125923 4-3z" fill="#090b0d" fill-rule="evenodd" transform="matrix(1 0 0 -1 0 146)"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137px" height="119px" viewBox="0 0 137 119"><title>shape-1</title><g id="Home" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g id="About-us" transform="translate(-1029.000000, -2967.000000)" fill="#FFD100"><g id="Footer" transform="translate(0.000000, 2948.000000)"><path d="M1020.69056,116.717267 C1016.21851,116.883796 1013.69187,115.421083 1013.11063,112.32913 C1012.52939,109.237177 1014.23857,107.106453 1018.23817,105.93696 C1050.29229,98.6398591 1085.30327,87.3999027 1105.31086,76.539641 C1102.56352,74.8197034 1099.96016,72.6678978 1097.47886,70.0772786 C1089.30166,61.5279774 1085.80645,51.1146536 1087.89208,41.4997756 C1089.63972,33.4685245 1095.04801,27.1069351 1102.7434,24.0411909 C1111.15792,20.6876821 1120.58655,23.8756802 1127.34645,32.3602067 C1134.50956,41.355709 1138.29972,56.6617695 1130.38855,69.8027783 C1133.05672,69.234268 1135.88323,68.3491061 1138.88943,67.1473108 C1154.03569,61.1239646 1165.01142,47.3353931 1165.1182,47.1990641 C1167.87907,44.4094187 1170.47902,43.9305753 1172.91804,45.7625339 C1175.35706,47.5944925 1175.65309,50.3473894 1173.80614,54.0212244 C1173.28832,54.6833043 1160.94693,70.2635335 1142.96717,77.4163837 C1133.99165,80.9858082 1125.90079,82.3026725 1118.60899,81.3312363 C1110.79853,86.6637262 1099.62323,92.1043581 1085.18875,97.6094699 C1066.90686,104.575513 1043.39688,111.534555 1020.6994,116.708424 L1020.69056,116.717267 Z M1109.28901,34.0654835 C1108.42615,34.0654835 1107.535,34.2252315 1106.62255,34.595548 C1102.47088,36.294632 1099.55004,39.8017648 1098.61632,44.2164909 C1097.34326,50.2286972 1099.76916,57.061392 1105.27168,62.9646708 C1108.49669,66.420983 1111.95528,68.7807703 1115.78865,70.0514679 C1117.50022,68.5774699 1118.85108,67.1397934 1119.79874,65.7311705 C1125.79631,56.8000402 1123.2289,45.916021 1118.30642,39.5699684 C1117.18188,38.1177559 1113.63148,34.0514679 1109.28879,34.0514679 L1109.28901,34.0654835 Z" id="shape-1" transform="translate(1094.000000, 69.730033) rotate(-18.000000) translate(-1094.000000, -69.730033) "></path></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

@@ -0,0 +1,9 @@
import { getValidatedFallbackExperimentDefinitions } from '../src/lib/experiments/validation.js';
try {
getValidatedFallbackExperimentDefinitions();
console.log('Experiment definitions validated.');
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+62
View File
@@ -0,0 +1,62 @@
<script>
import { Router, Route } from 'svelte-routing';
import Header from './components/Header.svelte';
import Footer from './components/Footer.svelte';
import Home from './routes/Home-2.svelte';
import PackWalks from './routes/PackWalks.svelte';
import OneOnOneWalks from './routes/OneOnOneWalks.svelte';
import PuppyVisits from './routes/PuppyVisits.svelte';
import Pricing from './routes/Pricing.svelte';
import About from './routes/About.svelte';
import Contact from './routes/Contact.svelte';
import NotFound from './routes/NotFound.svelte';
import RouteBridge from './components/RouteBridge.svelte';
import { navigation, footer, siteSettings } from './lib/content.js';
</script>
<Router url="">
<div id="top"></div>
<div id="page" class="site-shell">
<Header nav={navigation} logo={siteSettings.logo} social={siteSettings.social} />
<div id="main-content">
<div id="main" role="main" class="site-main content-shell">
<Route path="/" component={Home} />
<Route path="/pack-walks" component={PackWalks} />
<Route path="/1-1-walks" component={OneOnOneWalks} />
<Route path="/puppy-visits" component={PuppyVisits} />
<Route path="/our-pricing" component={Pricing} />
<Route path="/about" component={About} />
<Route path="/contact" component={Contact} />
<Route path="/admin" component={RouteBridge} target="/admin" />
<Route path="/admin/*" component={RouteBridge} />
<Route path="/members" component={RouteBridge} target="/members" />
<Route path="/members/*" component={RouteBridge} />
<Route component={NotFound} />
</div>
</div>
<Footer {footer} />
</div>
</Router>
<style>
#page {
display: flex;
flex-direction: column;
min-height: 100vh;
overflow-x: hidden;
}
#main-content {
flex: 1 0 auto;
}
#main {
min-height: 300px;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
/* --- Tailwind v3 --- */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* --- Global Design Tokens --- */
:root {
/* Accent Colours */
--gw-accent-color-1: #FFD100;
--gw-accent-color-1-hc: #000000;
--gw-accent-color-1-rgb: 255,209,0;
--gw-accent-color-2: #213021;
--gw-accent-color-2-hc: #FFFFFF;
--gw-accent-color-2-rgb: 33,48,33;
--gw-accent-color-3: #59606D;
--gw-accent-color-3-hc: #FFFFFF;
--gw-accent-color-3-rgb: 89,96,109;
--gw-accent-color-4: #E5D6C2;
--gw-accent-color-4-hc: #000000;
--gw-accent-color-4-rgb: 229,214,194;
--gw-accent-color-5: #FFFFFF;
--gw-accent-color-5-hc: #000000;
--gw-accent-color-5-rgb: 255,255,255;
--gw-accent-color-6: #000000;
--gw-accent-color-6-hc: #FFFFFF;
--gw-accent-color-6-rgb: 0,0,0;
--gw-accent-color-7: #E6E6E6;
--gw-accent-color-7-hc: #000000;
--gw-accent-color-7-rgb: 230,230,230;
--gw-accent-color-8: #F5F5F5;
--gw-accent-color-8-hc: #000000;
--gw-accent-color-8-rgb: 245,245,245;
/* Layout */
--gw-site-max-width: 1280px;
/* Typography — Primary (body) */
--gw-primary-font-color: #2E3031;
--gw-primary-font-font-family: 'DM Sans', sans-serif;
--gw-primary-font-font-weight: 500;
--gw-primary-font-font-size-desktop: 16px;
--gw-primary-font-font-size-tablet: 15px;
--gw-primary-font-font-size-phone: 16px;
--gw-primary-font-line-height-desktop: 1.6;
--gw-primary-font-line-height-tablet: 1.6;
--gw-primary-font-line-height-phone: 1.5;
/* Heading Colours */
--gw-h1-color: #000000;
--gw-h2-color: #000000;
--gw-h3-color: #000000;
--gw-h4-color: #000000;
--gw-h5-color: #000000;
--gw-h6-color: #000000;
/* H1 */
--gw-h1-font-family: 'Outfit', sans-serif; /* rollback: 'Unbounded', sans-serif */
--gw-h1-font-weight: bold;
--gw-h1-transform: none;
--gw-h1-font-size-desktop: 56px;
--gw-h1-font-size-tablet: 45px;
--gw-h1-font-size-phone: 34px;
--gw-h1-line-height-desktop: 1.2;
/* H2 */
--gw-h2-font-family: 'Unbounded', sans-serif;
--gw-h2-font-weight: bold;
--gw-h2-font-size-desktop: 42px;
--gw-h2-font-size-tablet: 35px;
--gw-h2-font-size-phone: 30px;
--gw-h2-line-height-desktop: 1.2;
/* H3 */
--gw-h3-font-family: 'Unbounded', sans-serif;
--gw-h3-font-weight: bold;
--gw-h3-font-size-desktop: 30px;
--gw-h3-font-size-tablet: 25px;
--gw-h3-font-size-phone: 23px;
--gw-h3-line-height-desktop: 1.2;
/* H4 */
--gw-h4-font-family: 'Unbounded', sans-serif;
--gw-h4-font-weight: bold;
--gw-h4-font-size-desktop: 24px;
--gw-h4-font-size-tablet: 20px;
--gw-h4-font-size-phone: 20px;
--gw-h4-line-height-desktop: 1.2;
/* H5 */
--gw-h5-font-family: 'DM Sans', sans-serif;
--gw-h5-font-weight: 500;
--gw-h5-font-size-desktop: 20px;
--gw-h5-line-height-desktop: 1.3;
/* H6 */
--gw-h6-font-family: 'DM Sans', sans-serif;
--gw-h6-font-weight: 500;
--gw-h6-font-size-desktop: 18px;
--gw-h6-line-height-desktop: 1.5;
/* Buttons */
--gw-btn-text-color: #FFFFFF;
--gw-btn-hover-text-color: #FFFFFF;
--gw-btn-bg-color: #213021;
--gw-btn-hover-bg-color: #000000;
/* Inputs / Forms */
--gw-input-border-radius: 12px;
--gw-form_field_border_border: solid;
--gw-form_field_border_width: 2px;
--gw-form_field_border_radius: 12px;
--gw-form_field_padding: 12px 15px;
--gw-form_field_background_color: #FFFFFF;
--gw-form_field_border_color: #000000;
--gw-form_field_focus_background_color: #E5D6C2;
/* Backgrounds */
--gw-body-background-color: #FBFBFB;
--gw-sticky-header-bg-color: #F6F4F1;
--gw-default-line-color: var(--gw-accent-color-7);
/* Body links */
--gw-body-link-regular: #2E3031;
--gw-body-link-visited: #2E3031;
--gw-body-link-hover: #000000;
--gw-body-link-active: #000000;
/* Spacing scale */
--gw-content-space-l: min(4vh, 40px);
--gw-content-space-xl: calc(var(--gw-content-space-l) * 1.2);
--gw-content-space-xxl: calc(var(--gw-content-space-l) * 2);
--gw-content-space-m: calc(var(--gw-content-space-l) * 0.4);
--gw-content-space-s: calc(var(--gw-content-space-l) * 0.2);
--gw-content-space-xs: calc(var(--gw-content-space-l) * 0.1);
}
/* --- Base Reset --- */
*, *::before, *::after {
box-sizing: border-box;
}
html {
background: #fff;
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
background-color: var(--gw-body-background-color);
color: var(--gw-primary-font-color);
font-family: var(--gw-primary-font-font-family);
font-size: var(--gw-primary-font-font-size-desktop);
font-weight: var(--gw-primary-font-font-weight);
line-height: var(--gw-primary-font-line-height-desktop);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4 {
font-family: var(--gw-h1-font-family);
font-weight: bold;
text-transform: none;
margin: 0 0 0.5em;
}
h1 { font-size: var(--gw-h1-font-size-desktop); color: var(--gw-h1-color); line-height: var(--gw-h1-line-height-desktop); }
h2 { font-size: var(--gw-h2-font-size-desktop); color: var(--gw-h2-color); line-height: var(--gw-h2-line-height-desktop); }
h3 { font-size: var(--gw-h3-font-size-desktop); color: var(--gw-h3-color); line-height: var(--gw-h3-line-height-desktop); }
h4 { font-size: var(--gw-h4-font-size-desktop); color: var(--gw-h4-color); line-height: var(--gw-h4-line-height-desktop); }
h5 {
font-family: var(--gw-h5-font-family);
font-weight: var(--gw-h5-font-weight);
font-size: var(--gw-h5-font-size-desktop);
color: var(--gw-h5-color);
line-height: var(--gw-h5-line-height-desktop);
margin: 0 0 0.5em;
text-transform: none;
}
h6 {
font-family: var(--gw-h6-font-family);
font-weight: var(--gw-h6-font-weight);
font-size: var(--gw-h6-font-size-desktop);
color: var(--gw-h6-color);
line-height: var(--gw-h6-line-height-desktop);
margin: 0 0 0.5em;
text-transform: none;
}
a {
color: var(--gw-body-link-regular);
text-decoration: none;
}
a:hover {
color: var(--gw-body-link-hover);
}
a:visited {
color: var(--gw-body-link-visited);
}
p {
margin: 0 0 1em;
}
img {
max-width: 100%;
height: auto;
display: block;
}
ul, ol {
padding-left: 1.5em;
}
/* --- Buttons --- */
button,
input[type="submit"],
input[type="button"],
.button {
background: var(--gw-accent-color-2);
color: var(--gw-accent-color-2-hc);
border: none;
border-radius: var(--gw-input-border-radius);
padding: 12px 24px;
font-family: var(--gw-primary-font-font-family);
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: background 0.2s ease;
line-height: 1.4;
}
button:hover,
input[type="submit"]:hover,
input[type="button"]:hover,
.button:hover {
background: var(--gw-btn-hover-bg-color);
color: var(--gw-btn-hover-text-color);
}
/* --- Form Fields --- */
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="password"],
input[type="search"],
textarea,
select {
border: var(--gw-form_field_border_width) var(--gw-form_field_border_border) var(--gw-form_field_border_color);
border-radius: var(--gw-form_field_border_radius);
padding: var(--gw-form_field_padding);
background-color: var(--gw-form_field_background_color);
font-family: var(--gw-primary-font-font-family);
font-size: 14px;
line-height: 1.6;
color: var(--gw-primary-font-color);
width: 100%;
outline: none;
transition: background-color 0.2s ease;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="tel"]:focus,
input[type="password"]:focus,
input[type="search"]:focus,
textarea:focus,
select:focus {
background-color: var(--gw-form_field_focus_background_color);
}
/* --- Layout Wrapper --- */
.limit-wrapper {
max-width: var(--gw-site-max-width);
margin: 0 auto;
padding: 0 30px;
}
/* --- Page container --- */
#page {
position: relative;
}
/* --- Page header / title bar --- */
.page-header {
padding: 20px 0 10px;
}
.page-header h1 {
font-size: 36px;
}
.page-header-line {
width: 60px;
height: 3px;
background: var(--gw-accent-color-1);
margin: 10px 0;
}
/* --- Clearfix --- */
.clearfix::after {
content: '';
display: block;
clear: both;
}
/* --- Accessibility skip link --- */
.visuallyhidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
/* --- Responsive breakpoints --- */
@media only screen and (max-width: 900px) {
body {
font-size: var(--gw-primary-font-font-size-phone);
line-height: var(--gw-primary-font-line-height-phone);
}
h1 { font-size: var(--gw-h1-font-size-phone); }
h2 { font-size: var(--gw-h2-font-size-phone); }
h3 { font-size: var(--gw-h3-font-size-phone); }
h4 { font-size: var(--gw-h4-font-size-phone); }
.limit-wrapper { padding: 0 20px; }
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
@media only screen and (min-width: 901px) and (max-width: 1280px) {
h1 { font-size: var(--gw-h1-font-size-tablet); }
h2 { font-size: var(--gw-h2-font-size-tablet); }
h3 { font-size: var(--gw-h3-font-size-tablet); }
h4 { font-size: var(--gw-h4-font-size-tablet); }
}
+36
View File
@@ -0,0 +1,36 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
interface Locals {
/** Set by hooks.server.js from the experiment context. */
experiments: {
assignments: Record<string, string>;
activeExperiments: string[];
enabled: boolean;
};
analytics: {
sessionId: string;
enabled: boolean;
consentGranted: boolean;
apiBaseUrl: string;
};
seo: {
canonicalUrl: string;
noindex: boolean;
};
qa: {
forced: boolean;
noindex: boolean;
};
/**
* Subdomain section detected by hooks.server.js.
* 'admin' | 'members' in production (subdomain routing).
* null in local dev (path-based routing).
*/
section: 'admin' | 'members' | null;
}
}
}
export {};
+352
View File
@@ -0,0 +1,352 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Nunito:wght@400;600;700;800;900&family=Unbounded:wght@400;700;900&family=Readex+Pro:wght@200;300;400;500;600;700&family=Syne:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap"
/>
<style>
html {
background: #fff;
min-height: 100%;
}
body {
margin: 0;
min-height: 100%;
background: #fbfbfb;
color: #2e3031;
font-family: 'DM Sans', sans-serif;
font-size: 16px;
font-weight: 500;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img {
max-width: 100%;
}
.site-shell {
min-height: 100vh;
overflow-x: hidden;
}
.fixed-header-box {
position: relative;
z-index: 20;
width: 100%;
}
.main-header {
width: 100%;
background: #213021;
}
.fixed-header-box.theme-light .main-header {
background: #fff;
border-bottom: 1px solid #ece6de;
}
.header-shell {
max-width: 1280px;
margin: 0 auto;
padding: 0 30px;
display: grid;
grid-template-columns: minmax(340px, 1fr) auto minmax(260px, 1fr);
align-items: center;
gap: 24px;
min-height: 96px;
}
.desktop-menu,
.header-actions {
display: flex;
align-items: center;
}
.desktop-menu {
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
.desktop-item {
position: relative;
}
.desktop-link,
.services-trigger {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 0 22px;
border-radius: 999px;
color: #fff;
font-size: 15px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
text-decoration: none;
appearance: none;
background: transparent;
border: 0;
box-shadow: none;
}
.fixed-header-box.theme-light .desktop-link,
.fixed-header-box.theme-light .services-trigger {
color: #213021;
}
.services-trigger {
cursor: pointer;
}
.chevron {
width: 16px;
height: 16px;
flex: 0 0 16px;
}
.brand-slot {
justify-self: center;
}
.brand-link {
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand-link img {
width: min(240px, 22vw);
min-width: 170px;
height: auto;
display: block;
}
.header-actions {
justify-self: end;
gap: 24px;
}
.services-panel[hidden] {
display: none !important;
}
.instagram-link {
width: 46px;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #efe2cb;
}
.fixed-header-box.theme-light .instagram-link {
color: #213021;
}
.instagram-link svg {
width: 46px;
height: 46px;
display: block;
}
.contact-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 56px;
padding: 0 34px;
border-radius: 999px;
background: #FFD100;
color: #090b0d;
font-size: 16px;
font-weight: 800;
line-height: 1;
text-decoration: none;
}
.fixed-header-box.theme-light .mobile-menu-toggle span {
background: #213021;
}
.mobile-menu-toggle,
.mobile-menu-drawer {
display: none;
}
.hero-section {
position: relative;
background: #213021;
color: #fff;
padding: 24px 0 34px;
overflow: hidden;
}
.hero-inner {
position: relative;
max-width: 1280px;
min-height: 370px;
margin: 0 auto;
padding: 0 30px;
display: flex;
align-items: center;
justify-content: space-between;
}
.hero-copy {
position: relative;
z-index: 1;
max-width: 520px;
padding: 28px 0 10px;
}
.hero-heading {
margin: 0 0 28px;
color: #fff;
font-family: 'Unbounded', sans-serif;
font-size: clamp(3rem, 4vw, 4.5rem);
line-height: 0.96;
letter-spacing: -0.06em;
}
.hero-art {
position: absolute;
right: 18px;
bottom: 0;
width: min(43vw, 500px);
aspect-ratio: 1 / 1;
}
.hero-art img {
width: 100%;
height: auto;
display: block;
}
.hero-actions {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 22px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
line-height: 1;
text-decoration: none;
}
.btn-cream {
background: #efe2cb;
color: #000;
}
.btn-yellow {
background: #FFD100;
color: #000;
}
.pricing-hero,
.about-hero,
.service-hero {
padding: 48px 0;
background: #fff;
border-bottom: 1px solid #ece6de;
text-align: center;
}
.service-hero {
background: #efe2cb;
}
.service-hero-inner {
max-width: 1280px;
margin: 0 auto;
padding: 0 30px;
display: flex;
align-items: center;
gap: 40px;
flex-wrap: wrap;
}
.service-hero-content {
flex: 1 1 380px;
text-align: left;
}
.service-hero-img-wrap {
flex: 0 1 420px;
width: min(100%, 420px);
}
.service-hero-img-wrap img {
width: 100%;
height: auto;
display: block;
}
.team-avatar-img,
.team-avatar-placeholder {
width: 80px;
height: 80px;
display: block;
border-radius: 50%;
}
@media only screen and (max-width: 1100px) {
.desktop-nav,
.instagram-link,
.contact-pill {
display: none;
}
.fixed-header-box.theme-light .mobile-menu-toggle {
color: #213021;
}
.mobile-menu-toggle {
display: inline-flex;
flex-direction: column;
gap: 4px;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.mobile-menu-toggle span {
width: 24px;
height: 2px;
background: #fff;
border-radius: 999px;
}
.service-hero-inner {
gap: 24px;
}
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More