Initial commit
This commit is contained in:
10
.cursor/rules/project.mdc
Normal file
10
.cursor/rules/project.mdc
Normal file
@@ -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.
|
||||||
24
.devcontainer/devcontainer.json
Normal file
24
.devcontainer/devcontainer.json
Normal file
@@ -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]
|
||||||
|
}
|
||||||
12
.eslintignore
Normal file
12
.eslintignore
Normal file
@@ -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
|
||||||
32
.eslintrc.cjs
Normal file
32
.eslintrc.cjs
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -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
|
||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.svelte-kit
|
||||||
|
package
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
9
.prettierrc
Normal file
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
||||||
13
.woodpecker.yml
Normal file
13
.woodpecker.yml
Normal file
@@ -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
|
||||||
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
@@ -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 `<script src="...">`.
|
||||||
|
- **Dev container** uses the same Linux + Playwright Chromium as CI so e2e/visual-regression snapshots are comparable.
|
||||||
|
- **Docker:** Static server is **nginx**; Traefik labels for `mifi.dev`, `www.mifi.dev`, network `marina-net`.
|
||||||
|
- **CI:** Woodpecker; pipeline runs lint, unit tests, e2e/visual regression, build (pnpm).
|
||||||
|
|
||||||
|
## Key paths
|
||||||
|
|
||||||
|
- `src/routes/` – pages and layout
|
||||||
|
- `src/lib/data/` – JSON content
|
||||||
|
- `src/app.html` – HTML shell
|
||||||
|
- `scripts/critical-css.mjs` – post-build critical CSS
|
||||||
|
- `.devcontainer/` – dev container (Node, pnpm, Playwright Linux)
|
||||||
|
- `docker-compose` – nginx + Traefik (in repo root when added)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Install: `pnpm install`
|
||||||
|
- Dev: `pnpm dev`
|
||||||
|
- Build: `pnpm build` (or `pnpm build:full` for critical CSS)
|
||||||
|
- Lint: `pnpm lint`
|
||||||
|
- Test: `pnpm test:run`, `pnpm test:e2e`
|
||||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# mifi.dev landing
|
||||||
|
|
||||||
|
One-page static Linktree-style site for [mifi.dev](https://mifi.dev). Built with SvelteKit (static adapter), TypeScript, pnpm, PostCSS, and critical CSS.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** 22+
|
||||||
|
- **pnpm** (enable via `corepack enable && corepack prepare pnpm@latest --activate`)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### Option A: Dev container (recommended)
|
||||||
|
|
||||||
|
Use the same Linux environment as CI so Playwright snapshots match.
|
||||||
|
|
||||||
|
1. Open the repo in VS Code or Cursor.
|
||||||
|
2. When prompted, click **Reopen in Container** (or run **Dev Containers: Reopen in Container** from the command palette).
|
||||||
|
3. Wait for the container to build; **postCreateCommand** runs `pnpm install` and `pnpm exec playwright install chromium --with-deps` so dependencies and the Playwright browser are ready.
|
||||||
|
4. Run the scripts below inside the container.
|
||||||
|
|
||||||
|
### Option B: Local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # dev server at http://localhost:5173
|
||||||
|
pnpm build # output in build/
|
||||||
|
pnpm preview # preview build at http://localhost:4173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Script | Description |
|
||||||
|
| -------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
|
| `pnpm dev:bio` | Start Vite dev server for mifi.bio |
|
||||||
|
| `pnpm dev:dev` | Start Vite dev server for mifi.dev |
|
||||||
|
| `pnpm build` | Build static site to `build/` |
|
||||||
|
| `pnpm build:full` | Build + inline critical CSS (requires Chromium: `pnpm exec puppeteer browsers install chromium`) |
|
||||||
|
| `pnpm preview` | Serve `build/` locally |
|
||||||
|
| `pnpm check` | Run `svelte-kit sync` and `svelte-check` |
|
||||||
|
| `pnpm lint` | ESLint + Stylelint |
|
||||||
|
| `pnpm format` | Prettier (write) |
|
||||||
|
| `pnpm format:check` | Prettier (check only) |
|
||||||
|
| `pnpm test` | Vitest (watch) |
|
||||||
|
| `pnpm test:run` | Vitest (single run) |
|
||||||
|
| `pnpm test:coverage` | Vitest with coverage |
|
||||||
|
| `pnpm test:e2e` | Playwright e2e (starts preview, then runs tests) |
|
||||||
|
| `pnpm test:e2e:ui` | Playwright e2e in UI mode |
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
- `src/` – SvelteKit app (routes, layout, global CSS)
|
||||||
|
- `src/lib/data/` – JSON content (e.g. `links.json`) loaded at build time
|
||||||
|
- `static/` – Static assets
|
||||||
|
- `scripts/` – Post-build scripts (e.g. critical CSS)
|
||||||
|
- `e2e/` – Playwright e2e tests
|
||||||
|
- `build/` – Output after `pnpm build` (gitignored)
|
||||||
|
|
||||||
|
## Tooling
|
||||||
|
|
||||||
|
- **Package manager:** pnpm
|
||||||
|
- **Language:** TypeScript
|
||||||
|
- **Lint:** ESLint, Stylelint
|
||||||
|
- **Format:** Prettier
|
||||||
|
- **Unit tests:** Vitest
|
||||||
|
- **E2E / visual regression:** Playwright (use same Linux build in dev container and CI)
|
||||||
|
- **Critical CSS:** Post-build step via `critical` (run `pnpm build:full` with Chromium installed)
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- Repo: `git.mifi.dev/mifi-holdings/mifi-dev-landing`
|
||||||
|
- Deploy via webhook to Portainer stack; `docker-compose` in repo defines nginx + Traefik labels for `mifi.dev` and `www.mifi.dev` on network `marina-net`.
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
Fonts live in `static/assets/fonts/`. The wordmark uses **Plus Jakarta Sans** (700). The self-hosted “latin” woff2 subsets omit ligature (GSUB) tables, so the **fi** ligature in “mifi” does not appear with them.
|
||||||
|
|
||||||
|
**Current setup:** Plus Jakarta Sans 700 is loaded from **Google Fonts** in the layout so the wordmark fi ligature works. All other fonts (Fraunces, Inter, etc.) are self-hosted.
|
||||||
|
|
||||||
|
**To self-host the wordmark font with ligatures** instead of Google Fonts:
|
||||||
|
|
||||||
|
1. **Download the font**
|
||||||
|
[Google Fonts → Plus Jakarta Sans](https://fonts.google.com/specimen/Plus+Jakarta+Sans) → “Download family” (ZIP). Unzip; use the **Bold** static file (e.g. `PlusJakartaSans-Bold.ttf` or `PlusJakartaText-Bold.otf` from the `static` folder). Or clone [Tokotype/PlusJakartaSans](https://github.com/tokotype/PlusJakartaSans) and use `fonts/ttf/PlusJakartaSans-Bold.ttf` (or the OTF equivalent).
|
||||||
|
|
||||||
|
2. **Subset with ligatures** (from the repo root, with fonttools installed: `pip install fonttools brotli`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace PATH_TO_BOLD with the path to the Bold TTF/OTF (e.g. ~/Downloads/Plus_Jakarta_Sans/static/PlusJakartaSans-Bold.ttf)
|
||||||
|
# Use --layout-features='*' to keep all layout features (including liga); or 'liga','clig'
|
||||||
|
pyftsubset PATH_TO_BOLD \
|
||||||
|
--output-file=static/assets/fonts/plus-jakarta-sans-700-liga.woff2 \
|
||||||
|
--flavor=woff2 \
|
||||||
|
--layout-features='*' \
|
||||||
|
--unicodes='U+0020-007F,U+00A0-00FF,U+FB01,U+FB02'
|
||||||
|
```
|
||||||
|
|
||||||
|
Example if the ZIP is in your Downloads folder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyftsubset ~/Downloads/Plus_Jakarta_Sans/static/PlusJakartaSans-Bold.ttf \
|
||||||
|
--output-file=static/assets/fonts/plus-jakarta-sans-700-liga.woff2 \
|
||||||
|
--flavor=woff2 \
|
||||||
|
--layout-features='*' \
|
||||||
|
--unicodes='U+0020-007F,U+00A0-00FF,U+FB01,U+FB02'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Switch back to self-hosted**
|
||||||
|
Put the new woff2 in `static/assets/fonts/`. In `src/lib/fonts.css`, uncomment the Plus Jakarta Sans `@font-face` and set its `url()` to `plus-jakarta-sans-700-liga.woff2`. In `src/routes/+layout.svelte`, remove the Google Fonts `<link>` for Plus Jakarta Sans.
|
||||||
|
|
||||||
|
### Fraunces variable (headings with opsz)
|
||||||
|
|
||||||
|
Headings use **Fraunces** with `font-variation-settings: "opsz" 36`. That only works with the **variable** font (opsz + wght axes). Static instances (e.g. `fraunces-v38-latin-500.woff2`) ignore opsz.
|
||||||
|
|
||||||
|
1. **Download the variable TTF**
|
||||||
|
[Google Fonts → Fraunces](https://fonts.google.com/specimen/Fraunces) → “Download family”. In the ZIP, use the file in the **variable** folder: `Fraunces-VariableFont_opsz,wght.ttf` (or similar name).
|
||||||
|
|
||||||
|
2. **Subset to woff2** (from repo root; `pip install fonttools brotli`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace PATH_TO_VARIANT with the path to Fraunces-VariableFont_opsz,wght.ttf
|
||||||
|
pyftsubset PATH_TO_VARIANT \
|
||||||
|
--output-file=static/assets/fonts/fraunces-variable-opsz-wght.woff2 \
|
||||||
|
--flavor=woff2 \
|
||||||
|
--layout-features='*' \
|
||||||
|
--unicodes='U+0020-007F,U+00A0-00FF'
|
||||||
|
```
|
||||||
|
|
||||||
|
Example if the ZIP is in Downloads:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pyftsubset ~/Downloads/Fraunces/fraunces-variable-opsz-wght.ttf \
|
||||||
|
--output-file=static/assets/fonts/fraunces-variable-opsz-wght.woff2 \
|
||||||
|
--flavor=woff2 \
|
||||||
|
--layout-features='*' \
|
||||||
|
--unicodes='U+0020-007F,U+00A0-00FF'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use it in the app**
|
||||||
|
In `src/lib/fonts.css`, replace the Fraunces `@font-face` with one that points at the variable woff2 and a `font-weight` range (e.g. 100 900) so the browser can use the wght axis. See the comment in `fonts.css` for the exact block.
|
||||||
|
|
||||||
|
## CSP
|
||||||
|
|
||||||
|
CSP is set via Traefik middleware, not in app code.
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./build:/usr/share/nginx/html:ro
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.mifi-dev.rule=Host(`mifi.dev`) || Host(`www.mifi.dev`)'
|
||||||
|
- 'traefik.http.routers.mifi-dev.entrypoints=websecure'
|
||||||
|
- 'traefik.http.services.mifi-dev.loadbalancer.server.port=80'
|
||||||
|
networks:
|
||||||
|
- marina-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marina-net:
|
||||||
|
external: true
|
||||||
24
docs/planning/data-planning.md
Normal file
24
docs/planning/data-planning.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
PROFESSIONAL/CODE
|
||||||
|
https://mifi.ventures - mifi.ventures - my Software Engineering Consultancy LLC - both variants
|
||||||
|
https://cal.mifi.ventures/the-mifi - my Cal.com profile - dev variant
|
||||||
|
https://www.linkedin.com/in/the-mifi - my LinkedIn profile - both variants
|
||||||
|
https://github.com/the-mifi - my GitHub profile - both variants
|
||||||
|
https://gitmifi.dev/mifi - my Gitea profile - both variants
|
||||||
|
resume.pdf - my resume - both variants (I have a contract-role focused and a permanent role focused resume)
|
||||||
|
|
||||||
|
PERSONAL
|
||||||
|
https://mifi.dev - mifi.dev - my professional person website - bio variant
|
||||||
|
https://mifi.bio - mifi.bio - my human person website - dev variant
|
||||||
|
|
||||||
|
SOCIAL
|
||||||
|
https://www.instagram.com/the.mifi - my US English-based Instagram profile an inconsistently updated visual diary of my life - bio variant
|
||||||
|
https://www.instagram.com/mifi.no.brasil - my Brazilian Portuguese-based Instagram profile, with delicious American recipes demonstrated through videos as I learn Portuguese in Reel Time - bio varaint
|
||||||
|
https://facebook.com/mifi79 - my Facebook profile - bio variant
|
||||||
|
https://youtube.com/@the-real-mifi - my YouTube channel/with drone videography - bio variant
|
||||||
|
https://www.tiktok.com/@the.mifi - my TikTok profile - bio variant
|
||||||
|
https://www.snapchat.com/add/the.mifi - my Snapchat profile - bio variant
|
||||||
|
https://www.discord.com/users/the_mifi - my Discord profile - bio variant
|
||||||
|
|
||||||
|
MEASUREMENT IDs:
|
||||||
|
mifi.dev: G-P8V832WDM8
|
||||||
|
mifi.bio: G-885B0KYWZ1
|
||||||
147
docs/planning/mifi-planning.md
Normal file
147
docs/planning/mifi-planning.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
mifi.dev one-page static landing — plan
|
||||||
|
|
||||||
|
1. Tech stack (locked): SvelteKit (static)
|
||||||
|
|
||||||
|
Chosen stack: SvelteKit with the static adapter (@sveltejs/adapter-static). Output is pure static HTML with no client-side framework bundle or rendering; minimal JS (theme toggle, copy-link, a11y, copyright year) compiles to a small vanilla-like bundle from Svelte components.
|
||||||
|
|
||||||
|
Why SvelteKit: Components + scoped CSS; static adapter = pre-rendered HTML; data-driven at build time via load in +page.server.ts or +layout.server.ts; no React runtime; you've used it recently for another static site with good results.
|
||||||
|
|
||||||
|
Critical CSS: Implement during scaffold (not bolted on later). Use a Vite plugin (e.g. vite-plugin-critical or equivalent) or a post-build step with the critical npm package to inline critical CSS in <head>, add <link rel="preload" as="style"> in <head>, and place <link rel="stylesheet"> at end of <body>.
|
||||||
|
|
||||||
|
Content: Data-driven JSON (e.g. src/lib/data/links.json or content/links.json); load in +page.server.ts (or +page.ts with export const prerender = true) and pass to the page—all content rendered into HTML at build time.
|
||||||
|
|
||||||
|
Package manager: pnpm (lockfile pnpm-lock.yaml); all install/build/test scripts assume pnpm.
|
||||||
|
|
||||||
|
Language: TypeScript (always); all app and config code in TypeScript.
|
||||||
|
|
||||||
|
CSS: PostCSS for nesting and future CSS features (e.g. postcss-nested, postcss-preset-env); integrate with Vite/SvelteKit so component and global CSS are processed consistently.
|
||||||
|
|
||||||
|
2. Repo, deploy, and infrastructure
|
||||||
|
|
||||||
|
Repo: git.mifi.dev/mifi-holdings/mifi-dev-landing.
|
||||||
|
|
||||||
|
Deploy: Webhook from Gitea to Portainer stack; stack defined by docker-compose in repo.
|
||||||
|
|
||||||
|
docker-compose (in repo):
|
||||||
|
|
||||||
|
Service: nginx (small footprint, low memory) serving the built static site.
|
||||||
|
|
||||||
|
Traefik labels for:
|
||||||
|
|
||||||
|
Hosts: mifi.dev, www.mifi.dev
|
||||||
|
|
||||||
|
Use existing Docker network: marina-net
|
||||||
|
|
||||||
|
No client-side rendering; container just serves pre-built HTML/CSS/JS and assets.
|
||||||
|
|
||||||
|
CI (Woodpecker) will run on the same server as Gitea: lint, unit tests, e2e/visual regression, and build (using pnpm); deploy can be “build artifact + webhook triggers Portainer to pull and redeploy stack.”
|
||||||
|
|
||||||
|
Local development: Dev Containers (.devcontainer/) so local development matches a consistent, reproducible environment (Node, pnpm, browsers/tools as needed). Playwright in the dev container must use the same Linux build as the CI pipeline so visual-regression snapshots are the same size and comparable. Document how to open the project in a dev container in README; CI can mirror the same stack where relevant.
|
||||||
|
|
||||||
|
3. Tooling and quality
|
||||||
|
|
||||||
|
Prettier: Format HTML, CSS, JS/TS, JSON, YAML (config in repo).
|
||||||
|
|
||||||
|
Lint:
|
||||||
|
|
||||||
|
ESLint and TypeScript for TS—strict, no unsafe patterns; align with CSP (e.g. no eval, no inline event handlers in generated HTML).
|
||||||
|
|
||||||
|
Stylelint for CSS—scoped to project rules, WCAG-related rules where helpful.
|
||||||
|
|
||||||
|
Unit tests: Vitest (latest), 100% coverage for all JS (theme toggle, copy-link, a11y helpers, copyright year). No framework runtime in output = tests run against plain JS (or Svelte-compiled output).
|
||||||
|
|
||||||
|
E2E / visual regression: Playwright (latest)—e2e flows plus visual regression (screenshot diffing) for layout/design. Run in CI (Woodpecker). Dev container must use the same Linux Playwright build as CI so snapshots are the same size.
|
||||||
|
|
||||||
|
Packages: Use latest stable versions; lockfile in repo (pnpm-lock.yaml).
|
||||||
|
|
||||||
|
4. Build pipeline
|
||||||
|
|
||||||
|
Critical CSS:
|
||||||
|
|
||||||
|
Inline critical CSS in <head>.
|
||||||
|
|
||||||
|
Non-critical styles: <link rel="preload" as="style"> in <head>, actual <link rel="stylesheet"> at end of <body> (your preferred pattern; Critters or alternative implements this).
|
||||||
|
|
||||||
|
Content: JSON data file(s) (e.g. src/lib/data/links.json) defining links, labels, and any copy; consumed at build time and rendered into HTML. No client-side data fetching or rendering.
|
||||||
|
|
||||||
|
JS: Single small bundle (or one script) for: theme switch, copy-link, a11y enhancements, copyright year. Script referenced via <script src="...">; CSP is enforced by Traefik middleware.
|
||||||
|
|
||||||
|
CSS pipeline: PostCSS runs on styles (e.g. via Vite PostCSS config); nesting and future features applied before critical-CSS extraction so inlined and deferred CSS are consistent.
|
||||||
|
|
||||||
|
5. Security and standards
|
||||||
|
|
||||||
|
CSP: Set via Traefik middleware (not in app code). This plan does not include implementing CSP in the site; the reverse proxy enforces policy.
|
||||||
|
|
||||||
|
SEO: Semantic HTML first; JSON-LD (e.g. WebSite, Person, Organization) in the page; meta tags and document structure that reflect content and purpose.
|
||||||
|
|
||||||
|
Accessibility: WCAG 2.2 AAA—color contrast, focus, keyboard, screen reader, and motion/prefers-reduced-motion considered from the start. Theme toggle and copy-link must be keyboard-accessible and announced.
|
||||||
|
|
||||||
|
6. Design and content (placeholder)
|
||||||
|
|
||||||
|
Design: Modern, fun, slightly cheeky, with personality; palette and light ideas (imagery, emoji, fonts) to be applied in implementation; plan assumes placeholder structure and components so copy and visuals can be dropped in later.
|
||||||
|
|
||||||
|
Page type: Single-page Linktree-style layout—links to LLC site, Facebook, IG (US and Brazilian), LinkedIn, Github, etc., with clear sections and optional short copy.
|
||||||
|
|
||||||
|
Imagery/emoji/fonts: To be specified in a follow-up; architecture will support semantic sections and components so design can evolve without changing stack.
|
||||||
|
|
||||||
|
7. Documentation and LLM guidance
|
||||||
|
|
||||||
|
README.md: Fully document setup (prereqs, pnpm, dev container), all scripts (pnpm install, pnpm dev, pnpm build, pnpm preview, pnpm lint, pnpm test, pnpm test:e2e, etc.), and how to run/deploy locally and via CI. New contributors and agents should be able to follow README to get from clone to running site and tests.
|
||||||
|
|
||||||
|
AGENTS.md: Project-level guidance for LLM agents (goals, stack, conventions, where key config and code live). Add and refine as we go so agents work efficiently and accurately.
|
||||||
|
|
||||||
|
Other LLM prompts/rules/hinting: Add Cursor rules (e.g. .cursor/rules/), RULE.md files, or similar project-specific hints as needed so agents respect pnpm, PostCSS, dev container, a11y, and repo structure.
|
||||||
|
|
||||||
|
8. High-level architecture (conceptual)
|
||||||
|
|
||||||
|
flowchart LR
|
||||||
|
subgraph repo [Repo]
|
||||||
|
Data[content YAML/JSON]
|
||||||
|
Components[Components/Templates]
|
||||||
|
Styles[CSS chunks]
|
||||||
|
Script[Minimal JS]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph build [Build]
|
||||||
|
Data --> Build
|
||||||
|
Components --> Build
|
||||||
|
Styles --> Build
|
||||||
|
Script --> Build
|
||||||
|
Build --> Critical[Critical CSS inline]
|
||||||
|
Build --> HTML[Static HTML]
|
||||||
|
Build --> Assets[CSS/JS assets]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph deploy [Deploy]
|
||||||
|
HTML --> Container
|
||||||
|
Assets --> Container
|
||||||
|
Container --> Traefik[Traefik marina-net]
|
||||||
|
Traefik --> Users[mifi.dev / www.mifi.dev]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ci [Woodpecker CI]
|
||||||
|
Lint[Lint]
|
||||||
|
Unit[Vitest]
|
||||||
|
E2E[Playwright E2E + VR]
|
||||||
|
Lint --> Build
|
||||||
|
Unit --> Build
|
||||||
|
E2E --> Build
|
||||||
|
end
|
||||||
|
|
||||||
|
9. Suggested next steps (implementation)
|
||||||
|
|
||||||
|
Scaffold SvelteKit: Create project with TypeScript, pnpm, @sveltejs/adapter-static, PostCSS (nesting + preset-env), Prettier, ESLint, Stylelint, Vitest, Playwright; configure static prerender and critical CSS during scaffold (post-build step or Vite plugin).
|
||||||
|
|
||||||
|
Dev container: Add .devcontainer/ (Node + pnpm, Playwright with same Linux build as CI for consistent snapshot dimensions) and document in README how to open and use it.
|
||||||
|
|
||||||
|
README and AGENTS.md: Add README.md with full setup and script documentation; add AGENTS.md with project overview and conventions for LLM agents.
|
||||||
|
|
||||||
|
Content shape: Add JSON data file(s) (e.g. src/lib/data/links.json) and load in +page.server.ts / +page.ts so links/sections are data-driven placeholders.
|
||||||
|
|
||||||
|
Docker + Traefik: Add docker-compose with nginx static file server and Traefik labels for mifi.dev, www.mifi.dev, and marina-net.
|
||||||
|
|
||||||
|
Woodpecker: Add pipeline for lint → unit → e2e/visual regression → build; document webhook → Portainer deploy.
|
||||||
|
|
||||||
|
SEO and semantics: Semantic layout and JSON-LD in app.html / layout (CSP is set via Traefik middleware; no app-side CSP work).
|
||||||
|
|
||||||
|
A11y and design: Apply WCAG 2.2 AAA and your palette/imagery/emoji/fonts in Svelte components and styles.
|
||||||
8
e2e/example.spec.ts
Normal file
8
e2e/example.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('homepage has title and main content', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/mifi\.dev/);
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('mifi.dev');
|
||||||
|
await expect(page.getByRole('main')).toBeVisible();
|
||||||
|
});
|
||||||
58
package.json
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "mifi-linktree",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev:bio": "CONTENT_VARIANT=bio vite dev",
|
||||||
|
"dev:dev": "CONTENT_VARIANT=dev vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:full": "vite build && pnpm run critical-css",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "eslint . && stylelint 'src/**/*.css'",
|
||||||
|
"lint:fix": "eslint . --fix && stylelint 'src/**/*.css' --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"critical-css": "node scripts/critical-css.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
|
"@sveltejs/kit": "^2.14.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.16.0",
|
||||||
|
"@typescript-eslint/parser": "^8.16.0",
|
||||||
|
"@vitest/coverage-v8": "^2.1.0",
|
||||||
|
"critical": "^7.0.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"postcss-html": "^1.0.0",
|
||||||
|
"postcss-load-config": "^6.0.0",
|
||||||
|
"postcss-nested": "^6.0.1",
|
||||||
|
"postcss-preset-env": "^11.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.2.0",
|
||||||
|
"puppeteer": "^23.0.0",
|
||||||
|
"stylelint": "^16.11.0",
|
||||||
|
"stylelint-config-standard": "^36.0.1",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"svelte-eslint-parser": "^0.43.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/svelte": "^0.563.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
playwright.config.ts
Normal file
26
playwright.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:4173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm run preview',
|
||||||
|
url: 'http://localhost:4173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
10043
pnpm-lock.yaml
generated
Normal file
10043
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
postcss.config.js
Normal file
9
postcss.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'postcss-nested': {},
|
||||||
|
'postcss-preset-env': {
|
||||||
|
stage: 2,
|
||||||
|
features: { 'nesting-rules': false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
36
scripts/critical-css.mjs
Normal file
36
scripts/critical-css.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Post-build step: inline critical CSS in built HTML.
|
||||||
|
* Reads build/index.html, extracts critical CSS, inlines in <head>,
|
||||||
|
* and defers non-critical styles (preload + link at end of body).
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const buildDir = join(__dirname, '..', 'build');
|
||||||
|
const htmlPath = join(buildDir, 'index.html');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { generate } = await import('critical');
|
||||||
|
const html = readFileSync(htmlPath, 'utf-8');
|
||||||
|
const { html: outHtml } = await generate({
|
||||||
|
base: buildDir,
|
||||||
|
html,
|
||||||
|
inline: true,
|
||||||
|
dimensions: [{ width: 1280, height: 720 }],
|
||||||
|
penthouse: { timeout: 30000 },
|
||||||
|
});
|
||||||
|
writeFileSync(htmlPath, outHtml, 'utf-8');
|
||||||
|
console.log('Critical CSS inlined in build/index.html');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('Critical CSS step failed:', msg);
|
||||||
|
if (msg.includes('Browser is not downloaded')) {
|
||||||
|
console.error(
|
||||||
|
'Install Chromium for critical CSS: pnpm exec puppeteer browsers install chromium',
|
||||||
|
);
|
||||||
|
console.error('Or run "pnpm run build" without critical CSS.');
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
142
src/app.css
Normal file
142
src/app.css
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Global base styles. Token file (tokens-dev.css / tokens-bio.css) is loaded
|
||||||
|
* by layout per variant; it defines --color-* and --font-*.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url('$lib/fonts.css');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-sans: system-ui, -apple-system, blinkmacsystemfont, sans-serif;
|
||||||
|
--font-serif: serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
font-family: var(--font-heading, var(--font-sans));
|
||||||
|
font-variation-settings: 'opsz' var(--font-heading-opsz, 36);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-body, var(--font-sans));
|
||||||
|
font-size: 100%;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-fg);
|
||||||
|
transition:
|
||||||
|
background-color 0.25s ease,
|
||||||
|
color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -100%;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-bg);
|
||||||
|
z-index: 100;
|
||||||
|
transition: top 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
|
||||||
|
color: var(--color-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
height: 3rem;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
padding: 0;
|
||||||
|
width: 3rem;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-btn {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--color-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app.html
Normal file
14
src/app.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="preload" href="/assets/js/theme-store.js" as="script" />
|
||||||
|
<title>mifi.dev</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<script src="/assets/js/theme-store.js"></script>
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
71
src/lib/components/ContactPanel.svelte
Normal file
71
src/lib/components/ContactPanel.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ProcessedLink } from '$lib/data/types';
|
||||||
|
|
||||||
|
import LinkIcon from './LinkIcon.svelte';
|
||||||
|
import Panel from './Panel.svelte';
|
||||||
|
import IconContact from './icons/IconContact.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = false,
|
||||||
|
links = [],
|
||||||
|
onclose = () => {},
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
links?: ProcessedLink[];
|
||||||
|
onclose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const componentId = $props.id();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Panel {open} icon={IconContact} title="Contact" {onclose}>
|
||||||
|
{#snippet children()}
|
||||||
|
<ul class="contact-panel-list">
|
||||||
|
{#each links as link (link.label)}
|
||||||
|
{@const descriptionId = `${componentId}-description-${link.label}`}
|
||||||
|
<li>
|
||||||
|
{#if link.description}
|
||||||
|
<div id={descriptionId} class="description">{link.description}</div>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="panel-btn"
|
||||||
|
onclick={onclose}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-describedby={link.description ? descriptionId : undefined}
|
||||||
|
>
|
||||||
|
{#if link.icon}
|
||||||
|
<LinkIcon href={link.href} icon={link.icon} label={link.label} />
|
||||||
|
{/if}
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/snippet}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.contact-panel-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-panel-list li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--color-secondary-muted);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
src/lib/components/Footer.svelte
Normal file
18
src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<footer class="footer">
|
||||||
|
<p>© {new Date().getFullYear()} Mike Fitzpatrick. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
color: var(--color-secondary-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
& p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
293
src/lib/components/Hero.svelte
Normal file
293
src/lib/components/Hero.svelte
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { HeroLayout } from '$lib/data/constants';
|
||||||
|
import type { Site } from '$lib/data/types';
|
||||||
|
import IconContact from './icons/IconContact.svelte';
|
||||||
|
import IconShare from './icons/IconShare.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
contactOpen = $bindable(),
|
||||||
|
heroLayout,
|
||||||
|
location,
|
||||||
|
person,
|
||||||
|
profileImage,
|
||||||
|
pronouns,
|
||||||
|
pronunciation,
|
||||||
|
shareOpen = $bindable(),
|
||||||
|
showContactButton,
|
||||||
|
} = $props<Site & { contactOpen: boolean; shareOpen: boolean; showContactButton: boolean }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="hero-backdrop" aria-label="Header background">
|
||||||
|
{#if profileImage}
|
||||||
|
<div
|
||||||
|
class="hero-bg"
|
||||||
|
style="background-image: url('/assets/images/{profileImage}.webp')"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
<div class="hero-actions">
|
||||||
|
{#if showContactButton}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
aria-label="Contact"
|
||||||
|
onclick={() => {
|
||||||
|
contactOpen = true;
|
||||||
|
shareOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconContact size={20} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action"
|
||||||
|
aria-label="Share"
|
||||||
|
onclick={() => {
|
||||||
|
shareOpen = true;
|
||||||
|
contactOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconShare size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card hero-header-wrapper">
|
||||||
|
<header class="hero-header" data-layout={heroLayout ?? 'side-by-side'}>
|
||||||
|
{#if profileImage}
|
||||||
|
<div class="hero-avatar">
|
||||||
|
<img
|
||||||
|
src="/assets/images/{profileImage}.webp"
|
||||||
|
alt="{person?.name ?? 'mifi'} profile"
|
||||||
|
width="160"
|
||||||
|
height="160"
|
||||||
|
fetchpriority="high"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1 class="wordmark">mifi</h1>
|
||||||
|
<h2 class="wordmark-explainer">
|
||||||
|
<strong>Mi</strong>ke <strong>Fi</strong>tzpatrick
|
||||||
|
</h2>
|
||||||
|
{#if pronunciation || pronouns || location}
|
||||||
|
<p class="hero-meta">
|
||||||
|
{#if pronunciation}<span>{pronunciation}</span>{/if}
|
||||||
|
{#if pronunciation && pronouns}
|
||||||
|
<span class="hero-meta-sep" aria-hidden="true"> · </span>{/if}
|
||||||
|
{#if pronouns}<span>{pronouns}</span>{/if}
|
||||||
|
{#if pronouns && location}
|
||||||
|
{#if (heroLayout ?? HeroLayout.SIDE_BY_SIDE) === HeroLayout.SIDE_BY_SIDE}
|
||||||
|
<br />
|
||||||
|
{:else}
|
||||||
|
<span class="hero-meta-sep" aria-hidden="true"> · </span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if location}<span>{location}</span>{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
{#if showContactButton}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button"
|
||||||
|
aria-label="Contact"
|
||||||
|
onclick={() => {
|
||||||
|
contactOpen = true;
|
||||||
|
shareOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconContact size={20} />
|
||||||
|
<span>Contact</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button"
|
||||||
|
aria-label="Share"
|
||||||
|
onclick={() => {
|
||||||
|
shareOpen = true;
|
||||||
|
contactOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconShare size={20} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero-backdrop {
|
||||||
|
position: relative;
|
||||||
|
height: 24vh;
|
||||||
|
min-height: 12rem;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-size: 220%;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
filter: blur(40px);
|
||||||
|
transform: scale(1.1);
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--color-bg) 0%,
|
||||||
|
transparent 50%,
|
||||||
|
var(--color-bg) 100%
|
||||||
|
);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-header-wrapper {
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 4px 1.5rem rgba(0, 0, 0, 0.08);
|
||||||
|
margin: -6rem auto 0;
|
||||||
|
max-width: 50ch;
|
||||||
|
overflow: visible;
|
||||||
|
padding: 1rem 1.5rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero content (avatar + wordmark) inside card */
|
||||||
|
.hero-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&[data-layout='side-by-side'] {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
'avatar content'
|
||||||
|
'buttons buttons';
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
gap: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar: extends outside card top (half in card, half above) */
|
||||||
|
.hero-avatar {
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
border: 3px solid var(--color-surface);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
grid-area: avatar;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 8rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8rem;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
grid-area: content;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-family: var(--font-wordmark, var(--font-sans));
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: clamp(2.5rem, 5vw, 3.5rem);
|
||||||
|
letter-spacing: unset;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-variant-ligatures: common-ligatures discretionary-ligatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark-explainer {
|
||||||
|
font-family: var(--font-wordmark, var(--font-sans));
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-primary-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
|
||||||
|
& strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
font-family: var(--font-body, var(--font-sans));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-secondary-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta-sep {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
grid-area: buttons;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
border: 1px solid var(--color-secondary-muted);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--color-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 50%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease-in-out;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background: var(--color-secondary-muted);
|
||||||
|
color: var(--color-surface-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
src/lib/components/Link.svelte
Normal file
80
src/lib/components/Link.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ProcessedLink } from '$lib/data/types';
|
||||||
|
import LinkIcon from './LinkIcon.svelte';
|
||||||
|
|
||||||
|
let { href, icon, label, description }: ProcessedLink = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a {href} rel="noopener noreferrer" target="_blank" class="link">
|
||||||
|
<span class="icon" aria-hidden="true">
|
||||||
|
<LinkIcon {href} {icon} {label} />
|
||||||
|
</span>
|
||||||
|
<div class="link-row-content">
|
||||||
|
<span class="title">{label}</span>
|
||||||
|
{#if description}
|
||||||
|
<span class="description">{description}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
& .title {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .description {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .icon {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title,
|
||||||
|
.description,
|
||||||
|
.icon {
|
||||||
|
transition:
|
||||||
|
color 0.2s ease-in-out,
|
||||||
|
text-decoration 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-link);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--color-secondary-muted);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
src/lib/components/LinkGroup.svelte
Normal file
58
src/lib/components/LinkGroup.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ProcessedLink, Section } from '$lib/data/types';
|
||||||
|
|
||||||
|
import Link from './Link.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
links,
|
||||||
|
order,
|
||||||
|
showHeading = false,
|
||||||
|
title,
|
||||||
|
} = $props<
|
||||||
|
Omit<Section, 'links' | 'order'> & {
|
||||||
|
links: ProcessedLink[];
|
||||||
|
order: number;
|
||||||
|
showHeading?: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby={`section-${id}`} style="order:{order}">
|
||||||
|
{#if showHeading}<h3 id={`section-${id}`} class="link-section-heading heading">
|
||||||
|
{title}
|
||||||
|
</h3>{/if}
|
||||||
|
<ul class="link-list">
|
||||||
|
{#each links as link (link.label)}
|
||||||
|
<li class="link-row">
|
||||||
|
<Link {...link} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.link-section-heading {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-family: var(--font-heading, var(--font-sans));
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variation-settings: 'opsz' var(--font-heading-opsz, 36);
|
||||||
|
letter-spacing: 0.045em;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
src/lib/components/LinkIcon.svelte
Normal file
60
src/lib/components/LinkIcon.svelte
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export type IconName =
|
||||||
|
| 'GitHub'
|
||||||
|
| 'LinkedIn'
|
||||||
|
| 'Instagram'
|
||||||
|
| 'Facebook'
|
||||||
|
| 'YouTube'
|
||||||
|
| 'TikTok'
|
||||||
|
| 'Snapchat'
|
||||||
|
| 'Discord'
|
||||||
|
| 'Strava'
|
||||||
|
| 'Flickr'
|
||||||
|
| 'Calendar'
|
||||||
|
| 'Resume'
|
||||||
|
| 'Mifi';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import IconLink from './icons/IconLink.svelte';
|
||||||
|
import IconGitHub from './icons/IconGitHub.svelte';
|
||||||
|
import IconLinkedIn from './icons/IconLinkedIn.svelte';
|
||||||
|
import IconInstagram from './icons/IconInstagram.svelte';
|
||||||
|
import IconFacebook from './icons/IconFacebook.svelte';
|
||||||
|
import IconYouTube from './icons/IconYouTube.svelte';
|
||||||
|
import IconTikTok from './icons/IconTikTok.svelte';
|
||||||
|
import IconSnapchat from './icons/IconSnapchat.svelte';
|
||||||
|
import IconDiscord from './icons/IconDiscord.svelte';
|
||||||
|
import IconStrava from './icons/IconStrava.svelte';
|
||||||
|
import IconFlickr from './icons/IconFlickr.svelte';
|
||||||
|
import IconCal from './icons/IconCal.svelte';
|
||||||
|
import IconResume from './icons/IconResume.svelte';
|
||||||
|
import IconMi from './icons/IconMi.svelte';
|
||||||
|
import type { Link } from '$lib/data/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
size = 24,
|
||||||
|
} = $props<Pick<Link, 'href' | 'icon' | 'label'> & { size?: number }>();
|
||||||
|
|
||||||
|
const iconMap: Record<IconName, typeof IconLink> = {
|
||||||
|
GitHub: IconGitHub,
|
||||||
|
LinkedIn: IconLinkedIn,
|
||||||
|
Instagram: IconInstagram,
|
||||||
|
Facebook: IconFacebook,
|
||||||
|
YouTube: IconYouTube,
|
||||||
|
TikTok: IconTikTok,
|
||||||
|
Snapchat: IconSnapchat,
|
||||||
|
Discord: IconDiscord,
|
||||||
|
Strava: IconStrava,
|
||||||
|
Flickr: IconFlickr,
|
||||||
|
Calendar: IconCal,
|
||||||
|
Resume: IconResume,
|
||||||
|
Mifi: IconMi,
|
||||||
|
};
|
||||||
|
|
||||||
|
const IconComponent = $derived(iconMap[(icon || label) as IconName] ?? IconLink);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<IconComponent {size} />
|
||||||
169
src/lib/components/Panel.svelte
Normal file
169
src/lib/components/Panel.svelte
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
icon: IconComponent,
|
||||||
|
onclose = () => {},
|
||||||
|
open = false,
|
||||||
|
title = '',
|
||||||
|
}: {
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
icon?: Component<{ size?: number }>;
|
||||||
|
onclose: () => void;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let dialogEl: HTMLDialogElement | undefined;
|
||||||
|
let closeBtnEl: HTMLButtonElement | undefined;
|
||||||
|
|
||||||
|
function handleDialogClose() {
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dialogEl) return;
|
||||||
|
if (open) {
|
||||||
|
dialogEl.showModal();
|
||||||
|
void tick().then(() => closeBtnEl?.focus());
|
||||||
|
} else {
|
||||||
|
dialogEl.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialogEl}
|
||||||
|
class="panel-dialog"
|
||||||
|
closedby="any"
|
||||||
|
aria-labelledby="panel-title"
|
||||||
|
aria-modal="true"
|
||||||
|
onclose={handleDialogClose}
|
||||||
|
oncancel={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onclose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="panel">
|
||||||
|
<header class="header">
|
||||||
|
<h2 id="panel-title" class="title heading">
|
||||||
|
{#if IconComponent}
|
||||||
|
<IconComponent size={24} />
|
||||||
|
{/if}
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-button"
|
||||||
|
aria-label="Close"
|
||||||
|
bind:this={closeBtnEl}
|
||||||
|
onclick={onclose}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel-dialog {
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
max-height: 80vh;
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
inset: auto;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
bottom: auto;
|
||||||
|
max-width: min(28rem, calc(100vw - 2rem));
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem 1rem 0 0;
|
||||||
|
box-shadow: 0 -4px 1rem rgb(0 0 0 / 15%);
|
||||||
|
flex-direction: column;
|
||||||
|
display: flex;
|
||||||
|
max-height: 70vh;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-fg);
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-family: var(--font-heading, var(--font-sans));
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
src/lib/components/SharePanel.svelte
Normal file
100
src/lib/components/SharePanel.svelte
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Panel from './Panel.svelte';
|
||||||
|
import IconCopy from './icons/IconCopy.svelte';
|
||||||
|
import IconEmail from './icons/IconEmail.svelte';
|
||||||
|
import IconShare from './icons/IconShare.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = false,
|
||||||
|
url = '',
|
||||||
|
qrCodeImage = '',
|
||||||
|
emailSubject = 'Link from mifi',
|
||||||
|
emailBody = '',
|
||||||
|
onclose = () => {},
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
url: string;
|
||||||
|
qrCodeImage?: string | null;
|
||||||
|
emailSubject?: string;
|
||||||
|
emailBody?: string;
|
||||||
|
onclose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.clipboard) return;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function share() {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.share) return;
|
||||||
|
navigator
|
||||||
|
.share({ title: 'mifi', url })
|
||||||
|
.then(() => onclose())
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canShare = $derived(typeof navigator !== 'undefined' && !!navigator.share);
|
||||||
|
const mailtoHref = $derived(
|
||||||
|
`mailto:?subject=${encodeURIComponent(emailSubject)}&body=${encodeURIComponent(emailBody || url)}`,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Panel {open} icon={IconShare} title="Share" {onclose}>
|
||||||
|
{#snippet children()}
|
||||||
|
<div class="share-panel">
|
||||||
|
{#if qrCodeImage}
|
||||||
|
<div class="share-qr">
|
||||||
|
<img
|
||||||
|
src="/assets/images/{qrCodeImage}.png"
|
||||||
|
alt="QR code for this page"
|
||||||
|
width="160"
|
||||||
|
height="160"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button type="button" class="panel-btn" onclick={copyLink}>
|
||||||
|
<IconCopy size={20} />
|
||||||
|
{copied ? 'Copied!' : 'Copy link'}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={mailtoHref}
|
||||||
|
class="panel-btn"
|
||||||
|
onclick={onclose}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IconEmail size={20} />
|
||||||
|
Email link
|
||||||
|
</a>
|
||||||
|
{#if canShare}
|
||||||
|
<button type="button" class="panel-btn" onclick={share}>
|
||||||
|
<IconShare size={20} />
|
||||||
|
Share…
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.share-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-qr {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
157
src/lib/components/ThemeToggle.svelte
Normal file
157
src/lib/components/ThemeToggle.svelte
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Moon, SunMoon, Sun } from '@lucide/svelte';
|
||||||
|
import { getStoredTheme, setTheme } from '$lib/theme';
|
||||||
|
import type { ThemeMode } from '$lib/theme';
|
||||||
|
|
||||||
|
const SLOT_WIDTH = 48;
|
||||||
|
const GAP = 8;
|
||||||
|
/** When collapsed, track translateX so the active option is in the 48px viewport (left=Light, middle=Dark, right=Auto). */
|
||||||
|
const OFFSETS: Record<ThemeMode, number> = {
|
||||||
|
light: 0,
|
||||||
|
dark: -(SLOT_WIDTH + GAP),
|
||||||
|
auto: -(SLOT_WIDTH + GAP) * 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = $state<ThemeMode>('auto');
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
const themeOffset = $derived(OFFSETS[current]);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
current = getStoredTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
function choose(mode: ThemeMode) {
|
||||||
|
setTheme(mode);
|
||||||
|
current = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(mode: ThemeMode) {
|
||||||
|
if (expanded) {
|
||||||
|
choose(mode);
|
||||||
|
expanded = false;
|
||||||
|
} else {
|
||||||
|
expanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<!-- Click outside to collapse -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-toggle-backdrop"
|
||||||
|
aria-label="Close theme menu"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={() => (expanded = false)}
|
||||||
|
></button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="theme-toggle"
|
||||||
|
class:expanded
|
||||||
|
role="group"
|
||||||
|
aria-label="Color theme"
|
||||||
|
style="--theme-offset: {themeOffset}px;"
|
||||||
|
>
|
||||||
|
<div class="theme-toggle-track">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action theme-option"
|
||||||
|
class:active={expanded && current === 'light'}
|
||||||
|
aria-label="Light"
|
||||||
|
aria-current={current === 'light' ? 'true' : undefined}
|
||||||
|
title="Light"
|
||||||
|
onclick={() => handleClick('light')}
|
||||||
|
>
|
||||||
|
<Sun size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action theme-option"
|
||||||
|
class:active={expanded && current === 'dark'}
|
||||||
|
aria-label="Dark"
|
||||||
|
aria-current={current === 'dark' ? 'true' : undefined}
|
||||||
|
title="Dark"
|
||||||
|
onclick={() => handleClick('dark')}
|
||||||
|
>
|
||||||
|
<Moon size={24} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="action theme-option"
|
||||||
|
class:active={expanded && current === 'auto'}
|
||||||
|
aria-label="Auto (system)"
|
||||||
|
aria-current={current === 'auto' ? 'true' : undefined}
|
||||||
|
title="Auto (system)"
|
||||||
|
onclick={() => handleClick('auto')}
|
||||||
|
>
|
||||||
|
<SunMoon size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.theme-toggle-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed: match hero-action (other header buttons); expanded: glass panel */
|
||||||
|
.theme-toggle {
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
transition:
|
||||||
|
width 0.22s ease-out,
|
||||||
|
padding 0.22s ease-out,
|
||||||
|
border-radius 0.22s ease-out,
|
||||||
|
background 0.22s ease-out,
|
||||||
|
box-shadow 0.22s ease-out,
|
||||||
|
backdrop-filter 0.22s ease-out;
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle.expanded {
|
||||||
|
width: 176px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-surface-elevated) 52%, transparent);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.2);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in srgb, white 12%, transparent) inset,
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.12),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 160px;
|
||||||
|
transform: translateX(var(--theme-offset));
|
||||||
|
transition: transform 0.22s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle.expanded .theme-toggle-track {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
src/lib/components/icons/IconCal.svelte
Normal file
22
src/lib/components/icons/IconCal.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/** Calendar / booking icon. */
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
18
src/lib/components/icons/IconContact.svelte
Normal file
18
src/lib/components/icons/IconContact.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
19
src/lib/components/icons/IconCopy.svelte
Normal file
19
src/lib/components/icons/IconCopy.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 } = $props<{ size?: number }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconDiscord.svelte
Normal file
16
src/lib/components/icons/IconDiscord.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
21
src/lib/components/icons/IconEmail.svelte
Normal file
21
src/lib/components/icons/IconEmail.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 } = $props<{ size?: number }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"
|
||||||
|
/>
|
||||||
|
<path d="m21.854 2.147-10.94 10.939" />
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconFacebook.svelte
Normal file
16
src/lib/components/icons/IconFacebook.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconFlickr.svelte
Normal file
16
src/lib/components/icons/IconFlickr.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.5 12c0-1.653 1.347-3 3-3s3 1.347 3 3-1.347 3-3 3-3-1.347-3-3zm10.5 0c0-1.653 1.347-3 3-3s3 1.347 3 3-1.347 3-3 3-3-1.347-3-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconGitHub.svelte
Normal file
16
src/lib/components/icons/IconGitHub.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconInstagram.svelte
Normal file
16
src/lib/components/icons/IconInstagram.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 0C8.74 0 8.333.015 7.053.072 5.775.132 4.905.333 4.14.63c-.789.306-1.459.717-2.126 1.384S.935 3.35.63 4.14C.333 4.905.131 5.775.072 7.053.012 8.333 0 8.74 0 12s.015 3.667.072 4.947c.06 1.277.261 2.148.558 2.913.306.788.717 1.459 1.384 2.126.667.666 1.336 1.079 2.126 1.384.766.296 1.636.499 2.913.558C8.333 23.988 8.74 24 12 24s3.667-.015 4.947-.072c1.277-.06 2.148-.262 2.913-.558.788-.306 1.459-.718 2.126-1.384.666-.667 1.079-1.335 1.384-2.126.296-.765.499-1.636.558-2.913.06-1.28.072-1.687.072-4.947s-.015-3.667-.072-4.947c-.06-1.277-.262-2.149-.558-2.913-.306-.789-.718-1.459-1.384-2.126C21.319 1.347 20.651.935 19.86.63c-.765-.297-1.636-.499-2.913-.558C15.667.012 15.26 0 12 0zm0 2.16c3.203 0 3.585.016 4.85.071 1.17.055 1.805.249 2.227.415.562.217.96.477 1.382.896.419.42.679.819.896 1.381.164.422.36 1.057.413 2.227.057 1.266.07 1.646.07 4.85s-.015 3.585-.074 4.85c-.061 1.17-.256 1.805-.421 2.227-.224.562-.479.96-.899 1.382-.419.419-.824.679-1.38.896-.42.164-1.065.36-2.235.413-1.274.057-1.649.07-4.859.07-3.211 0-3.586-.015-4.859-.074-1.171-.061-1.816-.256-2.236-.421-.569-.224-.96-.479-1.379-.899-.421-.419-.69-.824-.9-1.38-.165-.42-.359-1.065-.42-2.235-.045-1.26-.061-1.649-.061-4.844 0-3.196.016-3.586.061-4.861.061-1.17.255-1.814.42-2.234.21-.57.479-.96.9-1.381.419-.419.81-.689 1.379-.898.42-.166 1.051-.361 2.221-.421 1.275-.045 1.65-.06 4.859-.06l.045.03zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162S8.597 20.163 12 20.163s6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zM12 16c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm7.846-10.405a1.441 1.441 0 0 1-2.88 0 1.44 1.44 0 0 1 2.88 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
20
src/lib/components/icons/IconLink.svelte
Normal file
20
src/lib/components/icons/IconLink.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/** Default/link icon. 24×24, currentColor. */
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconLinkedIn.svelte
Normal file
16
src/lib/components/icons/IconLinkedIn.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconMi.svelte
Normal file
16
src/lib/components/icons/IconMi.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 2134 2134"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2133.333,400l0,1333.333c0,220.766 -179.234,400 -400,400l-1333.333,0c-220.766,0 -400,-179.234 -400,-400l0,-1333.333c0,-220.766 179.234,-400 400,-400l1333.333,0c220.766,0 400,179.234 400,400Zm-400.047,334.954l221.363,0l0,-236.121l-221.363,0l0,236.121Zm-806.91,230.385c-20.078,-34.566 -48.157,-64.067 -84.236,-88.502c-57.484,-38.932 -123.612,-58.398 -198.384,-58.398c-68.587,0 -128.391,16.304 -179.41,48.911c-32.843,20.991 -58.493,48.971 -76.95,83.94l0,-112.612l-208.714,0l0,916.655l221.363,0l0,-538.018c0,-40.478 7.379,-75.264 22.136,-104.357c14.758,-29.093 35.488,-51.722 62.193,-67.885c26.704,-16.163 57.765,-24.245 93.183,-24.245c36.261,0 67.463,8.082 93.605,24.245c26.142,16.163 46.521,38.721 61.138,67.674c14.617,28.953 21.926,63.809 21.926,104.568l0,538.018l221.363,0l0,-538.018c0,-40.478 7.379,-75.264 22.136,-104.357c14.758,-29.093 35.559,-51.722 62.403,-67.885c26.845,-16.163 57.836,-24.245 92.973,-24.245c36.261,0 67.463,8.082 93.605,24.245c26.142,16.163 46.521,38.721 61.138,67.674c14.617,28.953 21.926,63.809 21.926,104.568l0,538.018l221.363,0l0,-590.724c0,-68.306 -14.828,-128.461 -44.483,-180.464c-29.656,-52.003 -70.063,-92.621 -121.223,-121.855c-51.16,-29.234 -109.206,-43.851 -174.139,-43.851c-73.366,0 -138.089,18.06 -194.167,54.181c-35.933,23.145 -66.182,54.051 -90.747,92.718Zm806.91,789.994l221.363,0l0,-916.655l-221.363,0l0,916.655Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
22
src/lib/components/icons/IconResume.svelte
Normal file
22
src/lib/components/icons/IconResume.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/** PDF document / resume icon. */
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 640 640"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<!--!Font Awesome Pro v7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2026 Fonticons, Inc.-->
|
||||||
|
<path
|
||||||
|
d="M240 112L128 112C119.2 112 112 119.2 112 128L112 512C112 520.8 119.2 528 128 528L208 528L208 576L128 576C92.7 576 64 547.3 64 512L64 128C64 92.7 92.7 64 128 64L261.5 64C278.5 64 294.8 70.7 306.8 82.7L429.3 205.3C441.3 217.3 448 233.6 448 250.6L448 400.1L400 400.1L400 272.1L312 272.1C272.2 272.1 240 239.9 240 200.1L240 112.1zM380.1 224L288 131.9L288 200C288 213.3 298.7 224 312 224L380.1 224zM272 444L304 444C337.1 444 364 470.9 364 504C364 537.1 337.1 564 304 564L292 564L292 592C292 603 283 612 272 612C261 612 252 603 252 592L252 464C252 453 261 444 272 444zM304 524C315 524 324 515 324 504C324 493 315 484 304 484L292 484L292 524L304 524zM400 444L432 444C460.7 444 484 467.3 484 496L484 560C484 588.7 460.7 612 432 612L400 612C389 612 380 603 380 592L380 464C380 453 389 444 400 444zM432 572C438.6 572 444 566.6 444 560L444 496C444 489.4 438.6 484 432 484L420 484L420 572L432 572zM508 464C508 453 517 444 528 444L576 444C587 444 596 453 596 464C596 475 587 484 576 484L548 484L548 508L576 508C587 508 596 517 596 528C596 539 587 548 576 548L548 548L548 592C548 603 539 612 528 612C517 612 508 603 508 592L508 464z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
20
src/lib/components/icons/IconShare.svelte
Normal file
20
src/lib/components/icons/IconShare.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||||
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconSnapchat.svelte
Normal file
16
src/lib/components/icons/IconSnapchat.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.052-.225-.015-.239.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.333-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.089-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconStrava.svelte
Normal file
16
src/lib/components/icons/IconStrava.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15.387 17.944l-2.089-4.116h-3.065L15.387 24l5.15-10.172h-3.066m-7.008-5.113l2.836 5.116h3.172l-4.994-9.016c-.363-.636-1.134-.828-1.771-.828-.576 0-1.224.192-1.589.644L2.086 17.944h3.157l2.136-4.113zm4.008-5.113c-.748 0-1.271.312-1.271.312v3.177h2.453c.261 0 .523-.052.784-.156.523-.208.888-.572 1.044-1.036.156-.468.104-.988-.156-1.456-.416-.728-1.304-1.192-2.804-1.192z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconTikTok.svelte
Normal file
16
src/lib/components/icons/IconTikTok.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
16
src/lib/components/icons/IconYouTube.svelte
Normal file
16
src/lib/components/icons/IconYouTube.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 24 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
84
src/lib/config.test.ts
Normal file
84
src/lib/config.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/lib/config.ts
Normal file
43
src/lib/config.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/lib/data/constants.test.ts
Normal file
31
src/lib/data/constants.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/lib/data/constants.ts
Normal file
14
src/lib/data/constants.ts
Normal file
@@ -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',
|
||||||
|
}
|
||||||
218
src/lib/data/links.json
Normal file
218
src/lib/data/links.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
76
src/lib/data/types.test.ts
Normal file
76
src/lib/data/types.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
65
src/lib/data/types.ts
Normal file
65
src/lib/data/types.ts
Normal file
@@ -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<Link, 'variants' | 'utmContent'>;
|
||||||
|
|
||||||
|
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<ContentVariant, number | null>;
|
||||||
|
links: Link[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentData {
|
||||||
|
siteByVariant: Record<ContentVariant, Site>;
|
||||||
|
contactLinks: ContactLink[];
|
||||||
|
sections: Section[];
|
||||||
|
}
|
||||||
67
src/lib/fonts.css
Normal file
67
src/lib/fonts.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
97
src/lib/theme.test.ts
Normal file
97
src/lib/theme.test.ts
Normal file
@@ -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<string, string>;
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/lib/theme.ts
Normal file
25
src/lib/theme.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/lib/utils/getProcessedLinks.test.ts
Normal file
103
src/lib/utils/getProcessedLinks.test.ts
Normal file
@@ -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<Link> & { 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/lib/utils/getProcessedLinks.ts
Normal file
17
src/lib/utils/getProcessedLinks.ts
Normal file
@@ -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),
|
||||||
|
}));
|
||||||
|
};
|
||||||
44
src/lib/utils/getValueForVariant.test.ts
Normal file
44
src/lib/utils/getValueForVariant.test.ts
Normal file
@@ -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<ContentVariant, T>', () => {
|
||||||
|
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<ContentVariant, string>;
|
||||||
|
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<ContentVariant, string>, 'dev'),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null by returning it as T (no indexing)', () => {
|
||||||
|
expect(getValueForVariant(null as unknown as string, 'dev')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/lib/utils/getValueForVariant.ts
Normal file
14
src/lib/utils/getValueForVariant.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ContentVariant } from '$lib/data/constants';
|
||||||
|
|
||||||
|
export function getValueForVariant<T = string>(
|
||||||
|
value: T | Record<ContentVariant, T>,
|
||||||
|
variant: ContentVariant,
|
||||||
|
): T | undefined {
|
||||||
|
if (typeof value === 'object' && value !== null && variant in value) {
|
||||||
|
return (value as Record<ContentVariant, T>)[variant] ?? undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
52
src/lib/utils/isShowForVariant.test.ts
Normal file
52
src/lib/utils/isShowForVariant.test.ts
Normal file
@@ -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<Link> & { 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
7
src/lib/utils/isShowForVariant.ts
Normal file
7
src/lib/utils/isShowForVariant.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
27
src/lib/utm.test.ts
Normal file
27
src/lib/utm.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/lib/utm.ts
Normal file
28
src/lib/utm.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/routes/+layout.server.ts
Normal file
68
src/routes/+layout.server.ts
Normal file
@@ -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> = (): 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],
|
||||||
|
};
|
||||||
|
};
|
||||||
47
src/routes/+layout.svelte
Normal file
47
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
|
export let data: LayoutData;
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite' as const,
|
||||||
|
name: data.site.title,
|
||||||
|
url: data.site.url,
|
||||||
|
description: data.site.metaDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
$: personLd = data.site.person
|
||||||
|
? {
|
||||||
|
'@context': 'https://schema.org' as const,
|
||||||
|
'@type': 'Person' as const,
|
||||||
|
name: data.site.person.name,
|
||||||
|
url: data.site.url,
|
||||||
|
sameAs: data.site.person.sameAs,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Inject as HTML to avoid Prettier parsing ld+json script body as JS (Babel syntax error)
|
||||||
|
const ldJsonTag = (payload: string) =>
|
||||||
|
'<' + 'script type="application/ld+json">' + payload + '<' + '/script>';
|
||||||
|
$: jsonLdHtml = ldJsonTag(JSON.stringify(jsonLd));
|
||||||
|
$: personLdHtml = personLd != null ? ldJsonTag(JSON.stringify(personLd)) : '';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="stylesheet" href="/assets/tokens-{data.variant}.css" />
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- safe: our own JSON.stringify, no user input -->
|
||||||
|
{@html jsonLdHtml}
|
||||||
|
{#if personLdHtml}
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- safe: our own JSON.stringify, no user input -->
|
||||||
|
{@html personLdHtml}
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
<header class="site-header">
|
||||||
|
<ThemeToggle />
|
||||||
|
</header>
|
||||||
|
<slot />
|
||||||
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const prerender = true;
|
||||||
3
src/routes/+page.server.ts
Normal file
3
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ parent }) => parent();
|
||||||
65
src/routes/+page.svelte
Normal file
65
src/routes/+page.svelte
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ContactPanel from '$lib/components/ContactPanel.svelte';
|
||||||
|
import Footer from '$lib/components/Footer.svelte';
|
||||||
|
import Hero from '$lib/components/Hero.svelte';
|
||||||
|
import LinkGroup from '$lib/components/LinkGroup.svelte';
|
||||||
|
import SharePanel from '$lib/components/SharePanel.svelte';
|
||||||
|
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let shareOpen = $state(false);
|
||||||
|
let contactOpen = $state(false);
|
||||||
|
|
||||||
|
const showContactButton = $derived((data?.contactLinks?.length ?? 0) > 0);
|
||||||
|
|
||||||
|
const shareUrl = $derived(data.site.url);
|
||||||
|
const shareEmailSubject = $derived(`Link from ${data.site.title}`);
|
||||||
|
const shareEmailBody = $derived(`Check out Mike Fitzpatrick's links at: ${shareUrl}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.site.title}</title>
|
||||||
|
<meta name="description" content={data.site.metaDescription} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
<Hero bind:contactOpen bind:shareOpen {showContactButton} {...data.site} {...data.links} />
|
||||||
|
<div class="page">
|
||||||
|
{#each data.links.sections as section}
|
||||||
|
<LinkGroup
|
||||||
|
id={section.id}
|
||||||
|
links={section.links}
|
||||||
|
order={section.order}
|
||||||
|
showHeading={data.links.sections.length > 1}
|
||||||
|
title={section.title}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
<SharePanel
|
||||||
|
open={shareOpen}
|
||||||
|
url={shareUrl}
|
||||||
|
qrCodeImage={data.site.qrCodeImage}
|
||||||
|
emailSubject={shareEmailSubject}
|
||||||
|
emailBody={shareEmailBody}
|
||||||
|
onclose={() => (shareOpen = false)}
|
||||||
|
/>
|
||||||
|
<ContactPanel
|
||||||
|
open={contactOpen}
|
||||||
|
links={data.contactLinks}
|
||||||
|
onclose={() => (contactOpen = false)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 50ch;
|
||||||
|
padding: 4rem 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
src/routes/__tests__/layout.server.test.ts
Normal file
101
src/routes/__tests__/layout.server.test.ts
Normal file
@@ -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-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
src/routes/__tests__/layout.test.ts
Normal file
24
src/routes/__tests__/layout.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/routes/__tests__/page.server.test.ts
Normal file
19
src/routes/__tests__/page.server.test.ts
Normal file
@@ -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<typeof load>[0];
|
||||||
|
const result = await load(event);
|
||||||
|
expect(mockParent).toHaveBeenCalledOnce();
|
||||||
|
expect(result).toEqual({ site: { title: 'Parent' }, variant: 'dev' });
|
||||||
|
});
|
||||||
|
});
|
||||||
0
static/.gitkeep
Normal file
0
static/.gitkeep
Normal file
1
static/assets/fonts/.gitkeep
Normal file
1
static/assets/fonts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
static/assets/fonts/fraunces-v38-latin-500.woff2
Normal file
BIN
static/assets/fonts/fraunces-v38-latin-500.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/fraunces-v38-latin-500italic.woff2
Normal file
BIN
static/assets/fonts/fraunces-v38-latin-500italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/fraunces-v38-latin-600.woff2
Normal file
BIN
static/assets/fonts/fraunces-v38-latin-600.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/fraunces-v38-latin-600italic.woff2
Normal file
BIN
static/assets/fonts/fraunces-v38-latin-600italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/fraunces-v38-latin-700.woff2
Normal file
BIN
static/assets/fonts/fraunces-v38-latin-700.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/fraunces-v38-latin-italic.woff2
Normal file
BIN
static/assets/fonts/fraunces-v38-latin-italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/fraunces-v38-latin-regular.woff2
Normal file
BIN
static/assets/fonts/fraunces-v38-latin-regular.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/fraunces-variable-opsz-wght.woff2
Normal file
BIN
static/assets/fonts/fraunces-variable-opsz-wght.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-500.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-500.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-500italic.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-500italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-600.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-600.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-600italic.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-600italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-700.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-700.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-700italic.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-700italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-italic.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/inter-v20-latin-regular.woff2
Normal file
BIN
static/assets/fonts/inter-v20-latin-regular.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-500.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-500italic.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-500italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-600.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-600.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-600italic.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-600italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-700.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-700italic.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-700italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-italic.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-italic.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2
Normal file
BIN
static/assets/fonts/plus-jakarta-sans-v12-latin-regular.woff2
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user