feature/services-pages (#7)
Reviewed-on: #7 Co-authored-by: mifi <badmf@mifi.dev> Co-committed-by: mifi <badmf@mifi.dev>
This commit was merged in pull request #7.
This commit is contained in:
61
src/lib/components/Breadcrumbs.svelte
Normal file
61
src/lib/components/Breadcrumbs.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
items = [],
|
||||
}: {
|
||||
items: BreadcrumbItem[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<nav class="section breadcrumbs" aria-label="Breadcrumbs">
|
||||
<div class="container">
|
||||
<ol class="list">
|
||||
{#each items as item, index}
|
||||
<li class="item">
|
||||
{#if item.href}
|
||||
<a href={item.href}>{item.label}</a>
|
||||
{#if index < items.length - 1}<span
|
||||
class="separator"
|
||||
aria-hidden="true">></span
|
||||
>{/if}
|
||||
{:else}
|
||||
{item.label}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.breadcrumbs {
|
||||
padding: var(--space-md) 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.item a {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-size: var(--font-size-small);
|
||||
margin-inline: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
37
src/lib/components/EngagementsDl.svelte
Normal file
37
src/lib/components/EngagementsDl.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { EngagementItem } from '$lib/types/service-page';
|
||||
|
||||
const {
|
||||
items,
|
||||
sectionId = 'how-engagements-work',
|
||||
headingId = 'engagements-heading',
|
||||
heading = 'How engagements work',
|
||||
intro,
|
||||
outro,
|
||||
}: {
|
||||
items: EngagementItem[];
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
heading?: string;
|
||||
intro?: string;
|
||||
outro?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section id={sectionId} class="section" aria-labelledby={headingId}>
|
||||
<div class="container narrow">
|
||||
<h2 id={headingId}>{heading}</h2>
|
||||
{#if intro}
|
||||
<p>{intro}</p>
|
||||
{/if}
|
||||
<dl class="engagements-list">
|
||||
{#each items as item}
|
||||
<dt>{item.term}</dt>
|
||||
<dd>{item.definition}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
{#if outro}
|
||||
<p>{outro}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
51
src/lib/components/FAQ.svelte
Normal file
51
src/lib/components/FAQ.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { FaqList } from '$lib/types/faq';
|
||||
|
||||
const {
|
||||
faqList,
|
||||
title = 'FAQ',
|
||||
}: {
|
||||
faqList: FaqList;
|
||||
title?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section id="faq" class="section faq" aria-labelledby="faq-heading">
|
||||
<div class="container narrow">
|
||||
<h2 id="faq-heading">{title}</h2>
|
||||
<dl class="faq-list">
|
||||
{#each faqList as { question, answer }, index (index)}
|
||||
<dt>{question}</dt>
|
||||
<dd>{answer}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.faq {
|
||||
background-color: var(--color-bg-alt);
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
margin: 0;
|
||||
|
||||
& dt {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
margin-top: var(--space-xl);
|
||||
margin-bottom: var(--space-sm);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& dd {
|
||||
margin: 0 0 0 var(--space-md);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
max-width: var(--max-text-width);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,9 +7,9 @@
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p class="copyright">
|
||||
© <span id="copyright-year">2026</span> mifi Ventures, LLC · Boston, MA
|
||||
© <span id="copyright-year">2026</span> mifi Ventures, LLC. All rights reserved.
|
||||
</p>
|
||||
<nav class="footer-links" aria-label="Social media links">
|
||||
<nav class="footer-links footer-links-wrap" aria-label="Footer links">
|
||||
<a
|
||||
class="link"
|
||||
href="https://linkedin.com/in/the-mifi"
|
||||
@@ -36,6 +36,22 @@
|
||||
GitHub
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={15} />
|
||||
</a>
|
||||
<a
|
||||
class="link"
|
||||
href="/privacy-policy"
|
||||
data-umami-event="footer link"
|
||||
data-umami-event-label="privacy-policy"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
class="link"
|
||||
href="/terms-of-service"
|
||||
data-umami-event="footer link"
|
||||
data-umami-event-label="terms-of-service"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -56,6 +72,10 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.footer-links-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,41 +1,58 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
|
||||
import FiletypePdfIcon from './Icon/FiletypePdf.svelte';
|
||||
import Logo from './Logo.svelte';
|
||||
import ExternalLinkIcon from '$lib/components/Icon/ExternalLink.svelte';
|
||||
|
||||
interface SecondaryCta {
|
||||
href: string;
|
||||
label: string;
|
||||
umamiEventLabel: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
bookingLinkTitle,
|
||||
bookingLinkUrl,
|
||||
secondaryCta,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
bookingLinkTitle: string;
|
||||
bookingLinkUrl: string;
|
||||
secondaryCta?: SecondaryCta;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header id="header" class="hero">
|
||||
<div class="container">
|
||||
<Logo />
|
||||
<p class="headline">Software Engineering Consulting</p>
|
||||
<p class="subhead">
|
||||
Principal: Mike Fitzpatrick — senior full-stack engineer and architect helping
|
||||
teams ship reliable, accessible, high-performance web products.
|
||||
<h1 class="title">
|
||||
{title}
|
||||
</h1>
|
||||
<p class="subtitle">
|
||||
{subtitle}
|
||||
</p>
|
||||
<div class="cta-group">
|
||||
<a
|
||||
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=hero_cta"
|
||||
href={bookingLinkUrl}
|
||||
class="btn btn-primary icon-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Schedule a 30-minute intro call (opens in new tab)"
|
||||
data-umami-event="schedule call"
|
||||
data-umami-event-location="hero section"
|
||||
aria-label={`${bookingLinkTitle} (opens in new tab)`}
|
||||
data-umami-event="book discovery call"
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
Schedule a 30-minute intro call
|
||||
{bookingLinkTitle}
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
|
||||
</a>
|
||||
<a
|
||||
href="/downloads/resume.pdf"
|
||||
class="btn btn-secondary icon-button"
|
||||
download
|
||||
aria-label="Download Mike Fitzpatrick's resume as PDF"
|
||||
data-umami-event="download resume"
|
||||
data-umami-event-location="hero section"
|
||||
>
|
||||
Download resume
|
||||
<FiletypePdfIcon aria-label="PDF format file" size={17} />
|
||||
</a>
|
||||
{#if secondaryCta}
|
||||
<a
|
||||
href={secondaryCta.href}
|
||||
class="btn btn-secondary"
|
||||
data-umami-event={secondaryCta.umamiEventLabel}
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
{secondaryCta.label}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -46,26 +63,20 @@
|
||||
text-align: center;
|
||||
background-color: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: var(--space-xxl) 0 var(--space-xl) 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: var(--space-xl) 0 var(--space-lg) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.headline {
|
||||
.title {
|
||||
margin-bottom: var(--space-lg);
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text);
|
||||
letter-spacing: -0.02em;
|
||||
font-size: var(--font-size-xxl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: -0.03em;
|
||||
max-width: var(--max-text-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
.subtitle {
|
||||
max-width: var(--max-narrow-width);
|
||||
margin: 0 auto var(--space-xl) auto;
|
||||
font-size: var(--font-size-large);
|
||||
@@ -81,8 +92,10 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 768px) {
|
||||
.cta-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { howWeWorkItems } from '$lib/data/content';
|
||||
</script>
|
||||
|
||||
<section id="how-we-work" class="section" aria-labelledby="how-we-work-heading">
|
||||
<div class="container">
|
||||
<h2 id="how-we-work-heading" class="section-title">How We Work</h2>
|
||||
<ul class="content-list">
|
||||
{#each howWeWorkItems as item (item)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,5 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { page as pageState } from '$app/state';
|
||||
import Wordmark from './Wordmark.svelte';
|
||||
|
||||
const path = $derived(pageState.url?.pathname ?? '/');
|
||||
|
||||
/** Page slug for body class: "page-home" | "page-services" | "page-services-hands-on-saas-architecture-consultant" etc. Set at build time per route; no client JS. */
|
||||
const bodyClass = $derived(
|
||||
path === '/'
|
||||
? 'page-home'
|
||||
: 'page-' + path.replace(/^\/|\/$/g, '').replace(/\//g, '-'),
|
||||
);
|
||||
|
||||
interface NavigationItem {
|
||||
label: string;
|
||||
href: string;
|
||||
umamiEventLabel: string;
|
||||
}
|
||||
|
||||
const { items = [], page }: { items: NavigationItem[]; page: string } = $props();
|
||||
</script>
|
||||
|
||||
<nav id="nav" class="nav" aria-label="Main navigation">
|
||||
@@ -11,7 +29,9 @@
|
||||
hidden
|
||||
/>
|
||||
<div class="mobile-nav-header">
|
||||
<span class="mobile nav-header-logo">
|
||||
<span
|
||||
class={['mobile nav-header-logo', { 'page-home': bodyClass === 'page-home' }]}
|
||||
>
|
||||
<Wordmark />
|
||||
</span>
|
||||
<button
|
||||
@@ -37,42 +57,35 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="nav-menu" class="nav-menu container">
|
||||
<span class="nav-header-logo desktop">
|
||||
<Wordmark />
|
||||
<span
|
||||
class={[
|
||||
'nav-header-logo desktop',
|
||||
{ 'page-home': bodyClass === 'page-home' },
|
||||
]}
|
||||
>
|
||||
{#if page !== 'home'}
|
||||
<a href="/">
|
||||
<Wordmark />
|
||||
<span class="sr-only">mifi Ventures home page</span>
|
||||
</a>
|
||||
{:else}
|
||||
<Wordmark />
|
||||
{/if}
|
||||
</span>
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#what-we-do"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="services">Services</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#impact"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="impact">Impact</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#how-we-work"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="process">Process</a
|
||||
>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href="#schedule"
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label="contact">Contact</a
|
||||
>
|
||||
</li>
|
||||
{#each items as item (item.href)}
|
||||
<li class="nav-item">
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-link"
|
||||
data-umami-event="navigation"
|
||||
data-umami-event-label={item.umamiEventLabel}
|
||||
data-umami-event-page={page}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="nav-item nav-back-to-top">
|
||||
<a
|
||||
@@ -246,7 +259,7 @@
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -259,7 +272,7 @@
|
||||
|
||||
/* Back to top + mobile nav logo: hidden until page is scrolled (CSS scroll-driven animation) */
|
||||
.nav-back-to-top,
|
||||
.nav-header-logo {
|
||||
.nav-header-logo.page-home {
|
||||
/* Fallback when scroll-driven animations aren’t supported: always visible */
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
@@ -362,7 +375,7 @@
|
||||
|
||||
/* Composited-only animation: opacity only (visibility/pointer-events not animated) */
|
||||
.nav-back-to-top,
|
||||
.nav-header-logo {
|
||||
.nav-header-logo.page-home {
|
||||
opacity: 0;
|
||||
animation: nav-reveal-on-scroll linear;
|
||||
animation-timeline: scroll(root block);
|
||||
|
||||
@@ -1,27 +1,65 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from './Icon/ExternalLink.svelte';
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
bookingLinkTitle,
|
||||
bookingLinkUrl = 'https://cal.mifi.ventures/the-mifi/30min',
|
||||
showEmailLink = false,
|
||||
showServicesLink = false,
|
||||
sectionId = 'contact',
|
||||
headingId = 'contact-heading',
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
bookingLinkTitle: string;
|
||||
bookingLinkUrl?: string;
|
||||
showEmailLink?: boolean;
|
||||
showServicesLink?: boolean;
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="schedule"
|
||||
class="section schedule-section"
|
||||
aria-labelledby="schedule-heading"
|
||||
>
|
||||
<section id={sectionId} class="section schedule-section" aria-labelledby={headingId}>
|
||||
<div class="container">
|
||||
<h2 id="schedule-heading" class="section-title">Let's Talk</h2>
|
||||
<p class="schedule-text">Ready to discuss your project?</p>
|
||||
<a
|
||||
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_medium=schedule_section_cta"
|
||||
class="btn btn-primary icon-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Schedule a 30-minute intro call (opens in new tab)"
|
||||
data-umami-event="schedule call"
|
||||
data-umami-event-location="schedule section"
|
||||
>
|
||||
Schedule a 30-minute intro call
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
|
||||
</a>
|
||||
<h2 id={headingId} class="section-title">{title}</h2>
|
||||
<p class="schedule-text">{subtitle}</p>
|
||||
<div class="cta-group">
|
||||
<a
|
||||
href={bookingLinkUrl}
|
||||
class="btn btn-primary icon-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Schedule a 30-minute intro call (opens in new tab)"
|
||||
data-umami-event="schedule call"
|
||||
data-umami-event-location="contact section"
|
||||
>
|
||||
{bookingLinkTitle}
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
|
||||
</a>
|
||||
{#if showEmailLink}
|
||||
<a
|
||||
href="mailto:hello@mifi.ventures"
|
||||
class="btn btn-secondary"
|
||||
data-umami-event="email"
|
||||
data-umami-event-location="contact section"
|
||||
>
|
||||
Email me
|
||||
</a>
|
||||
{/if}
|
||||
{#if showServicesLink}
|
||||
<a
|
||||
href="/services"
|
||||
class="btn btn-secondary"
|
||||
data-umami-event="view services"
|
||||
data-umami-event-location="contact section"
|
||||
>
|
||||
View services
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -37,10 +75,23 @@
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
max-width: 100%;
|
||||
max-width: var(--max-text-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 0 auto;
|
||||
.cta-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cta-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
82
src/lib/components/ServiceSection.svelte
Normal file
82
src/lib/components/ServiceSection.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { ServiceSectionContent } from '$lib/types/service-page';
|
||||
|
||||
const {
|
||||
section,
|
||||
}: {
|
||||
section: ServiceSectionContent;
|
||||
} = $props();
|
||||
|
||||
const headingId = $derived(section.headingId ?? `${section.id}-heading`);
|
||||
const sectionClasses = $derived(
|
||||
['section', section.sectionClass].filter(Boolean).join(' '),
|
||||
);
|
||||
const containerClass = $derived(
|
||||
section.narrowContainer === false ? 'container' : 'container narrow',
|
||||
);
|
||||
const listClass = $derived(
|
||||
[section.bulletsListClass, 'content-list'].filter(Boolean).join(' '),
|
||||
);
|
||||
</script>
|
||||
|
||||
<section id={section.id} class={sectionClasses} aria-labelledby={headingId}>
|
||||
<div class={containerClass}>
|
||||
<h2 id={headingId} class={{ 'sr-only': section.headingSrOnly ?? false }}>
|
||||
{section.heading}
|
||||
</h2>
|
||||
|
||||
{#if section.lede}
|
||||
<p>{section.lede}</p>
|
||||
{/if}
|
||||
|
||||
{#each section.paragraphs ?? [] as p}
|
||||
<p>{p}</p>
|
||||
{/each}
|
||||
|
||||
{#each section.subsections ?? [] as sub, subIndex}
|
||||
{@const subId = sub.headingId ?? `${section.id}-sub-${subIndex}`}
|
||||
<h3 id={subId}>{sub.heading}</h3>
|
||||
{#each sub.paragraphs ?? [] as p}
|
||||
<p>{p}</p>
|
||||
{/each}
|
||||
{#if (sub.bullets?.length ?? 0) > 0}
|
||||
<ul class="content-list">
|
||||
{#each sub.bullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if (section.bullets?.length ?? 0) > 0}
|
||||
<ul class={listClass}>
|
||||
{#each section.bullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if (section.orderedBullets?.length ?? 0) > 0}
|
||||
<ol class="content-list ordered">
|
||||
{#each section.orderedBullets as bullet}
|
||||
<li>{bullet}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#each section.trailingParagraphs ?? [] as p}
|
||||
<p>{p}</p>
|
||||
{/each}
|
||||
|
||||
{#if (section.footerLinks?.length ?? 0) > 0}
|
||||
<p>
|
||||
{#each section.footerLinks as link, i}
|
||||
{#if i > 0}
|
||||
<span aria-hidden="true"> · </span>
|
||||
{/if}
|
||||
<a href={link.href}>{link.label}</a>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
115
src/lib/components/ServicesCardGrid.svelte
Normal file
115
src/lib/components/ServicesCardGrid.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { ServiceCard } from '$lib/types/service-page';
|
||||
|
||||
const {
|
||||
services,
|
||||
sectionId = 'services-grid',
|
||||
headingId = 'services-heading',
|
||||
heading = 'Services',
|
||||
overview = '',
|
||||
surface = 'bg',
|
||||
}: {
|
||||
services: ServiceCard[];
|
||||
sectionId?: string;
|
||||
headingId?: string;
|
||||
heading?: string;
|
||||
overview?: string;
|
||||
surface?: 'bg' | 'bg-alt';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
id={sectionId}
|
||||
class={['section services-grid-section', { 'bg-alt': surface === 'bg-alt' }]}
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
<div class="container">
|
||||
<h2 id={headingId} class="section-title">{heading}</h2>
|
||||
{#if overview}
|
||||
<p class="overview">{overview}</p>
|
||||
{/if}
|
||||
<ul class="services-card-list">
|
||||
{#each services as service (service.href)}
|
||||
<li class={['services-card', { bg: surface === 'bg-alt' }]}>
|
||||
<h3 class="title">{service.title}</h3>
|
||||
<p class="desc">{service.description}</p>
|
||||
<a
|
||||
href={service.href}
|
||||
class="link"
|
||||
data-umami-event="service link"
|
||||
data-umami-event-label={service.href}
|
||||
>
|
||||
Learn more
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.services-grid-section {
|
||||
background-color: var(--color-bg);
|
||||
|
||||
&.bg-alt {
|
||||
background-color: var(--color-bg-alt);
|
||||
}
|
||||
}
|
||||
|
||||
.overview {
|
||||
max-width: var(--max-text-width);
|
||||
margin: 0 auto var(--space-xxl) auto;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.services-card-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--space-xl);
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.services-card {
|
||||
padding: var(--space-xl);
|
||||
background-color: var(--color-bg-alt);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-medium);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.bg {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.desc {
|
||||
flex: 1;
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
& span {
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
src/lib/components/TOC.svelte
Normal file
58
src/lib/components/TOC.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
interface TOCItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const {
|
||||
ariaLabel = 'Page contents',
|
||||
items,
|
||||
title = 'On this page',
|
||||
}: {
|
||||
ariaLabel?: string;
|
||||
items: TOCItem[];
|
||||
title?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<nav class="section toc" aria-label={ariaLabel}>
|
||||
<div class="container">
|
||||
<h2 class="title">{title}</h2>
|
||||
<ul class="list">
|
||||
{#each items as item}
|
||||
<li><a href={item.href}>{item.label}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.toc {
|
||||
padding: var(--space-xl) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm) var(--space-xl);
|
||||
|
||||
& a {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-medium);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { whatWeDoItems } from '$lib/data/content';
|
||||
</script>
|
||||
|
||||
<section id="what-we-do" class="section" aria-labelledby="what-we-do-heading">
|
||||
<div class="container">
|
||||
<h2 id="what-we-do-heading" class="section-title">What We Do</h2>
|
||||
<ul class="content-list">
|
||||
{#each whatWeDoItems as item (item)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
59
src/lib/components/WhoGrid.svelte
Normal file
59
src/lib/components/WhoGrid.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
const {
|
||||
showTitle = false,
|
||||
title = 'Who this is for',
|
||||
whoForList,
|
||||
whoNotList,
|
||||
whoForHeading = 'Good fit',
|
||||
whoNotHeading = 'Who this is not for',
|
||||
}: {
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
whoForList: string[];
|
||||
whoNotList: string[];
|
||||
whoForHeading?: string;
|
||||
whoNotHeading?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section id="who-its-for" class="section" aria-labelledby="who-for-heading">
|
||||
<div class="container">
|
||||
<h2 id="who-for-heading" class={['title', { 'sr-only': !showTitle }]}>{title}</h2>
|
||||
<div class="who-grid">
|
||||
<div class="who-block">
|
||||
<h3 id="who-for-list-heading" class="list-heading">{whoForHeading}</h3>
|
||||
<ul class="content-list" aria-labelledby="who-for-list-heading">
|
||||
{#each whoForList as item, index (index)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="who-block">
|
||||
<h3 id="who-not-list-heading" class="list-heading">{whoNotHeading}</h3>
|
||||
<ul class="content-list" aria-labelledby="who-not-list-heading">
|
||||
{#each whoNotList as item, index (index)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.who-grid {
|
||||
display: grid;
|
||||
gap: var(--space-xxl);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.title,
|
||||
.list-heading {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { engagements } from '$lib/data/engagements';
|
||||
import { engagements } from '$lib/data/home/engagements';
|
||||
</script>
|
||||
|
||||
<section id="engagements" class="section" aria-labelledby="engagements-heading">
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { experienceLogos, experienceTextList } from '$lib/data/experience';
|
||||
import { experienceLogos, experienceTextList } from '$lib/data/home/experience';
|
||||
</script>
|
||||
|
||||
<section
|
||||
101
src/lib/components/home/Hero.svelte
Normal file
101
src/lib/components/home/Hero.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkIcon from '$lib/components/Icon/ExternalLink.svelte';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
</script>
|
||||
|
||||
<header id="header" class="hero">
|
||||
<div class="container">
|
||||
<Logo />
|
||||
<h2 class="headline">Hands-On Product Architecture for Early-Stage SaaS</h2>
|
||||
<p class="subhead">
|
||||
I help SaaS teams ship quickly without creating frontend debt, architectural
|
||||
drift, or technical complexity that slows iteration later.
|
||||
</p>
|
||||
<p class="supporting">
|
||||
Mike Fitzpatrick — product architect and senior software engineer
|
||||
</p>
|
||||
<div class="cta-group">
|
||||
<a
|
||||
href="https://cal.mifi.ventures/the-mifi/30min?utm_source=website&utm_medium=cta&utm_campaign=schedule_call&utm_content=hero"
|
||||
class="btn btn-primary icon-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Schedule a 30-minute intro call (opens in new tab)"
|
||||
data-umami-event="schedule call"
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
Schedule a 30-minute intro call
|
||||
<ExternalLinkIcon aria-label="Opens in new tab" size={17} />
|
||||
</a>
|
||||
<a
|
||||
href="/services"
|
||||
class="btn btn-secondary"
|
||||
data-umami-event="view services"
|
||||
data-umami-event-location="hero"
|
||||
>
|
||||
View services
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
padding: var(--space-xxxl) 0 var(--space-xxl) 0;
|
||||
text-align: center;
|
||||
background-color: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: var(--space-xxl) 0 var(--space-xl) 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: var(--space-xl) 0 var(--space-lg) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-bottom: var(--space-lg);
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: var(--font-size-xxl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-text);
|
||||
line-height: var(--line-height-heading);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.subhead {
|
||||
max-width: var(--max-narrow-width);
|
||||
margin: 0 auto var(--space-md) auto;
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
.supporting {
|
||||
margin: 0 auto var(--space-xl) auto;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.cta-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: var(--space-lg);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
src/lib/components/home/HowWeWork.svelte
Normal file
14
src/lib/components/home/HowWeWork.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { howIWorkItems } from '$lib/data/home/content';
|
||||
</script>
|
||||
|
||||
<section id="process" class="section" aria-labelledby="process-heading">
|
||||
<div class="container">
|
||||
<h2 id="process-heading" class="section-title">How I Work</h2>
|
||||
<ul class="content-list">
|
||||
{#each howIWorkItems as item (item)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { impactItems } from '$lib/data/content';
|
||||
import { impactItems } from '$lib/data/home/content';
|
||||
</script>
|
||||
|
||||
<section id="impact" class="section" aria-labelledby="impact-heading">
|
||||
25
src/lib/components/home/WhatWeDo.svelte
Normal file
25
src/lib/components/home/WhatWeDo.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
whatIHelpTeamsFixIntro,
|
||||
whatIHelpTeamsFixItems,
|
||||
} from '$lib/data/home/content';
|
||||
</script>
|
||||
|
||||
<section id="what-i-help-fix" class="section" aria-labelledby="what-i-help-fix-heading">
|
||||
<div class="container">
|
||||
<h2 id="what-i-help-fix-heading" class="section-title">What I Help Teams Fix</h2>
|
||||
<p class="what-fix-intro">{whatIHelpTeamsFixIntro}</p>
|
||||
<ul class="content-list">
|
||||
{#each whatIHelpTeamsFixItems as item (item)}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.what-fix-intro {
|
||||
max-width: var(--max-text-width);
|
||||
margin: 0 auto var(--space-lg) auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user