Design language

This commit is contained in:
2026-05-13 09:39:52 +12:00
parent 6c943b14bd
commit de8b60b9c3
11 changed files with 329 additions and 236 deletions
+17 -43
View File
@@ -53,6 +53,23 @@
<Icon name="fab fa-google" />
</a>
</div>
{#if footer.email || footer.phone}
<div class="footer-contact">
{#if footer.email}
<a href="mailto:{footer.email}" class="footer-contact-link">
<Icon name="fas fa-envelope" />
{footer.email}
</a>
{/if}
{#if footer.phone}
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
<Icon name="fas fa-phone" />
{footer.phone}
</a>
{/if}
</div>
{/if}
</div>
<div class="footer-explore">
@@ -80,49 +97,6 @@
{/each}
</ul>
</div>
<div class="footer-action footer-panel footer-panel-accent">
<p class="footer-col-label">Get Started</p>
<h3 class="footer-action-title">Ready when you are</h3>
<p class="footer-action-copy">Questions, pricing, or your first Meet &amp; Greet. Start here and well reply within 24 hours.</p>
<a href="/contact-us" class="footer-book-btn">
Contact Us
<Icon name="fas fa-arrow-right" />
</a>
<p class="footer-book-note">Friendly, no-pressure first step</p>
<a
href="https://g.page/r/CUsvrWPhkYrAEB0/"
target="_blank"
rel="noopener"
class="footer-reviews"
>
<img
class="footer-google-logo"
src="/images/google-g-logo.svg"
alt=""
width="16"
height="17"
/>
<span>30+ five-star Google reviews</span>
</a>
{#if footer.email || footer.phone}
<div class="footer-contact">
{#if footer.email}
<a href="mailto:{footer.email}" class="footer-contact-link">
<Icon name="fas fa-envelope" />
{footer.email}
</a>
{/if}
{#if footer.phone}
<a href="tel:{footer.phone.replace(/[^0-9+]/g, '')}" class="footer-contact-link">
<Icon name="fas fa-phone" />
{footer.phone}
</a>
{/if}
</div>
{/if}
</div>
</div>
<div class="footer-bottom">
+23 -3
View File
@@ -13,6 +13,8 @@
export let navigation: NavigationContent;
let mobileMenuOpen = false;
let headerElement: HTMLElement;
let mobileMenuTop = 0;
const mobilePhoneDisplay = '(022) 642 1011';
const mobilePhoneHref = '+64226421011';
@@ -32,6 +34,11 @@
mobileMenuOpen = !mobileMenuOpen;
}
function updateMobileMenuTop() {
if (!headerElement) return;
mobileMenuTop = Math.max(headerElement.getBoundingClientRect().bottom, 0);
}
function mobileLinkIcon(href: string) {
if (href === '/') return 'fas fa-house';
if (href === '/pack-walks') return 'fas fa-paw';
@@ -91,11 +98,17 @@
}
function handleViewportChange() {
updateMobileMenuTop();
if (window.innerWidth > 768) {
mobileMenuOpen = false;
}
}
$: if (mobileMenuOpen && typeof window !== 'undefined') {
updateMobileMenuTop();
}
$: if (typeof document !== 'undefined') {
document.body.classList.toggle('mobile-menu-open', mobileMenuOpen);
}
@@ -103,17 +116,19 @@
onMount(() => {
handleViewportChange();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', updateMobileMenuTop, { passive: true });
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', updateMobileMenuTop);
window.removeEventListener('keydown', handleKeydown);
document.body.classList.remove('mobile-menu-open');
};
});
</script>
<header>
<header bind:this={headerElement}>
<nav>
<ul class="nav-links">
{#each navigation.desktopLinks as link, i}
@@ -231,8 +246,13 @@
</div>
{/if}
<div class:open={mobileMenuOpen} class="mobile-menu-shell">
<div class="mobile-menu" id="mobile-menu">
<div
class:open={mobileMenuOpen}
class="mobile-menu-shell"
style={`--mobile-menu-top: ${mobileMenuTop}px;`}
on:click={closeMenu}
>
<div class="mobile-menu" id="mobile-menu" on:click|stopPropagation>
<div class="mobile-menu-links">
{#each navigation.mobileLinks as link}
<a
+26 -2
View File
@@ -28,16 +28,40 @@ describe('Header', () => {
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
const mobileMenu = container.querySelector('.mobile-menu') as HTMLDivElement;
const mobileMenuShell = container.querySelector('.mobile-menu-shell') as HTMLDivElement;
const firstMobileLink = mobileMenu.querySelector('a') as HTMLAnchorElement;
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
await fireEvent.click(menuToggle);
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
expect(mobileMenu.classList.contains('open')).toBe(true);
expect(mobileMenuShell.classList.contains('open')).toBe(true);
await fireEvent.click(firstMobileLink);
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
expect(mobileMenu.classList.contains('open')).toBe(false);
expect(mobileMenuShell.classList.contains('open')).toBe(false);
});
it('closes the mobile menu when tapping outside the menu panel', async () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: 390
});
const { container } = render(Header, {
navigation: homepageContent.navigation
});
const menuToggle = container.querySelector('.hamburger') as HTMLButtonElement;
const mobileMenuShell = container.querySelector('.mobile-menu-shell') as HTMLDivElement;
await fireEvent.click(menuToggle);
expect(menuToggle).toHaveAttribute('aria-expanded', 'true');
expect(mobileMenuShell.classList.contains('open')).toBe(true);
await fireEvent.click(mobileMenuShell);
expect(menuToggle).toHaveAttribute('aria-expanded', 'false');
expect(mobileMenuShell.classList.contains('open')).toBe(false);
});
});
+3 -17
View File
@@ -152,8 +152,6 @@
{/if}
</div>
<p class="service-benefit-mobile-hint">Swipe through the reasons Tiny Gang works so well.</p>
<div class="service-benefit-shell">
<div bind:this={benefitScroller} class="service-benefit-grid">
{#each benefitCards as benefit}
@@ -566,13 +564,9 @@
overflow: visible;
}
.service-benefit-mobile-hint {
display: none;
}
.service-benefit-mobile-controls {
display: none;
}
.service-benefit-mobile-controls {
display: none;
}
.service-section-heading {
text-align: center;
@@ -1133,14 +1127,6 @@
line-height: 1.68;
}
.service-benefit-mobile-hint {
display: block;
margin: -10px 0 16px;
color: var(--gray);
font-size: 13px;
line-height: 1.5;
}
.service-benefit-mobile-controls {
display: flex;
justify-content: flex-end;
+68 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { reveal } from '$lib/actions/reveal';
import Icon from '$lib/components/Icon.svelte';
import { getEnhancedImage } from '$lib/enhanced-images';
@@ -52,6 +52,7 @@
let inView = false;
let prefersReducedMotion = false;
let carouselEl: HTMLDivElement | undefined;
let stageEl: HTMLDivElement | undefined;
let slideSignature = '';
$: slides = testimonials
@@ -89,6 +90,7 @@
}
activeIndex = (activeIndex - 1 + slides.length) % slides.length;
syncMobileStage();
}
function showNext() {
@@ -97,6 +99,34 @@
}
activeIndex = (activeIndex + 1) % slides.length;
syncMobileStage();
}
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth <= 767;
}
async function syncMobileStage(behavior: ScrollBehavior = 'smooth') {
if (!stageEl || !isMobileViewport()) {
return;
}
await tick();
stageEl.scrollTo({
left: stageEl.clientWidth * activeIndex,
behavior
});
}
function handleStageScroll() {
if (!stageEl || !isMobileViewport()) {
return;
}
const nextIndex = Math.round(stageEl.scrollLeft / Math.max(stageEl.clientWidth, 1));
if (nextIndex !== activeIndex) {
activeIndex = nextIndex;
}
}
onMount(() => {
@@ -120,6 +150,13 @@
observer.observe(carouselEl);
}
const handleResize = () => {
syncMobileStage('auto');
};
window.addEventListener('resize', handleResize);
syncMobileStage('auto');
const interval = window.setInterval(() => {
if (!paused && !prefersReducedMotion && inView && slides.length > 1) {
showNext();
@@ -128,6 +165,7 @@
return () => {
window.clearInterval(interval);
window.removeEventListener('resize', handleResize);
motionQuery.removeEventListener('change', onMotionChange);
observer?.disconnect();
};
@@ -162,7 +200,7 @@
<Icon name="fas fa-chevron-left" />
</button>
<div class="testimonial-stage">
<div bind:this={stageEl} class="testimonial-stage" on:scroll={handleStageScroll}>
<div class="testimonial-woof" aria-hidden="true">
<span class="testimonial-woof-text">WOOF</span>
<span class="testimonial-ray testimonial-ray-1"></span>
@@ -640,14 +678,28 @@
.testimonial-stage {
min-height: unset;
display: flex;
padding-bottom: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.testimonial-stage::-webkit-scrollbar {
display: none;
}
.testimonial-slide {
position: relative;
display: none;
display: grid;
flex: 0 0 100%;
grid-template-columns: 1fr;
opacity: 1;
pointer-events: auto;
transform: none;
scroll-snap-align: start;
}
.testimonial-slide-active {
@@ -689,17 +741,27 @@
.testimonial-mobile-controls {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: 100%;
gap: 12px;
margin-top: 20px;
}
.testimonial-arrow-inline {
position: static;
width: 48px;
height: 48px;
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: rgba(33, 48, 33, 0.08);
color: var(--gw-green);
font-size: 18px;
transform: none;
box-shadow: 0 10px 22px rgba(20, 24, 20, 0.08);
box-shadow: none;
}
.testimonial-arrow-inline:active {
transform: scale(0.95);
}
.testimonial-google {
+125 -11
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
import type { IconCard } from '$lib/types';
export let values: IconCard[];
let valuesScroller: HTMLDivElement | undefined;
$: orderedValues = values
.map((value, index) => ({ value, index }))
@@ -17,6 +19,36 @@
return a.index - b.index;
})
.map(({ value }) => value);
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth <= 768;
}
async function scrollValues(direction: 1 | -1) {
if (!valuesScroller || !isMobileViewport()) {
return;
}
const firstCard = valuesScroller.querySelector<HTMLElement>('.value-card');
if (!firstCard) {
return;
}
const cardStyle = window.getComputedStyle(valuesScroller);
const gap = Number.parseFloat(cardStyle.columnGap || cardStyle.gap || '0') || 0;
const step = firstCard.offsetWidth + gap;
await tick();
valuesScroller.scrollBy({ left: direction * step, behavior: 'smooth' });
}
onMount(() => {
if (!valuesScroller || !isMobileViewport()) {
return;
}
valuesScroller.scrollTo({ left: 0, behavior: 'auto' });
});
</script>
<section id="values">
@@ -27,18 +59,29 @@
Everything is designed to make life easier for busy Auckland dog owners and safer, happier for the dogs in our care.
</p>
<div class="values-grid">
{#each orderedValues as value}
<div class="value-card">
<div class="value-icon-wrap">
<Icon name={value.icon} className="value-card-icon" />
<div class="values-shell">
<div bind:this={valuesScroller} class="values-grid">
{#each orderedValues as value}
<div class="value-card">
<div class="value-icon-wrap">
<Icon name={value.icon} className="value-card-icon" />
</div>
<div class="value-text">
<h3>{value.title}</h3>
<p>{value.body}</p>
</div>
</div>
<div class="value-text">
<h3>{value.title}</h3>
<p>{value.body}</p>
</div>
</div>
{/each}
{/each}
</div>
<div class="values-mobile-controls" aria-label="Value cards navigation">
<button type="button" class="values-mobile-button" aria-label="Previous value" on:click={() => scrollValues(-1)}>
<Icon name="fas fa-chevron-left" />
</button>
<button type="button" class="values-mobile-button" aria-label="Next value" on:click={() => scrollValues(1)}>
<Icon name="fas fa-chevron-right" />
</button>
</div>
</div>
</div>
</section>
@@ -68,7 +111,68 @@
line-height: 1.65;
}
.values-mobile-controls {
display: none;
}
@media (max-width: 768px) {
.values-shell {
margin-top: 32px;
}
.values-grid {
grid-auto-flow: column;
grid-auto-columns: minmax(272px, 84vw);
grid-template-columns: none;
gap: 14px;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
scroll-padding-left: 24px;
padding: 0 24px 8px 0;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.values-grid::-webkit-scrollbar {
display: none;
}
.values-mobile-controls {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.values-mobile-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.value-card {
min-height: 100%;
padding: 24px 22px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
background: rgba(255, 255, 255, 0.05);
scroll-snap-align: start;
}
.value-card:nth-child(odd) {
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
.values-eyebrow {
margin-bottom: 8px;
padding: 6px 10px;
@@ -81,4 +185,14 @@
line-height: 1.55;
}
}
@media (hover: hover) {
.values-mobile-button:hover {
background: rgba(255, 255, 255, 0.18);
}
}
.values-mobile-button:active {
transform: scale(0.95);
}
</style>
+3 -3
View File
@@ -10,7 +10,7 @@ export const homepageContent: HomePageContent = {
desktopLinks: [
{ label: 'Our Services', href: '#services' },
{ label: 'Our Pricing', href: '/our-pricing' },
{ label: 'About', href: '/about' }
{ label: 'About Us', href: '/about' }
],
mobileLinks: [
{ label: 'Home', href: '/' },
@@ -18,8 +18,8 @@ export const homepageContent: HomePageContent = {
{ label: '1:1 Walks', href: '/dog-walking' },
{ label: 'Puppy Visits', href: '/puppy-visits' },
{ label: 'Our Pricing', href: '/our-pricing' },
{ label: 'About', href: '/about' },
{ label: 'Contact', href: '/contact-us' }
{ label: 'About Us', href: '/about' },
{ label: 'Contact Us', href: '/contact-us' }
],
cta: { label: 'Contact Us', href: '/contact-us', variant: 'yellow' },
instagram: { href: 'https://www.instagram.com/goodwalk.nz/', external: true },
+9 -8
View File
@@ -46,20 +46,22 @@ header {
@media (max-width: 768px) {
.nav-ribbon {
padding: 10px 16px;
padding: 14px 16px;
}
.nav-ribbon-item {
flex: 1;
justify-content: center;
padding: 0;
gap: 6px;
gap: 7px;
font-size: 9px;
letter-spacing: 0.04em;
line-height: 1.25;
letter-spacing: 0.045em;
white-space: nowrap;
}
.nav-ribbon-item .icon {
font-size: 13px;
font-size: 14px;
}
/* Hide the third ribbon item and its preceding divider on mobile */
@@ -69,8 +71,7 @@ header {
}
.nav-ribbon-divider {
height: 12px;
flex: none;
display: none;
}
}
@@ -437,8 +438,8 @@ nav {
.footer-inner {
display: grid;
grid-template-columns: 1.1fr 0.8fr 0.8fr 1fr;
gap: 24px;
grid-template-columns: 1.1fr 0.8fr 1fr;
gap: 32px;
margin-bottom: 48px;
align-items: start;
}
+38 -36
View File
@@ -135,16 +135,18 @@
.mobile-menu {
display: flex;
width: 100%;
max-width: none;
flex-direction: column;
gap: 0;
padding: 10px;
padding: 10px 12px 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.985), rgba(248, 248, 245, 0.98));
border: 1px solid rgba(33, 48, 33, 0.08);
border-radius: 22px;
border-top: 0;
border-radius: 0 0 24px 24px;
box-shadow:
0 16px 32px rgba(17, 20, 24, 0.12),
0 2px 10px rgba(17, 20, 24, 0.05);
0 18px 32px rgba(17, 20, 24, 0.12),
0 6px 14px rgba(17, 20, 24, 0.05);
opacity: 0;
transform: translateY(-10px) scale(0.992);
transition:
@@ -153,13 +155,13 @@
}
.mobile-menu-shell {
display: block;
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
display: flex;
align-items: flex-start;
position: fixed;
inset: var(--mobile-menu-top, 0px) 0 0;
z-index: 120;
padding: 0 16px;
padding: 0 0 max(20px, env(safe-area-inset-bottom));
background: rgba(17, 20, 24, 0.04);
pointer-events: none;
opacity: 0;
visibility: hidden;
@@ -306,7 +308,7 @@
max-width: none;
margin: 0;
object-fit: cover;
object-position: center 48%;
object-position: 43% center;
transform: none;
}
@@ -706,41 +708,35 @@
}
footer {
padding: 48px 24px 28px;
padding: 40px 24px 24px;
}
.footer-inner {
grid-template-columns: 1fr;
gap: 16px;
}
.footer-action {
order: -1;
}
.footer-panel {
padding: 22px 18px;
border-radius: 24px;
}
.footer-action-title {
font-size: 22px;
}
.footer-action-copy {
font-size: 14px;
}
.footer-book-note {
text-align: left;
gap: 22px;
}
.footer-nav a {
padding: 11px 0;
padding: 9px 0;
}
.footer-logo {
display: none;
}
.footer-brand p {
margin-bottom: 14px;
max-width: none;
}
.footer-col-label {
margin-bottom: 12px;
}
.footer-contact {
gap: 6px;
margin-top: 14px;
padding-top: 14px;
}
.footer-bottom {
@@ -756,7 +752,13 @@
@media (max-width: 480px) {
.footer-nav {
gap: 2px 16px;
grid-template-columns: 1fr;
gap: 0;
}
.footer-locations .footer-nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.mobile-phone {
+11 -107
View File
@@ -737,33 +737,13 @@ footer {
margin-bottom: 18px;
}
.footer-panel {
min-height: 100%;
padding: 28px 26px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.06);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 18px 34px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(6px);
}
.footer-panel-accent {
background:
radial-gradient(circle at top right, rgba(255, 209, 71, 0.22), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.11) 0%, rgba(255, 255, 255, 0.06) 100%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.1),
0 20px 40px rgba(0, 0, 0, 0.12);
}
.footer-brand p {
font-size: 14px;
line-height: 1.7;
opacity: 0.76;
margin-bottom: 24px;
opacity: 0.72;
margin-bottom: 18px;
white-space: pre-line;
max-width: 34ch;
max-width: 30ch;
}
.social-links a {
@@ -789,13 +769,13 @@ footer {
}
.footer-col-label {
margin: 0 0 16px;
margin: 0 0 14px;
font-family: var(--font-head);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.45;
opacity: 0.5;
}
.footer-nav {
@@ -816,10 +796,10 @@ footer {
.footer-nav a {
display: block;
padding: 9px 0;
font-size: 15px;
padding: 8px 0;
font-size: 14px;
font-weight: 500;
opacity: 0.75;
opacity: 0.72;
transition: opacity 0.18s;
}
@@ -827,89 +807,13 @@ footer {
opacity: 1;
}
.footer-action-title {
margin: 0 0 10px;
color: #fff;
font-size: 28px;
line-height: 1;
letter-spacing: -0.04em;
}
.footer-action-copy {
margin: 0 0 22px;
max-width: 34ch;
color: rgba(255, 255, 255, 0.78);
font-size: 14px;
line-height: 1.65;
}
.footer-book-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 16px 22px;
border-radius: 999px;
background: var(--yellow);
color: #000;
font-family: var(--font-head);
font-weight: 700;
font-size: 15px;
line-height: 1.2;
letter-spacing: 0.01em;
transition: background 0.2s, transform 0.15s;
margin-bottom: 10px;
}
.footer-book-btn:hover {
background: #ffe033;
transform: translateY(-1px);
}
.footer-book-note {
margin: 0 0 20px;
font-size: 13px;
opacity: 0.68;
text-align: left;
}
.footer-reviews {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 9px 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.12);
font-family: var(--font-head);
font-size: 13px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.01em;
opacity: 0.8;
transition: background 0.2s, opacity 0.2s;
}
.footer-google-logo {
width: 16px;
height: 17px;
flex: 0 0 auto;
}
.footer-reviews:hover {
background: rgba(255, 255, 255, 0.13);
opacity: 1;
}
.footer-contact {
display: grid;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
max-width: 24rem;
}
.footer-contact-link {
+6
View File
@@ -37,6 +37,10 @@
let revealObserver: IntersectionObserver | null = null;
function shouldAnimateAnchorBounce() {
return typeof window !== 'undefined' && window.innerWidth > 768;
}
function initReveal() {
revealObserver?.disconnect();
@@ -64,6 +68,8 @@
}
function bounceSection(hash: string) {
if (!shouldAnimateAnchorBounce()) return;
const id = hash.startsWith('#') ? hash.slice(1) : hash;
if (!id) return;
// Wait for smooth scroll to finish before playing the bounce