commit 4bc96abf4a1fe881ec577b9d0274bebce5ca1ea3 Author: mifi Date: Fri Feb 6 15:28:27 2026 -0300 Initial commit diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc new file mode 100644 index 0000000..379ac23 --- /dev/null +++ b/.cursor/rules/project.mdc @@ -0,0 +1,10 @@ +--- +description: mifi.dev landing – stack and conventions +globs: ["**/*"] +--- + +- Use **pnpm** only (no npm/yarn). **TypeScript** everywhere. +- Content is **JSON** in `src/lib/data/`; load at build time in `+page.server.ts`. No client-side data fetching. +- **CSP** is set via Traefik; do not add CSP in app code. No unsafe-inline scripts. +- Target **WCAG 2.2 AAA** and semantic HTML; use JSON-LD for SEO. +- **Dev container** and **CI** use the same Linux + Playwright Chromium for consistent e2e snapshots. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e0fdf24 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +{ + "name": "mifi Links", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "22", + "nodeGypDependencies": true + }, + "ghcr.io/devcontainers/features/git:1": {} + }, + "postCreateCommand": "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm exec playwright install chromium --with-deps", + "customizations": { + "vscode": { + "extensions": [ + "svelte.svelte-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss" + ] + } + }, + "remoteUser": "node", + "forwardPorts": [5173, 4173] +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a9edf52 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,12 @@ +node_modules +build +.svelte-kit +package +coverage +playwright-report +test-results +*.config.js +*.config.cjs +*.config.mjs +src/**/*.test.ts +src/**/*.spec.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..42bd5f2 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,32 @@ +module.exports = { + root: true, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:svelte/recommended', + 'prettier', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + extraFileExtensions: ['.svelte'], + }, + plugins: ['@typescript-eslint'], + overrides: [ + { + files: ['*.svelte'], + parser: 'svelte-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + }, + }, + ], + env: { + browser: true, + es2022: true, + node: true, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0afbf0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store +coverage +playwright-report +test-results +playwright/.cache diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2ecd787 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +build +.svelte-kit +package +coverage +playwright-report +test-results diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3a79856 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": false, + "tabWidth": 4, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..b485f3a --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,13 @@ +steps: + build: + image: node:22-bookworm-slim + commands: + - corepack enable + - corepack prepare pnpm@latest --activate + - pnpm install --frozen-lockfile + - pnpm run lint + - pnpm run check + - pnpm run test:run + - pnpm run build + - pnpm exec playwright install chromium --with-deps + - pnpm run test:e2e diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd1e647 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Agent guidance for mifi.dev landing + +## Purpose + +This repo is a **one-page static** Linktree-style site for mifi.dev. It is **not** a SPA: output is pure HTML/CSS/JS with no client-side framework runtime. Minimal JS (theme toggle, copy-link, a11y, copyright year) is allowed. + +## Stack (locked) + +- **SvelteKit** with `@sveltejs/adapter-static` (static prerender only) +- **TypeScript** (always) +- **pnpm** (only); do not use npm or yarn +- **PostCSS** for CSS (nesting + preset-env) +- **Critical CSS** via post-build script (`scripts/critical-css.mjs`); full build with critical CSS is `pnpm run build:full` (requires Chromium) +- **Content:** JSON in `src/lib/data/` (e.g. `links.json`), loaded in `+page.server.ts` at build time +- **CSP:** Set by Traefik middleware; do not add CSP in app code + +## Conventions + +- Use **semantic HTML** and **JSON-LD** for SEO; target **WCAG 2.2 AAA** for accessibility. +- **No unsafe-inline** scripts; all JS via ` +
%sveltekit.body%
+ + diff --git a/src/lib/components/ContactPanel.svelte b/src/lib/components/ContactPanel.svelte new file mode 100644 index 0000000..0de6106 --- /dev/null +++ b/src/lib/components/ContactPanel.svelte @@ -0,0 +1,71 @@ + + + + {#snippet children()} + + {/snippet} + + + diff --git a/src/lib/components/Footer.svelte b/src/lib/components/Footer.svelte new file mode 100644 index 0000000..6013878 --- /dev/null +++ b/src/lib/components/Footer.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/components/Hero.svelte b/src/lib/components/Hero.svelte new file mode 100644 index 0000000..c539d40 --- /dev/null +++ b/src/lib/components/Hero.svelte @@ -0,0 +1,293 @@ + + +
+ {#if profileImage} + + {/if} +
+ {#if showContactButton} + + {/if} + +
+
+ +
+
+ {#if profileImage} +
+ {person?.name ?? 'mifi'} profile +
+ {/if} +
+

mifi

+

+ Mike Fitzpatrick +

+ {#if pronunciation || pronouns || location} +

+ {#if pronunciation}{pronunciation}{/if} + {#if pronunciation && pronouns} + {/if} + {#if pronouns}{pronouns}{/if} + {#if pronouns && location} + {#if (heroLayout ?? HeroLayout.SIDE_BY_SIDE) === HeroLayout.SIDE_BY_SIDE} +
+ {:else} + + {/if} + {/if} + {#if location}{location}{/if} +

+ {/if} +
+
+ {#if showContactButton} + + {/if} + +
+
+
+ + diff --git a/src/lib/components/Link.svelte b/src/lib/components/Link.svelte new file mode 100644 index 0000000..d445e4d --- /dev/null +++ b/src/lib/components/Link.svelte @@ -0,0 +1,80 @@ + + + + + + + + diff --git a/src/lib/components/LinkGroup.svelte b/src/lib/components/LinkGroup.svelte new file mode 100644 index 0000000..888fc56 --- /dev/null +++ b/src/lib/components/LinkGroup.svelte @@ -0,0 +1,58 @@ + + +
+ {#if showHeading}{/if} + +
+ + diff --git a/src/lib/components/LinkIcon.svelte b/src/lib/components/LinkIcon.svelte new file mode 100644 index 0000000..56de885 --- /dev/null +++ b/src/lib/components/LinkIcon.svelte @@ -0,0 +1,60 @@ + + + + + diff --git a/src/lib/components/Panel.svelte b/src/lib/components/Panel.svelte new file mode 100644 index 0000000..00414f6 --- /dev/null +++ b/src/lib/components/Panel.svelte @@ -0,0 +1,169 @@ + + + { + e.preventDefault(); + onclose(); + }} +> +
+
+

+ {#if IconComponent} + + {/if} + {title} +

+ +
+
+ {@render children?.()} +
+
+
+ + diff --git a/src/lib/components/SharePanel.svelte b/src/lib/components/SharePanel.svelte new file mode 100644 index 0000000..b1e32ef --- /dev/null +++ b/src/lib/components/SharePanel.svelte @@ -0,0 +1,100 @@ + + + + {#snippet children()} + + {/snippet} + + + diff --git a/src/lib/components/ThemeToggle.svelte b/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..d43ecdc --- /dev/null +++ b/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,157 @@ + + +{#if expanded} + + +{/if} + +
+
+ + + +
+
+ + diff --git a/src/lib/components/icons/IconCal.svelte b/src/lib/components/icons/IconCal.svelte new file mode 100644 index 0000000..6707acf --- /dev/null +++ b/src/lib/components/icons/IconCal.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/lib/components/icons/IconContact.svelte b/src/lib/components/icons/IconContact.svelte new file mode 100644 index 0000000..8e04073 --- /dev/null +++ b/src/lib/components/icons/IconContact.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/components/icons/IconCopy.svelte b/src/lib/components/icons/IconCopy.svelte new file mode 100644 index 0000000..e98d0c9 --- /dev/null +++ b/src/lib/components/icons/IconCopy.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/icons/IconDiscord.svelte b/src/lib/components/icons/IconDiscord.svelte new file mode 100644 index 0000000..5381e4a --- /dev/null +++ b/src/lib/components/icons/IconDiscord.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconEmail.svelte b/src/lib/components/icons/IconEmail.svelte new file mode 100644 index 0000000..cdac8ab --- /dev/null +++ b/src/lib/components/icons/IconEmail.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/icons/IconFacebook.svelte b/src/lib/components/icons/IconFacebook.svelte new file mode 100644 index 0000000..3db7125 --- /dev/null +++ b/src/lib/components/icons/IconFacebook.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconFlickr.svelte b/src/lib/components/icons/IconFlickr.svelte new file mode 100644 index 0000000..f2e4594 --- /dev/null +++ b/src/lib/components/icons/IconFlickr.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconGitHub.svelte b/src/lib/components/icons/IconGitHub.svelte new file mode 100644 index 0000000..eb386ec --- /dev/null +++ b/src/lib/components/icons/IconGitHub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconInstagram.svelte b/src/lib/components/icons/IconInstagram.svelte new file mode 100644 index 0000000..27eef25 --- /dev/null +++ b/src/lib/components/icons/IconInstagram.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconLink.svelte b/src/lib/components/icons/IconLink.svelte new file mode 100644 index 0000000..947fd26 --- /dev/null +++ b/src/lib/components/icons/IconLink.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/icons/IconLinkedIn.svelte b/src/lib/components/icons/IconLinkedIn.svelte new file mode 100644 index 0000000..33ed9ba --- /dev/null +++ b/src/lib/components/icons/IconLinkedIn.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconMi.svelte b/src/lib/components/icons/IconMi.svelte new file mode 100644 index 0000000..3ffe6a9 --- /dev/null +++ b/src/lib/components/icons/IconMi.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconResume.svelte b/src/lib/components/icons/IconResume.svelte new file mode 100644 index 0000000..e2dad9d --- /dev/null +++ b/src/lib/components/icons/IconResume.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/lib/components/icons/IconShare.svelte b/src/lib/components/icons/IconShare.svelte new file mode 100644 index 0000000..360ced3 --- /dev/null +++ b/src/lib/components/icons/IconShare.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/icons/IconSnapchat.svelte b/src/lib/components/icons/IconSnapchat.svelte new file mode 100644 index 0000000..3edc154 --- /dev/null +++ b/src/lib/components/icons/IconSnapchat.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconStrava.svelte b/src/lib/components/icons/IconStrava.svelte new file mode 100644 index 0000000..a793ebb --- /dev/null +++ b/src/lib/components/icons/IconStrava.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconTikTok.svelte b/src/lib/components/icons/IconTikTok.svelte new file mode 100644 index 0000000..a85971c --- /dev/null +++ b/src/lib/components/icons/IconTikTok.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/icons/IconYouTube.svelte b/src/lib/components/icons/IconYouTube.svelte new file mode 100644 index 0000000..87fe392 --- /dev/null +++ b/src/lib/components/icons/IconYouTube.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts new file mode 100644 index 0000000..6d01ae2 --- /dev/null +++ b/src/lib/config.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { + OWN_PROPERTY_HOSTS, + VARIANT_HOSTS, + GA_MEASUREMENT_IDS, + UTM_MEDIUM, + UTM_CAMPAIGN, + appendUtmParams, +} from './config'; + +describe('config', () => { + describe('constants', () => { + it('OWN_PROPERTY_HOSTS includes expected hostnames', () => { + expect(OWN_PROPERTY_HOSTS).toContain('mifi.dev'); + expect(OWN_PROPERTY_HOSTS).toContain('mifi.bio'); + expect(OWN_PROPERTY_HOSTS).toContain('mifi.ventures'); + expect(OWN_PROPERTY_HOSTS).toContain('cal.mifi.ventures'); + }); + + it('VARIANT_HOSTS maps dev and bio', () => { + expect(VARIANT_HOSTS.dev).toBe('mifi.dev'); + expect(VARIANT_HOSTS.bio).toBe('mifi.bio'); + }); + + it('GA_MEASUREMENT_IDS has dev and bio', () => { + expect(GA_MEASUREMENT_IDS.dev).toMatch(/^G-[A-Z0-9]+$/); + expect(GA_MEASUREMENT_IDS.bio).toMatch(/^G-[A-Z0-9]+$/); + }); + + it('UTM_MEDIUM and UTM_CAMPAIGN are set', () => { + expect(UTM_MEDIUM).toBe('link'); + expect(UTM_CAMPAIGN).toBe('landing'); + }); + }); + + describe('appendUtmParams', () => { + it('appends utm params to own-property URL', () => { + const href = 'https://mifi.dev/page'; + const result = appendUtmParams(href, 'mifi.dev'); + const url = new URL(result); + expect(url.searchParams.get('utm_source')).toBe('mifi.dev'); + expect(url.searchParams.get('utm_medium')).toBe('link'); + expect(url.searchParams.get('utm_campaign')).toBe('landing'); + expect(url.origin).toBe('https://mifi.dev'); + }); + + it('appends utm_content when provided', () => { + const href = 'https://mifi.bio/'; + const result = appendUtmParams(href, 'mifi.bio', 'hero'); + const url = new URL(result); + expect(url.searchParams.get('utm_content')).toBe('hero'); + }); + + it('preserves existing query params and adds UTM', () => { + const href = 'https://mifi.dev/page?foo=bar'; + const result = appendUtmParams(href, 'mifi.dev'); + const url = new URL(result); + expect(url.searchParams.get('foo')).toBe('bar'); + expect(url.searchParams.get('utm_source')).toBe('mifi.dev'); + }); + + it('returns href unchanged for non-own-property host', () => { + const href = 'https://example.com/page'; + expect(appendUtmParams(href, 'mifi.dev')).toBe(href); + expect(appendUtmParams('https://github.com/the-mifi', 'mifi.dev')).toBe( + 'https://github.com/the-mifi', + ); + }); + + it('matches hostname case-insensitively', () => { + const result = appendUtmParams('https://MIFI.DEV/path', 'mifi.dev'); + const url = new URL(result); + expect(url.searchParams.get('utm_source')).toBe('mifi.dev'); + }); + + it('uses base for relative href', () => { + const result = appendUtmParams('/about', 'mifi.dev'); + const url = new URL(result); + expect(url.hostname).toBe('mifi.dev'); + expect(url.pathname).toBe('/about'); + expect(url.searchParams.get('utm_source')).toBe('mifi.dev'); + }); + }); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..abe3e85 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,43 @@ +/** + * App config: own-property hostnames for UTM attribution, variant hostnames, GA IDs. + */ + +export const OWN_PROPERTY_HOSTS = [ + 'mifi.ventures', + 'cal.mifi.ventures', + 'mifi.dev', + 'mifi.bio', +] as const; + +export const VARIANT_HOSTS: Record<'dev' | 'bio', string> = { + dev: 'mifi.dev', + bio: 'mifi.bio', +}; + +export const GA_MEASUREMENT_IDS: Record<'dev' | 'bio', string> = { + dev: 'G-P8V832WDM8', + bio: 'G-885B0KYWZ1', +}; + +export const UTM_MEDIUM = 'link'; +export const UTM_CAMPAIGN = 'landing'; + +/** + * Returns href with UTM params appended if the URL's host is an own property. + */ +export function appendUtmParams(href: string, sourceHost: string, utmContent?: string): string { + try { + const url = new URL(href, 'https://mifi.dev'); + const hostname = url.hostname.toLowerCase(); + if (!OWN_PROPERTY_HOSTS.some((h) => hostname === h)) return href; + const params = new URLSearchParams(url.search); + params.set('utm_source', sourceHost); + params.set('utm_medium', UTM_MEDIUM); + params.set('utm_campaign', UTM_CAMPAIGN); + if (utmContent) params.set('utm_content', utmContent); + url.search = params.toString(); + return url.toString(); + } catch { + return href; + } +} diff --git a/src/lib/data/constants.test.ts b/src/lib/data/constants.test.ts new file mode 100644 index 0000000..bd7ffcc --- /dev/null +++ b/src/lib/data/constants.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { ContentVariant, HeroLayout, AvatarVariant } from './constants'; + +describe('constants', () => { + describe('ContentVariant', () => { + it('has bio and dev', () => { + expect(ContentVariant.BIO).toBe('bio'); + expect(ContentVariant.DEV).toBe('dev'); + }); + + it('values are string literals usable as variant keys', () => { + const variants: ContentVariant[] = [ContentVariant.DEV, ContentVariant.BIO]; + expect(variants).toContain('dev'); + expect(variants).toContain('bio'); + }); + }); + + describe('HeroLayout', () => { + it('has stack and side-by-side', () => { + expect(HeroLayout.STACK).toBe('stack'); + expect(HeroLayout.SIDE_BY_SIDE).toBe('side-by-side'); + }); + }); + + describe('AvatarVariant', () => { + it('has classic and tropical', () => { + expect(AvatarVariant.CLASSIC).toBe('classic-mifi'); + expect(AvatarVariant.TROPICAL).toBe('tropical-mifi'); + }); + }); +}); diff --git a/src/lib/data/constants.ts b/src/lib/data/constants.ts new file mode 100644 index 0000000..c344c9b --- /dev/null +++ b/src/lib/data/constants.ts @@ -0,0 +1,14 @@ +export enum ContentVariant { + BIO = 'bio', + DEV = 'dev', +} + +export enum HeroLayout { + STACK = 'stack', + SIDE_BY_SIDE = 'side-by-side', +} + +export enum AvatarVariant { + CLASSIC = 'classic-mifi', + TROPICAL = 'tropical-mifi', +} diff --git a/src/lib/data/links.json b/src/lib/data/links.json new file mode 100644 index 0000000..7220d7d --- /dev/null +++ b/src/lib/data/links.json @@ -0,0 +1,218 @@ +{ + "siteByVariant": { + "dev": { + "title": "mifi.dev — the homepage of the professional Mike Fitzpatrick", + "metaDescription": "Professional links and profiles for mifi – consultancy, code, and contact.", + "url": "https://mifi.dev", + "heroLayout": "side-by-side", + "profileImage": "classic-mifi", + "pronunciation": "MY-fy (/ˈmaɪˌfaɪ/)", + "pronouns": "he/him", + "location": "Boston, MA", + "person": { + "name": "mifi", + "sameAs": [ + "https://mifi.ventures", + "https://cal.mifi.ventures/the-mifi", + "https://www.linkedin.com/in/the-mifi", + "https://github.com/the-mifi", + "https://git.mifi.dev/mifi" + ] + }, + "linksHeading": "Professional Links and Profiles", + "showContact": true, + "qrCodeImage": null + }, + "bio": { + "title": "mifi.bio — the homepage of the human Mike Fitzpatrick", + "metaDescription": "Links and profiles for mifi – professional, personal, and everything in between.", + "url": "https://mifi.bio", + "heroLayout": "side-by-side", + "profileImage": "tropical-mifi", + "pronunciation": "MY-fy (/ˈmaɪˌfaɪ/)", + "pronouns": "he/him/mifi", + "location": "Vitória, ES, Brasil", + "person": { + "name": "mifi", + "sameAs": [ + "https://mifi.ventures", + "https://www.linkedin.com/in/the-mifi", + "https://github.com/the-mifi", + "https://git.mifi.dev/mifi", + "https://www.instagram.com/the.mifi", + "https://www.instagram.com/mifi.no.brasil", + "https://facebook.com/mifi79", + "https://youtube.com/@the-real-mifi", + "https://www.tiktok.com/@the.mifi", + "https://www.snapchat.com/add/the.mifi", + "https://www.discord.com/users/the_mifi", + "https://www.strava.com/athletes/the-mifi", + "https://flickr.com/people/michael-gerard" + ] + }, + "linksHeading": "Links and Profiles", + "showContact": false, + "qrCodeImage": null + } + }, + "contactLinks": [ + { + "href": "https://cal.mifi.ventures/the-mifi", + "icon": "Calendar", + "label": "Book a meeting", + "description": "Book time. No games, no gatekeeping.", + "utmContent": "contact-panel", + "variants": ["dev", "bio"] + } + ], + "sections": [ + { + "id": "professional", + "title": "Professional / Code", + "order": { + "bio": 2, + "dev": 0 + }, + "links": [ + { + "href": "https://mifi.ventures", + "icon": "Mifi", + "label": "mifi Ventures", + "description": "The LLC. Where the real work happens (and the invoices get paid).", + "utmContent": "mifi-ventures", + "variants": ["dev", "bio"] + }, + { + "href": "https://cal.mifi.ventures/the-mifi", + "icon": "Calendar", + "label": "Cal.com", + "description": "Book time. No games, no gatekeeping.", + "utmContent": "cal", + "variants": [] + }, + { + "href": "https://github.com/the-mifi", + "label": "GitHub", + "description": "Code, commits, and the occasional typo in prod.", + "variants": ["dev", "bio"] + } + ] + }, + { + "id": "professional-link-site", + "title": "Professional Link Site", + "order": { + "bio": 2, + "dev": null + }, + "links": [ + { + "href": "https://mifi.dev", + "icon": "Mifi", + "label": "mifi.dev", + "description": "The professional side. Suits optional.", + "utmContent": "mifi-dev", + "variants": ["bio"] + } + ] + }, + { + "id": "resume", + "title": "Resumes", + "order": { + "bio": 3, + "dev": 1 + }, + "links": [ + { + "href": "https://mifi.dev/downloads/resume-2026c.pdf", + "icon": "Resume", + "label": "Contract", + "description": "Need an engineering gun-for-hire? I do that.", + "utmContent": "resume-c", + "variants": ["dev", "bio"] + }, + { + "href": "https://mifi.dev/downloads/resume-2026p.pdf", + "icon": "Resume", + "label": "Permanent", + "description": "I'm open to dedicated, long-term engagements, too.", + "utmContent": "resume-p", + "variants": ["dev", "bio"] + } + ] + }, + { + "id": "social", + "title": "Social", + "order": { + "bio": 0, + "dev": 1 + }, + "links": [ + { + "href": "https://www.discord.com/users/the_mifi", + "label": "Discord", + "description": "Where I lurk when I should be working.", + "variants": ["bio"] + }, + { + "href": "https://facebook.com/mifi79", + "label": "Facebook", + "description": "Yes, I'm still here. Don't @ me, poke me, or whatever we're doing these days", + "variants": ["bio"] + }, + { + "href": "https://flickr.com/people/michael-gerard", + "label": "Flickr", + "description": "Where I used tostore my photos and videos... an archive of the ancient past.", + "variants": ["bio"] + }, + { + "href": "https://www.instagram.com/the.mifi", + "icon": "Instagram", + "label": "Instagram (US)", + "description": "Visual diary. Update frequency: whenever I remember.", + "variants": ["bio"] + }, + { + "href": "https://www.instagram.com/mifi.no.brasil", + "icon": "Instagram", + "label": "Instagram (Brazil)", + "description": "American recipes, Portuguese practice, and Reel Time chaos.", + "variants": ["bio"] + }, + { + "href": "https://www.linkedin.com/in/the-mifi", + "label": "LinkedIn", + "description": "Where I pretend to be professional (it's mostly true).", + "variants": ["dev", "bio"] + }, + { + "href": "https://www.snapchat.com/add/the.mifi", + "label": "Snapchat", + "description": "Ephemeral nonsense. You know the deal.", + "variants": ["bio"] + }, + { + "href": "https://www.strava.com/athletes/the-mifi", + "label": "Strava", + "description": "Where I track my rides and other outdoor activities.", + "variants": ["bio"] + }, + { + "href": "https://www.tiktok.com/@the.mifi", + "label": "TikTok", + "description": "Short-form chaos. You've been warned.", + "variants": ["bio"] + }, + { + "href": "https://youtube.com/@the-real-mifi", + "label": "YouTube", + "description": "Drones, vibes, and the occasional crash.", + "variants": ["bio"] + } + ] + } + ] +} diff --git a/src/lib/data/types.test.ts b/src/lib/data/types.test.ts new file mode 100644 index 0000000..e5009e0 --- /dev/null +++ b/src/lib/data/types.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import type { ContentVariant, Link, ProcessedLink, Site, Section, ContentData } from './types'; +import { ContentVariant as ContentVariantEnum } from './constants'; + +/** + * Runtime shape checks for types. TypeScript types are erased at runtime; + * these tests ensure our fixtures and expected structures match the documented shape. + */ +describe('types (runtime shape)', () => { + it('ContentVariant type aligns with constants', () => { + const variants: ContentVariant[] = [ContentVariantEnum.DEV, ContentVariantEnum.BIO]; + expect(variants).toEqual(['dev', 'bio']); + }); + + it('Link has required fields and variants array', () => { + const link: Link = { + href: 'https://example.com', + label: 'Example', + variants: ['dev'], + }; + expect(link).toHaveProperty('href'); + expect(link).toHaveProperty('label'); + expect(link).toHaveProperty('variants'); + expect(Array.isArray(link.variants)).toBe(true); + }); + + it('ProcessedLink omits variants and utmContent', () => { + const processed: ProcessedLink = { + href: 'https://example.com', + label: 'Example', + }; + expect(processed).toHaveProperty('href'); + expect(processed).toHaveProperty('label'); + expect(processed).not.toHaveProperty('variants'); + expect(processed).not.toHaveProperty('utmContent'); + }); + + it('Site has required fields', () => { + const site: Site = { + title: 'Test', + metaDescription: 'Desc', + url: 'https://mifi.dev', + }; + expect(site).toHaveProperty('title'); + expect(site).toHaveProperty('metaDescription'); + expect(site).toHaveProperty('url'); + }); + + it('Section has id, title, order, links', () => { + const section: Section = { + id: 'test', + title: 'Test Section', + order: { dev: 0, bio: 1 }, + links: [], + }; + expect(section).toHaveProperty('id'); + expect(section).toHaveProperty('title'); + expect(section).toHaveProperty('order'); + expect(section).toHaveProperty('links'); + expect(section.order).toHaveProperty('dev'); + expect(section.order).toHaveProperty('bio'); + }); + + it('ContentData has siteByVariant, contactLinks, sections', () => { + const data: ContentData = { + siteByVariant: { dev: {} as Site, bio: {} as Site }, + contactLinks: [], + sections: [], + }; + expect(data).toHaveProperty('siteByVariant'); + expect(data).toHaveProperty('contactLinks'); + expect(data).toHaveProperty('sections'); + expect(data.siteByVariant).toHaveProperty('dev'); + expect(data.siteByVariant).toHaveProperty('bio'); + }); +}); diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts new file mode 100644 index 0000000..50fe299 --- /dev/null +++ b/src/lib/data/types.ts @@ -0,0 +1,65 @@ +import { + ContentVariant as ContentVariantEnum, + HeroLayout as HeroLayoutEnum, + AvatarVariant as AvatarVariantEnum, +} from './constants'; + +import type { IconName } from '$lib/components/LinkIcon.svelte'; + +/** + * Content types for links.json. Used at build time in +layout.server.ts. + */ + +export type ContentVariant = `${ContentVariantEnum}`; + +export type HeroLayout = `${HeroLayoutEnum}`; +export type ProfileImageName = `${AvatarVariantEnum}`; + +export interface Site { + title: string; + metaDescription: string; + url: string; + heroLayout?: HeroLayout; + profileImage?: ProfileImageName; + pronunciation?: string; + pronouns?: string; + location?: string; + person?: { + name: string; + sameAs: string[]; + }; + linksHeading?: string; + /** If false, hide Contact button and panel for this variant. Default true. */ + showContact?: boolean; + /** Contact panel links; if omitted, first section links are used. */ + contactLinks?: ContactLink[]; + /** Optional QR code image path (e.g. /assets/images/qr-mifi-dev.png) for Share panel. */ + qrCodeImage?: string | null; +} + +export interface Link { + href: string; + icon?: IconName; + label: string; + description?: string; + variants: ContentVariant[]; + utmContent?: string; +} + +export type ContactLink = Link; + +export type ProcessedLink = Omit; + +export interface Section { + id: string; + title: string; + /** The zero-based order of the links in the section. If null, the section is not shown for that variant. */ + order: Record; + links: Link[]; +} + +export interface ContentData { + siteByVariant: Record; + contactLinks: ContactLink[]; + sections: Section[]; +} diff --git a/src/lib/fonts.css b/src/lib/fonts.css new file mode 100644 index 0000000..5d532db --- /dev/null +++ b/src/lib/fonts.css @@ -0,0 +1,67 @@ +/** + * Self-hosted fonts in static/assets/fonts/ (Google Fonts–style filenames). + * Plus Jakarta Sans 700 (wordmark), Fraunces 500 (headings), Inter 400/500/600 (body). + * + * Wordmark fi ligature: Google’s latin woff2 subsets often omit GSUB ligature tables. + * Re-subset the full Bold TTF/OTF with ligatures kept (see README “Fonts” section): + * pyftsubset /path/to/PlusJakartaSans-Bold.ttf --output-file=static/assets/fonts/plus-jakarta-sans-700-liga.woff2 + * --flavor=woff2 --layout-features='liga','clig' --unicodes='U+0020-007F,U+00A0-00FF,U+FB01,U+FB02' + * Then change the url() below to plus-jakarta-sans-700-liga.woff2. + */ + +@font-face { + font-family: 'Plus Jakarta Sans'; + src: url('/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Plus Jakarta Sans'; + src: url('/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Plus Jakarta Sans'; + src: url('/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Fraunces; + src: url('/assets/fonts/fraunces-variable-opsz-wght.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; + font-optical-sizing: auto; +} + +@font-face { + font-family: Inter; + src: url('/assets/fonts/inter-v20-latin-regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Inter; + src: url('/assets/fonts/inter-v20-latin-500.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Inter; + src: url('/assets/fonts/inter-v20-latin-600.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts new file mode 100644 index 0000000..ccdcaf7 --- /dev/null +++ b/src/lib/theme.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getStoredTheme, setTheme } from './theme'; + +describe('theme', () => { + const STORAGE_KEY = 'mifi-theme'; + let localStorageMock: Record; + + beforeEach(() => { + localStorageMock = {}; + vi.stubGlobal('localStorage', { + getItem: (key: string) => localStorageMock[key] ?? null, + setItem: (key: string, value: string) => { + localStorageMock[key] = value; + }, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }); + vi.stubGlobal('document', { + documentElement: { + setAttribute: vi.fn(), + removeAttribute: vi.fn(), + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('getStoredTheme', () => { + it('returns "auto" when window is undefined', () => { + const originalWindow = globalThis.window; + // @ts-expect-error unsetting for test + delete globalThis.window; + expect(getStoredTheme()).toBe('auto'); + globalThis.window = originalWindow; + }); + + it('returns stored value when it is light, dark, or auto', () => { + vi.stubGlobal('window', {}); + localStorageMock[STORAGE_KEY] = 'light'; + expect(getStoredTheme()).toBe('light'); + localStorageMock[STORAGE_KEY] = 'dark'; + expect(getStoredTheme()).toBe('dark'); + localStorageMock[STORAGE_KEY] = 'auto'; + expect(getStoredTheme()).toBe('auto'); + }); + + it('returns "auto" when stored value is invalid', () => { + vi.stubGlobal('window', {}); + localStorageMock[STORAGE_KEY] = 'invalid'; + expect(getStoredTheme()).toBe('auto'); + localStorageMock[STORAGE_KEY] = ''; + expect(getStoredTheme()).toBe('auto'); + }); + + it('returns "auto" when nothing is stored', () => { + vi.stubGlobal('window', {}); + expect(getStoredTheme()).toBe('auto'); + }); + }); + + describe('setTheme', () => { + it('does nothing when window is undefined', () => { + const originalWindow = globalThis.window; + // @ts-expect-error unsetting for test + delete globalThis.window; + expect(() => setTheme('light')).not.toThrow(); + globalThis.window = originalWindow; + }); + + it('sets localStorage and data-theme for light and dark', () => { + vi.stubGlobal('window', {}); + setTheme('light'); + expect(localStorageMock[STORAGE_KEY]).toBe('light'); + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'data-theme', + 'light', + ); + setTheme('dark'); + expect(localStorageMock[STORAGE_KEY]).toBe('dark'); + expect(document.documentElement.setAttribute).toHaveBeenCalledWith( + 'data-theme', + 'dark', + ); + }); + + it('removes data-theme for auto', () => { + vi.stubGlobal('window', {}); + setTheme('auto'); + expect(localStorageMock[STORAGE_KEY]).toBe('auto'); + expect(document.documentElement.removeAttribute).toHaveBeenCalledWith('data-theme'); + }); + }); +}); diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..a7ef2d1 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,25 @@ +/** + * Theme: light | dark | auto. Stored in localStorage as 'mifi-theme'. + * Default is auto (no attribute; CSS uses prefers-color-scheme). + */ + +export type ThemeMode = 'light' | 'dark' | 'auto'; + +const STORAGE_KEY = 'mifi-theme'; + +export function getStoredTheme(): ThemeMode { + if (typeof window === 'undefined') return 'auto'; + const t = localStorage.getItem(STORAGE_KEY); + if (t === 'light' || t === 'dark' || t === 'auto') return t; + return 'auto'; +} + +export function setTheme(mode: ThemeMode): void { + if (typeof window === 'undefined') return; + localStorage.setItem(STORAGE_KEY, mode); + if (mode === 'light' || mode === 'dark') { + document.documentElement.setAttribute('data-theme', mode); + } else { + document.documentElement.removeAttribute('data-theme'); + } +} diff --git a/src/lib/utils/getProcessedLinks.test.ts b/src/lib/utils/getProcessedLinks.test.ts new file mode 100644 index 0000000..eeb6ab3 --- /dev/null +++ b/src/lib/utils/getProcessedLinks.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getProcessedLinks } from './getProcessedLinks'; +import type { ContentVariant, Link } from '$lib/data/types'; + +vi.mock('$lib/config', () => ({ + appendUtmParams: vi.fn((href: string, _sourceHost: string, utmContent?: string) => { + const url = new URL(href, 'https://mifi.dev'); + if (utmContent) url.searchParams.set('utm_content', utmContent); + return url.toString(); + }), + VARIANT_HOSTS: { dev: 'mifi.dev', bio: 'mifi.bio' }, +})); + +function link(overrides: Partial & { variants: ContentVariant[] }): Link { + return { + href: 'https://example.com', + label: 'Example', + variants: ['dev', 'bio'], + ...overrides, + }; +} + +describe('getProcessedLinks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty array when links is empty', () => { + expect(getProcessedLinks([], 'dev')).toEqual([]); + expect(getProcessedLinks([], 'bio')).toEqual([]); + }); + + it('filters out links that do not include the variant', () => { + const links: Link[] = [ + link({ label: 'Dev only', variants: ['dev'] }), + link({ label: 'Bio only', variants: ['bio'] }), + ]; + expect(getProcessedLinks(links, 'dev')).toHaveLength(1); + expect(getProcessedLinks(links, 'dev')[0].label).toBe('Dev only'); + expect(getProcessedLinks(links, 'bio')).toHaveLength(1); + expect(getProcessedLinks(links, 'bio')[0].label).toBe('Bio only'); + }); + + it('maps each link to ProcessedLink (href, icon, label, description) without variants or utmContent', () => { + const links: Link[] = [ + link({ + href: 'https://mifi.dev/blog', + label: 'Blog', + description: 'My blog', + icon: 'Mifi', + variants: ['dev'], + utmContent: 'nav', + }), + ]; + const result = getProcessedLinks(links, 'dev'); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + label: 'Blog', + description: 'My blog', + icon: 'Mifi', + }); + expect(result[0]).not.toHaveProperty('variants'); + expect(result[0]).not.toHaveProperty('utmContent'); + expect(result[0].href).toContain('https://'); + expect(result[0].href).toContain('utm_content=nav'); + }); + + it('uses getValueForVariant for icon, label, and description (per-variant values)', () => { + const links: Link[] = [ + link({ + label: { dev: 'Dev label', bio: 'Bio label' } as unknown as string, + description: { dev: 'Dev desc', bio: 'Bio desc' } as unknown as string, + icon: { dev: 'GitHub', bio: 'LinkedIn' } as unknown as Link['icon'], + variants: ['dev', 'bio'], + }), + ]; + const devResult = getProcessedLinks(links, 'dev'); + const bioResult = getProcessedLinks(links, 'bio'); + expect(devResult[0].label).toBe('Dev label'); + expect(devResult[0].description).toBe('Dev desc'); + expect(devResult[0].icon).toBe('GitHub'); + expect(bioResult[0].label).toBe('Bio label'); + expect(bioResult[0].description).toBe('Bio desc'); + expect(bioResult[0].icon).toBe('LinkedIn'); + }); + + it('passes sourceHost from VARIANT_HOSTS to appendUtmParams', async () => { + const { appendUtmParams } = await import('$lib/config'); + const links: Link[] = [link({ href: 'https://mifi.dev/x', variants: ['dev', 'bio'] })]; + getProcessedLinks(links, 'dev'); + expect(appendUtmParams).toHaveBeenCalledWith('https://mifi.dev/x', 'mifi.dev', undefined); + vi.clearAllMocks(); + getProcessedLinks(links, 'bio'); + expect(appendUtmParams).toHaveBeenCalledWith('https://mifi.dev/x', 'mifi.bio', undefined); + }); + + it('passes utmContent to appendUtmParams when present', async () => { + const { appendUtmParams } = await import('$lib/config'); + const links: Link[] = [link({ utmContent: 'hero', variants: ['dev'] })]; + getProcessedLinks(links, 'dev'); + expect(appendUtmParams).toHaveBeenCalledWith(expect.any(String), 'mifi.dev', 'hero'); + }); +}); diff --git a/src/lib/utils/getProcessedLinks.ts b/src/lib/utils/getProcessedLinks.ts new file mode 100644 index 0000000..d09c7b8 --- /dev/null +++ b/src/lib/utils/getProcessedLinks.ts @@ -0,0 +1,17 @@ +import { appendUtmParams, VARIANT_HOSTS } from '$lib/config'; +import type { ContentVariant } from '$lib/data/constants'; +import type { Link, ProcessedLink } from '$lib/data/types'; +import { getValueForVariant } from './getValueForVariant'; + +import { isShowForVariant } from './isShowForVariant'; + +export const getProcessedLinks = (links: Link[], variant: ContentVariant): ProcessedLink[] => { + const sourceHost = VARIANT_HOSTS[variant]; + + return links.filter(isShowForVariant(variant)).map((link) => ({ + href: appendUtmParams(link.href, sourceHost, link.utmContent), + icon: getValueForVariant(link.icon, variant), + label: getValueForVariant(link.label, variant) as string, + description: getValueForVariant(link.description, variant), + })); +}; diff --git a/src/lib/utils/getValueForVariant.test.ts b/src/lib/utils/getValueForVariant.test.ts new file mode 100644 index 0000000..03532e5 --- /dev/null +++ b/src/lib/utils/getValueForVariant.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { getValueForVariant } from './getValueForVariant'; +import type { ContentVariant } from '$lib/data/types'; + +describe('getValueForVariant', () => { + it('returns the value when it is a plain string (not a record)', () => { + expect(getValueForVariant('Same for all', 'dev')).toBe('Same for all'); + expect(getValueForVariant('Same for all', 'bio')).toBe('Same for all'); + }); + + it('returns the value when it is a plain number (not a record)', () => { + expect(getValueForVariant(42, 'dev')).toBe(42); + expect(getValueForVariant(42, 'bio')).toBe(42); + }); + + it('returns the variant key when value is a Record', () => { + const perVariant = { dev: 'Dev label', bio: 'Bio label' }; + expect(getValueForVariant(perVariant, 'dev')).toBe('Dev label'); + expect(getValueForVariant(perVariant, 'bio')).toBe('Bio label'); + }); + + it('returns undefined when record has no key for variant', () => { + const partial = { dev: 'Only dev' } as Record; + expect(getValueForVariant(partial, 'dev')).toBe('Only dev'); + expect(getValueForVariant(partial, 'bio')).toBeUndefined(); + }); + + it('returns undefined when record value for variant is undefined', () => { + const withUndefined = { dev: 'Dev', bio: undefined }; + expect(getValueForVariant(withUndefined, 'dev')).toBe('Dev'); + expect(getValueForVariant(withUndefined, 'bio')).toBeUndefined(); + }); + + it('returns undefined for object without variant key (e.g. array)', () => { + const arr = ['a', 'b']; + expect( + getValueForVariant(arr as unknown as Record, 'dev'), + ).toBeUndefined(); + }); + + it('handles null by returning it as T (no indexing)', () => { + expect(getValueForVariant(null as unknown as string, 'dev')).toBeNull(); + }); +}); diff --git a/src/lib/utils/getValueForVariant.ts b/src/lib/utils/getValueForVariant.ts new file mode 100644 index 0000000..725cf81 --- /dev/null +++ b/src/lib/utils/getValueForVariant.ts @@ -0,0 +1,14 @@ +import type { ContentVariant } from '$lib/data/constants'; + +export function getValueForVariant( + value: T | Record, + variant: ContentVariant, +): T | undefined { + if (typeof value === 'object' && value !== null && variant in value) { + return (value as Record)[variant] ?? undefined; + } + if (typeof value === 'object' && value !== null) { + return undefined; + } + return value as T; +} diff --git a/src/lib/utils/isShowForVariant.test.ts b/src/lib/utils/isShowForVariant.test.ts new file mode 100644 index 0000000..48b66cb --- /dev/null +++ b/src/lib/utils/isShowForVariant.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { isShowForVariant } from './isShowForVariant'; +import type { ContentVariant, Link } from '$lib/data/types'; + +function link(overrides: Partial & { variants: ContentVariant[] }): Link { + return { + href: 'https://example.com', + label: 'Example', + variants: overrides.variants, + ...overrides, + }; +} + +describe('isShowForVariant', () => { + it('returns a predicate function', () => { + const predicate = isShowForVariant('dev'); + expect(typeof predicate).toBe('function'); + expect(predicate.length).toBe(1); + }); + + it('returns true when link.variants includes the variant', () => { + const forDev = isShowForVariant('dev'); + expect(forDev(link({ variants: ['dev'] }))).toBe(true); + expect(forDev(link({ variants: ['dev', 'bio'] }))).toBe(true); + expect(forDev(link({ variants: ['bio', 'dev'] }))).toBe(true); + + const forBio = isShowForVariant('bio'); + expect(forBio(link({ variants: ['bio'] }))).toBe(true); + expect(forBio(link({ variants: ['dev', 'bio'] }))).toBe(true); + }); + + it('returns false when link.variants does not include the variant', () => { + const forDev = isShowForVariant('dev'); + expect(forDev(link({ variants: ['bio'] }))).toBe(false); + expect(forDev(link({ variants: [] }))).toBe(false); + + const forBio = isShowForVariant('bio'); + expect(forBio(link({ variants: ['dev'] }))).toBe(false); + expect(forBio(link({ variants: [] }))).toBe(false); + }); + + it('works when used with Array.prototype.filter', () => { + const links: Link[] = [ + link({ label: 'A', variants: ['dev'] }), + link({ label: 'B', variants: ['bio'] }), + link({ label: 'C', variants: ['dev', 'bio'] }), + ]; + expect(links.filter(isShowForVariant('dev'))).toHaveLength(2); + expect(links.filter(isShowForVariant('bio'))).toHaveLength(2); + expect(links.filter(isShowForVariant('dev')).map((l) => l.label)).toEqual(['A', 'C']); + }); +}); diff --git a/src/lib/utils/isShowForVariant.ts b/src/lib/utils/isShowForVariant.ts new file mode 100644 index 0000000..dfc0c59 --- /dev/null +++ b/src/lib/utils/isShowForVariant.ts @@ -0,0 +1,7 @@ +import type { ContentVariant, Link } from '$lib/data/types'; + +export const isShowForVariant = + (variant: ContentVariant) => + (link: Link): boolean => { + return link.variants.includes(variant); + }; diff --git a/src/lib/utm.test.ts b/src/lib/utm.test.ts new file mode 100644 index 0000000..2fd2bbc --- /dev/null +++ b/src/lib/utm.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { appendUtmParams } from './utm'; + +describe('utm', () => { + describe('appendUtmParams', () => { + it('appends utm params to own-property URL', () => { + const href = 'https://mifi.dev/page'; + const result = appendUtmParams(href, 'mifi.dev'); + const url = new URL(result); + expect(url.searchParams.get('utm_source')).toBe('mifi.dev'); + expect(url.searchParams.get('utm_medium')).toBe('link'); + expect(url.searchParams.get('utm_campaign')).toBe('landing'); + }); + + it('appends utm_content when provided', () => { + const href = 'https://mifi.bio/'; + const result = appendUtmParams(href, 'mifi.bio', 'hero'); + const url = new URL(result); + expect(url.searchParams.get('utm_content')).toBe('hero'); + }); + + it('returns href unchanged for non-own-property host', () => { + const href = 'https://example.com/page'; + expect(appendUtmParams(href, 'mifi.dev')).toBe(href); + }); + }); +}); diff --git a/src/lib/utm.ts b/src/lib/utm.ts new file mode 100644 index 0000000..aaafc11 --- /dev/null +++ b/src/lib/utm.ts @@ -0,0 +1,28 @@ +/** + * Append UTM params to own-property URLs for attribution. + */ + +import { UTM_MEDIUM, UTM_CAMPAIGN } from '$lib/config'; + +const OWN_PROPERTY_HOSTS = ['mifi.ventures', 'cal.mifi.ventures', 'mifi.dev', 'mifi.bio']; + +/** + * Returns href with UTM params appended if the URL's host is an own property. + * Respects existing query params. + */ +export function appendUtmParams(href: string, sourceHost: string, utmContent?: string): string { + try { + const url = new URL(href, 'https://mifi.dev'); + const hostname = url.hostname.toLowerCase(); + if (!OWN_PROPERTY_HOSTS.includes(hostname)) return href; + const params = new URLSearchParams(url.search); + params.set('utm_source', sourceHost); + params.set('utm_medium', UTM_MEDIUM); + params.set('utm_campaign', UTM_CAMPAIGN); + if (utmContent) params.set('utm_content', utmContent); + url.search = params.toString(); + return url.toString(); + } catch { + return href; + } +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..6a8405b --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,68 @@ +import contentData from '$lib/data/links.json'; +import { VARIANT_HOSTS, GA_MEASUREMENT_IDS } from '$lib/config'; +import type { Site, ContentData, ProcessedLink } from '$lib/data/types'; +import type { LayoutServerLoad } from './$types'; +import { ContentVariant, HeroLayout } from '$lib/data/constants'; +import { getProcessedLinks } from '$lib/utils/getProcessedLinks'; + +export type LayoutServerDataOut = { + site: Site; + contactLinks?: ProcessedLink[]; + links: { + sections: { + id: string; + title: string; + order: number; + links: ProcessedLink[]; + }[]; + }; + variant: string; + gaMeasurementId: string; +}; + +export const load: LayoutServerLoad = (): LayoutServerDataOut => { + const variant = + process.env.CONTENT_VARIANT === ContentVariant.BIO + ? ContentVariant.BIO + : ContentVariant.DEV; + const sourceHost = VARIANT_HOSTS[variant]; + const siteUrl = 'https://' + sourceHost; + const data = contentData as ContentData; + const contactLinks = getProcessedLinks(data.contactLinks, variant); + const sections = data.sections + .map((section) => { + const links: ProcessedLink[] = getProcessedLinks(section.links, variant); + return { + id: section.id, + title: section.title, + links, + order: section.order[variant] ?? null, + }; + }) + .filter( + (s) => s.links.length > 0 && s.order !== null, + ) as LayoutServerDataOut['links']['sections']; + const siteDef = data.siteByVariant[variant]; + const site: LayoutServerDataOut['site'] = { + title: siteDef?.title ?? (variant === ContentVariant.DEV ? 'mifi.dev' : 'mifi.bio'), + metaDescription: siteDef?.metaDescription ?? '', + url: siteUrl, + heroLayout: siteDef?.heroLayout ?? HeroLayout.SIDE_BY_SIDE, + profileImage: siteDef?.profileImage, + pronunciation: siteDef?.pronunciation, + pronouns: siteDef?.pronouns, + location: siteDef?.location, + person: siteDef?.person, + linksHeading: siteDef?.linksHeading, + showContact: siteDef?.showContact, + contactLinks: siteDef?.contactLinks, + qrCodeImage: siteDef?.qrCodeImage ?? undefined, + }; + return { + site, + contactLinks, + links: { sections }, + variant, + gaMeasurementId: GA_MEASUREMENT_IDS[variant], + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..63b3594 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,47 @@ + + + + + + {@html jsonLdHtml} + {#if personLdHtml} + + {@html personLdHtml} + {/if} + + + + + diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..189f71e --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1 @@ +export const prerender = true; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..de74633 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,3 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ parent }) => parent(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..e240001 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,65 @@ + + + + {data.site.title} + + + +
+ +
+ {#each data.links.sections as section} + 1} + title={section.title} + /> + {/each} +
+
+ (shareOpen = false)} + /> + (contactOpen = false)} + /> +
+ + diff --git a/src/routes/__tests__/layout.server.test.ts b/src/routes/__tests__/layout.server.test.ts new file mode 100644 index 0000000..e9e3255 --- /dev/null +++ b/src/routes/__tests__/layout.server.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { load } from '../+layout.server'; +import { ContentVariant } from '$lib/data/constants'; +import type { ContentData } from '$lib/data/types'; + +const { mockContentData } = vi.hoisted(() => { + const data: ContentData = { + siteByVariant: { + dev: { + title: 'Dev Site', + metaDescription: 'Dev desc', + url: 'https://mifi.dev', + heroLayout: 'side-by-side', + }, + bio: { + title: 'Bio Site', + metaDescription: 'Bio desc', + url: 'https://mifi.bio', + heroLayout: 'side-by-side', + }, + }, + contactLinks: [ + { + href: 'https://mifi.dev/contact', + label: 'Contact', + variants: ['dev', 'bio'], + }, + ], + sections: [ + { + id: 'main', + title: 'Links', + order: { dev: 0, bio: 0 }, + links: [ + { + href: 'https://mifi.dev/x', + label: 'Link', + variants: ['dev', 'bio'], + }, + ], + }, + ], + }; + return { mockContentData: data }; +}); + +vi.mock('$lib/data/links.json', () => ({ + default: mockContentData, +})); + +describe('+layout.server', () => { + const originalEnv = process.env.CONTENT_VARIANT; + + afterEach(() => { + process.env.CONTENT_VARIANT = originalEnv; + }); + + it('returns layout data with site, contactLinks, links, variant, gaMeasurementId', () => { + process.env.CONTENT_VARIANT = ContentVariant.DEV; + const result = load(); + expect(result).toHaveProperty('site'); + expect(result).toHaveProperty('contactLinks'); + expect(result).toHaveProperty('links'); + expect(result).toHaveProperty('variant'); + expect(result).toHaveProperty('gaMeasurementId'); + expect(result.variant).toBe('dev'); + expect(result.site.title).toBe('Dev Site'); + expect(result.links.sections).toHaveLength(1); + expect(result.links.sections[0].links).toHaveLength(1); + expect(result.links.sections[0].links[0].label).toBe('Link'); + }); + + it('uses bio variant when CONTENT_VARIANT is bio', () => { + process.env.CONTENT_VARIANT = ContentVariant.BIO; + const result = load(); + expect(result.variant).toBe('bio'); + expect(result.site.title).toBe('Bio Site'); + }); + + it('defaults to dev when CONTENT_VARIANT is not bio', () => { + process.env.CONTENT_VARIANT = 'other'; + const result = load(); + expect(result.variant).toBe('dev'); + }); + + it('site.url is https + VARIANT_HOSTS[variant]', () => { + process.env.CONTENT_VARIANT = ContentVariant.DEV; + const result = load(); + expect(result.site.url).toBe('https://mifi.dev'); + process.env.CONTENT_VARIANT = ContentVariant.BIO; + const resultBio = load(); + expect(resultBio.site.url).toBe('https://mifi.bio'); + }); + + it('gaMeasurementId matches variant', () => { + process.env.CONTENT_VARIANT = ContentVariant.DEV; + expect(load().gaMeasurementId).toMatch(/^G-/); + process.env.CONTENT_VARIANT = ContentVariant.BIO; + expect(load().gaMeasurementId).toMatch(/^G-/); + }); +}); diff --git a/src/routes/__tests__/layout.test.ts b/src/routes/__tests__/layout.test.ts new file mode 100644 index 0000000..39225b7 --- /dev/null +++ b/src/routes/__tests__/layout.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import type { LayoutData } from '../$types'; + +/** + * LayoutData shape used by +layout.svelte. Component itself is covered by e2e. + */ +describe('+layout (LayoutData)', () => { + it('LayoutData shape matches what load returns', () => { + const mockData: LayoutData = { + site: { + title: 'Test', + metaDescription: 'Desc', + url: 'https://mifi.dev', + }, + links: { sections: [] }, + variant: 'dev', + gaMeasurementId: 'G-xxx', + }; + expect(mockData.site).toHaveProperty('title'); + expect(mockData.site).toHaveProperty('url'); + expect(mockData).toHaveProperty('variant'); + expect(mockData.links).toHaveProperty('sections'); + }); +}); diff --git a/src/routes/__tests__/page.server.test.ts b/src/routes/__tests__/page.server.test.ts new file mode 100644 index 0000000..f3f07ad --- /dev/null +++ b/src/routes/__tests__/page.server.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { load } from '../+page.server'; + +describe('+page.server', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('load returns parent() result', async () => { + const mockParent = vi.fn().mockResolvedValue({ + site: { title: 'Parent' }, + variant: 'dev', + }); + const event = { parent: mockParent } as Parameters[0]; + const result = await load(event); + expect(mockParent).toHaveBeenCalledOnce(); + expect(result).toEqual({ site: { title: 'Parent' }, variant: 'dev' }); + }); +}); diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/static/assets/fonts/.gitkeep b/static/assets/fonts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/static/assets/fonts/.gitkeep @@ -0,0 +1 @@ + diff --git a/static/assets/fonts/fraunces-v38-latin-500.woff2 b/static/assets/fonts/fraunces-v38-latin-500.woff2 new file mode 100644 index 0000000..969f099 Binary files /dev/null and b/static/assets/fonts/fraunces-v38-latin-500.woff2 differ diff --git a/static/assets/fonts/fraunces-v38-latin-500italic.woff2 b/static/assets/fonts/fraunces-v38-latin-500italic.woff2 new file mode 100644 index 0000000..100413d Binary files /dev/null and b/static/assets/fonts/fraunces-v38-latin-500italic.woff2 differ diff --git a/static/assets/fonts/fraunces-v38-latin-600.woff2 b/static/assets/fonts/fraunces-v38-latin-600.woff2 new file mode 100644 index 0000000..58cee1f Binary files /dev/null and b/static/assets/fonts/fraunces-v38-latin-600.woff2 differ diff --git a/static/assets/fonts/fraunces-v38-latin-600italic.woff2 b/static/assets/fonts/fraunces-v38-latin-600italic.woff2 new file mode 100644 index 0000000..66d09d2 Binary files /dev/null and b/static/assets/fonts/fraunces-v38-latin-600italic.woff2 differ diff --git a/static/assets/fonts/fraunces-v38-latin-700.woff2 b/static/assets/fonts/fraunces-v38-latin-700.woff2 new file mode 100644 index 0000000..71bbace Binary files /dev/null and b/static/assets/fonts/fraunces-v38-latin-700.woff2 differ diff --git a/static/assets/fonts/fraunces-v38-latin-italic.woff2 b/static/assets/fonts/fraunces-v38-latin-italic.woff2 new file mode 100644 index 0000000..8b47701 Binary files /dev/null and b/static/assets/fonts/fraunces-v38-latin-italic.woff2 differ diff --git a/static/assets/fonts/fraunces-v38-latin-regular.woff2 b/static/assets/fonts/fraunces-v38-latin-regular.woff2 new file mode 100644 index 0000000..83cbd8f Binary files /dev/null and b/static/assets/fonts/fraunces-v38-latin-regular.woff2 differ diff --git a/static/assets/fonts/fraunces-variable-opsz-wght.woff2 b/static/assets/fonts/fraunces-variable-opsz-wght.woff2 new file mode 100644 index 0000000..bc8bdf7 Binary files /dev/null and b/static/assets/fonts/fraunces-variable-opsz-wght.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-500.woff2 b/static/assets/fonts/inter-v20-latin-500.woff2 new file mode 100644 index 0000000..54f0a59 Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-500.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-500italic.woff2 b/static/assets/fonts/inter-v20-latin-500italic.woff2 new file mode 100644 index 0000000..f4f25da Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-500italic.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-600.woff2 b/static/assets/fonts/inter-v20-latin-600.woff2 new file mode 100644 index 0000000..d189794 Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-600.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-600italic.woff2 b/static/assets/fonts/inter-v20-latin-600italic.woff2 new file mode 100644 index 0000000..e882c78 Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-600italic.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-700.woff2 b/static/assets/fonts/inter-v20-latin-700.woff2 new file mode 100644 index 0000000..a68fb10 Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-700.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-700italic.woff2 b/static/assets/fonts/inter-v20-latin-700italic.woff2 new file mode 100644 index 0000000..48b6e5b Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-700italic.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-italic.woff2 b/static/assets/fonts/inter-v20-latin-italic.woff2 new file mode 100644 index 0000000..9e98286 Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-italic.woff2 differ diff --git a/static/assets/fonts/inter-v20-latin-regular.woff2 b/static/assets/fonts/inter-v20-latin-regular.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/static/assets/fonts/inter-v20-latin-regular.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2 new file mode 100644 index 0000000..8e64129 Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-500italic.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-500italic.woff2 new file mode 100644 index 0000000..b25a793 Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-500italic.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-600.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-600.woff2 new file mode 100644 index 0000000..29b73cf Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-600.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-600italic.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-600italic.woff2 new file mode 100644 index 0000000..7ac1a9d Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-600italic.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2 new file mode 100644 index 0000000..9cf2bf8 Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-700italic.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-700italic.woff2 new file mode 100644 index 0000000..f757373 Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-700italic.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-italic.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-italic.woff2 new file mode 100644 index 0000000..397a343 Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-italic.woff2 differ diff --git a/static/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2 b/static/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2 new file mode 100644 index 0000000..dc81658 Binary files /dev/null and b/static/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2 differ diff --git a/static/assets/images/.gitkeep b/static/assets/images/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/static/assets/images/.gitkeep @@ -0,0 +1 @@ + diff --git a/static/assets/images/classic-mifi.png b/static/assets/images/classic-mifi.png new file mode 100644 index 0000000..8a1e6d4 Binary files /dev/null and b/static/assets/images/classic-mifi.png differ diff --git a/static/assets/images/classic-mifi.webp b/static/assets/images/classic-mifi.webp new file mode 100644 index 0000000..7c12d4f Binary files /dev/null and b/static/assets/images/classic-mifi.webp differ diff --git a/static/assets/images/cutout-block.svg b/static/assets/images/cutout-block.svg new file mode 100644 index 0000000..de00285 --- /dev/null +++ b/static/assets/images/cutout-block.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/assets/images/tropical-mifi.png b/static/assets/images/tropical-mifi.png new file mode 100644 index 0000000..5a7302d Binary files /dev/null and b/static/assets/images/tropical-mifi.png differ diff --git a/static/assets/images/tropical-mifi.webp b/static/assets/images/tropical-mifi.webp new file mode 100644 index 0000000..8837bb8 Binary files /dev/null and b/static/assets/images/tropical-mifi.webp differ diff --git a/static/assets/js/theme-store.js b/static/assets/js/theme-store.js new file mode 100644 index 0000000..414190b --- /dev/null +++ b/static/assets/js/theme-store.js @@ -0,0 +1,5 @@ +(function () { + var t = localStorage.getItem('mifi-theme'); + if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t); + else document.documentElement.removeAttribute('data-theme'); +})(); diff --git a/static/assets/tokens-bio.css b/static/assets/tokens-bio.css new file mode 100644 index 0000000..c17fbf6 --- /dev/null +++ b/static/assets/tokens-bio.css @@ -0,0 +1,131 @@ +/** + * mifi.bio variant – blue & rose palette (WCAG 2.2 AAA). + * Light by default; dark via prefers-color-scheme (auto) or [data-theme="dark"]. + * Extra tuning tokens (--tune-*) for experimentation. + * + * Alternative palettes (AAA-compliant; replace the blocks below to try): + * - Green only: primary/secondary both green hues (e.g. 158 and 175). + * - Orange only: primary/secondary both warm hues (e.g. 28 and 24), neutrals hue 30. + */ + +:root { + /* Neutrals – cool tint (blue) */ + --color-bg: hsl(220 22% 98%); + --color-fg: hsl(220 20% 12%); + --color-surface: hsl(220 20% 100%); + --color-surface-elevated: hsl(220 18% 100%); + --color-border: hsl(220 16% 90%); + --color-border-subtle: hsl(220 14% 94%); + + /* Primary – blue (≥7:1 on bg) */ + --color-primary: hsl(220 65% 32%); + --color-primary-muted: hsl(220 50% 38%); + --color-secondary: hsl(330 55% 38%); + --color-secondary-muted: hsl(330 45% 42%); + + --color-accent: hsl(220 65% 32%); + --color-focus-ring: hsl(220 65% 32%); + --color-link: hsl(220 65% 36%); + --color-link-hover: hsl(220 55% 26%); + + /* Tuning */ + --tune-primary-alt: hsl(220 55% 38%); + --tune-secondary-alt: hsl(330 50% 45%); + --tune-surface-2: hsl(220 20% 98%); + --tune-accent-alt: hsl(330 50% 48%); + + /* Typography */ + --font-wordmark: 'Plus Jakarta Sans', var(--font-sans); + --font-heading: Fraunces, var(--font-sans); + --font-body: Inter, var(--font-sans); + --font-heading-opsz: 36; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --color-bg: hsl(220 18% 8%); + --color-fg: hsl(220 14% 93%); + --color-surface: hsl(220 16% 11%); + --color-surface-elevated: hsl(220 14% 15%); + --color-border: hsl(220 14% 20%); + --color-border-subtle: hsl(220 12% 16%); + --color-primary: hsl(220 55% 62%); + --color-primary-muted: hsl(220 45% 55%); + --color-secondary: hsl(330 50% 65%); + --color-secondary-muted: hsl(330 42% 58%); + --color-accent: hsl(220 55% 62%); + --color-focus-ring: hsl(220 55% 62%); + --color-link: hsl(220 55% 66%); + --color-link-hover: hsl(220 50% 75%); + --tune-primary-alt: hsl(220 50% 58%); + --tune-secondary-alt: hsl(330 48% 62%); + --tune-surface-2: hsl(220 18% 9%); + --tune-accent-alt: hsl(330 48% 62%); + } +} + +[data-theme='light'] { + --color-bg: hsl(220 22% 98%); + --color-fg: hsl(220 20% 12%); + --color-surface: hsl(220 20% 100%); + --color-surface-elevated: hsl(220 18% 100%); + --color-border: hsl(220 16% 90%); + --color-border-subtle: hsl(220 14% 94%); + --color-primary: hsl(220 65% 32%); + --color-primary-muted: hsl(220 50% 38%); + --color-secondary: hsl(330 55% 38%); + --color-secondary-muted: hsl(330 45% 42%); + --color-accent: hsl(220 65% 32%); + --color-focus-ring: hsl(220 65% 32%); + --color-link: hsl(220 65% 36%); + --color-link-hover: hsl(220 55% 26%); + --tune-primary-alt: hsl(220 55% 38%); + --tune-secondary-alt: hsl(330 50% 45%); + --tune-surface-2: hsl(220 20% 98%); + --tune-accent-alt: hsl(330 50% 48%); +} + +[data-theme='dark'] { + --color-bg: hsl(220 18% 8%); + --color-fg: hsl(220 14% 93%); + --color-surface: hsl(220 16% 11%); + --color-surface-elevated: hsl(220 14% 15%); + --color-border: hsl(220 14% 20%); + --color-border-subtle: hsl(220 12% 16%); + --color-primary: hsl(220 55% 62%); + --color-primary-muted: hsl(220 45% 55%); + --color-secondary: hsl(330 50% 65%); + --color-secondary-muted: hsl(330 42% 58%); + --color-accent: hsl(220 55% 62%); + --color-focus-ring: hsl(220 55% 62%); + --color-link: hsl(220 55% 66%); + --color-link-hover: hsl(220 50% 75%); + --tune-primary-alt: hsl(220 50% 58%); + --tune-secondary-alt: hsl(330 48% 62%); + --tune-surface-2: hsl(220 18% 9%); + --tune-accent-alt: hsl(330 48% 62%); +} + +/* ------------------------------------------------------------------------- + ALTERNATIVE PALETTES (WCAG 2.2 AAA). Replace the :root and dark blocks + above with one of these if you prefer a single-hue scheme. + ------------------------------------------------------------------------- + + GREEN ONLY (no orange) – :root neutrals/primary/secondary: + --color-bg: hsl(158 22% 98%); + --color-fg: hsl(158 20% 12%); + --color-primary: hsl(158 64% 28%); + --color-primary-muted: hsl(158 45% 38%); + --color-secondary: hsl(175 55% 32%); + --color-secondary-muted: hsl(175 40% 40%); + (Dark: primary hsl(158 50% 58%); secondary hsl(175 45% 60%).) + + ORANGE ONLY (no green) – :root neutrals/primary/secondary: + --color-bg: hsl(30 25% 98%); + --color-fg: hsl(30 20% 10%); + --color-primary: hsl(24 72% 38%); + --color-primary-muted: hsl(24 55% 45%); + --color-secondary: hsl(38 65% 35%); + --color-secondary-muted: hsl(38 50% 42%); + (Dark: primary hsl(24 60% 58%); secondary hsl(38 55% 62%).) +*/ diff --git a/static/assets/tokens-dev.css b/static/assets/tokens-dev.css new file mode 100644 index 0000000..bbe21d2 --- /dev/null +++ b/static/assets/tokens-dev.css @@ -0,0 +1,99 @@ +/** + * mifi.dev variant – purple/slate palette. + * Light by default; dark via prefers-color-scheme (auto) or [data-theme="dark"]. + * Extra tuning tokens (--tune-*) for experimentation. + */ + +:root { + --color-bg: hsl(260 20% 98%); + --color-fg: hsl(260 15% 12%); + --color-surface: hsl(260 18% 100%); + --color-surface-elevated: hsl(260 15% 100%); + --color-border: hsl(260 12% 90%); + --color-border-subtle: hsl(260 10% 94%); + --color-primary: hsl(262 83% 38%); + --color-primary-muted: hsl(262 60% 52%); + --color-secondary: hsl(220 25% 35%); + --color-secondary-muted: hsl(220 20% 48%); + --color-accent: hsl(262 83% 38%); + --color-focus-ring: hsl(262 83% 38%); + --color-link: hsl(262 83% 38%); + --color-link-hover: hsl(262 70% 30%); + + /* Tuning – adjust without changing structure */ + --tune-primary-alt: hsl(262 70% 45%); + --tune-secondary-alt: hsl(220 30% 42%); + --tune-surface-2: hsl(260 12% 98%); + --tune-accent-alt: hsl(262 60% 50%); + + /* Typography (shared; override in app if needed) */ + --font-wordmark: 'Plus Jakarta Sans', var(--font-sans); + --font-heading: Fraunces, var(--font-sans); + --font-body: Inter, var(--font-sans); + --font-heading-opsz: 36; /* optical size for variable Fraunces; no effect on static instances */ +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --color-bg: hsl(260 18% 8%); + --color-fg: hsl(260 12% 94%); + --color-surface: hsl(260 20% 12%); + --color-surface-elevated: hsl(260 18% 16%); + --color-border: hsl(260 15% 22%); + --color-border-subtle: hsl(260 12% 18%); + --color-primary: hsl(262 70% 65%); + --color-primary-muted: hsl(262 50% 58%); + --color-secondary: hsl(220 25% 68%); + --color-secondary-muted: hsl(220 20% 58%); + --color-accent: hsl(262 70% 65%); + --color-focus-ring: hsl(262 70% 65%); + --color-link: hsl(262 70% 70%); + --color-link-hover: hsl(262 65% 82%); + --tune-primary-alt: hsl(262 60% 60%); + --tune-secondary-alt: hsl(220 30% 65%); + --tune-surface-2: hsl(260 15% 11%); + --tune-accent-alt: hsl(262 55% 68%); + } +} + +[data-theme='light'] { + --color-bg: hsl(260 20% 98%); + --color-fg: hsl(260 15% 12%); + --color-surface: hsl(260 18% 100%); + --color-surface-elevated: hsl(260 15% 100%); + --color-border: hsl(260 12% 90%); + --color-border-subtle: hsl(260 10% 94%); + --color-primary: hsl(262 83% 38%); + --color-primary-muted: hsl(262 60% 52%); + --color-secondary: hsl(220 25% 35%); + --color-secondary-muted: hsl(220 20% 48%); + --color-accent: hsl(262 83% 38%); + --color-focus-ring: hsl(262 83% 38%); + --color-link: hsl(262 83% 38%); + --color-link-hover: hsl(262 70% 30%); + --tune-primary-alt: hsl(262 70% 45%); + --tune-secondary-alt: hsl(220 30% 42%); + --tune-surface-2: hsl(260 12% 98%); + --tune-accent-alt: hsl(262 60% 50%); +} + +[data-theme='dark'] { + --color-bg: hsl(260 18% 8%); + --color-fg: hsl(260 12% 94%); + --color-surface: hsl(260 20% 12%); + --color-surface-elevated: hsl(260 18% 16%); + --color-border: hsl(260 15% 22%); + --color-border-subtle: hsl(260 12% 18%); + --color-primary: hsl(262 70% 65%); + --color-primary-muted: hsl(262 50% 58%); + --color-secondary: hsl(220 25% 68%); + --color-secondary-muted: hsl(220 20% 58%); + --color-accent: hsl(262 70% 65%); + --color-focus-ring: hsl(262 70% 65%); + --color-link: hsl(262 70% 70%); + --color-link-hover: hsl(262 65% 82%); + --tune-primary-alt: hsl(262 60% 60%); + --tune-secondary-alt: hsl(220 30% 65%); + --tune-surface-2: hsl(260 15% 11%); + --tune-accent-alt: hsl(262 55% 68%); +} diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..6a2944d --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylelint.config.js b/stylelint.config.js new file mode 100644 index 0000000..a32a038 --- /dev/null +++ b/stylelint.config.js @@ -0,0 +1,12 @@ +export default { + extends: ['stylelint-config-standard'], + overrides: [ + { + files: ['**/*.svelte'], + customSyntax: 'postcss-html', + }, + ], + rules: { + 'no-descending-specificity': null, + }, +}; diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..10c4479 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,23 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: undefined, + precompress: false, + strict: true, + }), + prerender: { + handleHttpError: 'warn', + handleMissingId: 'warn', + origin: undefined, + }, + }, +}; + +export default config; diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ecad107 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"], + "exclude": ["node_modules", "dist", "build", "coverage", "playwright-report", "test-results", ".svelte-kit", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b109c7d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..339a062 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import { sveltekit } from '@sveltejs/kit/vite'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['src/**/*.{test,spec}.{js,ts}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{js,ts}'], + exclude: ['src/**/*.d.ts', 'src/**/*.test.*', 'src/**/*.spec.*'], + }, + }, +});