Showing preview only (746K chars total). Download the full file or copy to clipboard to get everything.
Repository: franky47/francoisbest.com
Branch: next
Commit: 676fb74ac925
Files: 177
Total size: 686.1 KB
Directory structure:
gitextract_puz3o34r/
├── .beans/
│ ├── fbst-0ilj--step-6-dark-mode-next-themes.md
│ ├── fbst-2evh--step-1-tooling-foundation-turbo-v2-oxfmt-oxlint-vi.md
│ ├── fbst-5495--step-3-dead-code-small-dependency-cleanup.md
│ ├── fbst-9pj9--modernise-francoisbestcom-codebase.md
│ ├── fbst-9w5o--step-7-mdx-pipeline-fumadocs-mdx.md
│ ├── fbst-abdk--step-8-non-blog-pages-tsx.md
│ ├── fbst-lzix--step-10-knip-final-cleanup.md
│ ├── fbst-m7lu--fix-broken-links-in-blog-posts-and-sitemap-page.md
│ ├── fbst-nv8l--restore-hireme-cta-and-edit-links-on-static-pages.md
│ ├── fbst-p7fj--step-9-dayjs-temporal.md
│ ├── fbst-qj1o--expand-sitemap-to-include-all-public-pages.md
│ ├── fbst-qp1g--step-4-nextjs-16-upgrade.md
│ ├── fbst-tzpj--fix-qa-regressions-in-modernisation-pr.md
│ ├── fbst-u6tb--fix-reading-time-computation-and-display.md
│ ├── fbst-ugwm--step-5-built-in-sitemap-robots.md
│ └── fbst-xhgq--step-2-typescript-60-upgrade.md
├── .beans.yml
├── .github/
│ └── FUNDING.yml
├── .gitignore
├── .node-version
├── .oxfmtrc.json
├── .vscode/
│ └── launch.json
├── LICENSE.txt
├── README.md
├── docs/
│ ├── authoring.md
│ └── blog-engine.md
├── package.json
├── packages/
│ └── francoisbest.com/
│ ├── .npmrc
│ ├── .oxlintrc.json
│ ├── README.md
│ ├── content/
│ │ └── blog/
│ │ ├── 2019/
│ │ │ ├── how-to-store-e2ee-keys-in-the-browser/
│ │ │ │ └── index.mdx
│ │ │ └── strava-auth-cli-in-rust/
│ │ │ └── index.mdx
│ │ ├── 2020/
│ │ │ ├── dark-mode-for-excalidraw/
│ │ │ │ ├── index.mdx
│ │ │ │ ├── status-text.tsx
│ │ │ │ └── venn.tsx
│ │ │ ├── mobile-device-frames-for-excalidraw/
│ │ │ │ ├── index.mdx
│ │ │ │ └── mobile-mockup.tsx
│ │ │ ├── password-reset-for-e2ee-apps/
│ │ │ │ └── index.mdx
│ │ │ └── the-security-of-github-actions/
│ │ │ └── index.mdx
│ │ ├── 2021/
│ │ │ ├── cargo-docker-mtime/
│ │ │ │ └── index.mdx
│ │ │ └── hashvatars/
│ │ │ └── index.mdx
│ │ └── 2023/
│ │ ├── displaying-local-times-in-nextjs/
│ │ │ └── index.mdx
│ │ ├── displaying-the-right-vercel-deployment-urls-in-nextjs/
│ │ │ └── index.mdx
│ │ ├── dotenv-is-dead/
│ │ │ └── index.mdx
│ │ ├── npm-download-stats-are-down/
│ │ │ └── index.mdx
│ │ ├── publish-a-json-schema/
│ │ │ └── index.mdx
│ │ ├── reading-files-on-vercel-during-nextjs-isr/
│ │ │ └── index.mdx
│ │ ├── storing-react-state-in-the-url-with-nextjs/
│ │ │ ├── demo.tsx
│ │ │ ├── greetings.tsx
│ │ │ ├── index.mdx
│ │ │ ├── query-spy.tsx
│ │ │ └── update-queue.tsx
│ │ └── testing-against-every-nextjs-canary-release/
│ │ ├── index.mdx
│ │ └── windowing.tsx
│ ├── knip.json
│ ├── next.config.ts
│ ├── package.json
│ ├── public/
│ │ ├── .well-known/
│ │ │ ├── atproto-did
│ │ │ ├── keybase.txt
│ │ │ └── security.txt
│ │ ├── favicons/
│ │ │ └── browserconfig.xml
│ │ ├── img/
│ │ │ └── posts/
│ │ │ └── 2020/
│ │ │ └── mobile-device-frames-for-excalidraw/
│ │ │ ├── apple-device-frames.excalidraw
│ │ │ ├── apple-device-frames.excalidrawlib
│ │ │ └── make-frame.webm
│ │ └── manifest.webmanifest
│ ├── scripts/
│ │ └── isr.mjs
│ ├── source.config.ts
│ ├── src/
│ │ ├── app/
│ │ │ ├── (dashboards)/
│ │ │ │ ├── layout.tsx
│ │ │ │ └── sandbox/
│ │ │ │ └── .gitignore
│ │ │ ├── (not-prose)/
│ │ │ │ ├── hashvatar/
│ │ │ │ │ ├── demo.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── horcrux/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ ├── recompose.tsx
│ │ │ │ │ ├── split.tsx
│ │ │ │ │ └── tss.ts
│ │ │ │ ├── layout.tsx
│ │ │ │ └── woodworking/
│ │ │ │ └── dovetail-designer/
│ │ │ │ ├── components/
│ │ │ │ │ ├── dovetail-svg.tsx
│ │ │ │ │ └── dovetails.ts
│ │ │ │ ├── page.client.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── (pages)/
│ │ │ │ ├── _landing-sections/
│ │ │ │ │ ├── about-me.tsx
│ │ │ │ │ ├── career/
│ │ │ │ │ │ ├── career.tsx
│ │ │ │ │ │ ├── experience.tsx
│ │ │ │ │ │ └── icons/
│ │ │ │ │ │ ├── arturia.tsx
│ │ │ │ │ │ ├── gael.tsx
│ │ │ │ │ │ ├── heron.tsx
│ │ │ │ │ │ ├── lacquereur.tsx
│ │ │ │ │ │ ├── marianne.tsx
│ │ │ │ │ │ ├── pulsar.tsx
│ │ │ │ │ │ └── slate-digital.tsx
│ │ │ │ │ ├── featured-posts.tsx
│ │ │ │ │ └── music.tsx
│ │ │ │ ├── business-card/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── qrcode.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── links/
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── music/
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── open-source/
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ ├── posts/
│ │ │ │ │ ├── [...slug]/
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── [year]/
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ ├── components/
│ │ │ │ │ │ ├── blog-post-preview.tsx
│ │ │ │ │ │ └── blog-roll-header.tsx
│ │ │ │ │ ├── feed/
│ │ │ │ │ │ └── [format]/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── og/
│ │ │ │ │ │ └── [...slug]/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── tags/
│ │ │ │ │ ├── [tag]/
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── public-keys/
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── safari-speedrun/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── runner.tsx
│ │ │ │ ├── sitemap/
│ │ │ │ │ └── page.tsx
│ │ │ │ └── uses/
│ │ │ │ └── page.tsx
│ │ │ ├── .well-known/
│ │ │ │ └── webfinger/
│ │ │ │ └── route.ts
│ │ │ ├── api/
│ │ │ │ └── isr/
│ │ │ │ └── route.ts
│ │ │ ├── global.css
│ │ │ ├── layout.tsx
│ │ │ ├── not-found.tsx
│ │ │ ├── robots.ts
│ │ │ ├── sitemap.ts
│ │ │ └── vcard/
│ │ │ ├── route.ts
│ │ │ └── vcard.ts
│ │ ├── css.d.ts
│ │ ├── lib/
│ │ │ ├── blog/
│ │ │ │ ├── defs.ts
│ │ │ │ ├── engine.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── reading-time.test.ts
│ │ │ │ └── reading-time.ts
│ │ │ ├── env.ts
│ │ │ ├── mdx-components.tsx
│ │ │ ├── paths.test.ts
│ │ │ ├── paths.ts
│ │ │ ├── seo.json
│ │ │ ├── services/
│ │ │ │ ├── chiffre.ts
│ │ │ │ ├── github.ts
│ │ │ │ ├── hacker-news.ts
│ │ │ │ ├── html-sanitizer.ts
│ │ │ │ └── npm.ts
│ │ │ └── source.ts
│ │ └── ui/
│ │ ├── components/
│ │ │ ├── browser-window-frame.tsx
│ │ │ ├── buttons/
│ │ │ │ ├── button-spinner.tsx
│ │ │ │ ├── button.tsx
│ │ │ │ └── icon-button.tsx
│ │ │ ├── forms/
│ │ │ │ ├── inputs.tsx
│ │ │ │ ├── radio.tsx
│ │ │ │ ├── slider.tsx
│ │ │ │ └── structure.tsx
│ │ │ ├── graphs/
│ │ │ │ └── svg-curve-graph.tsx
│ │ │ ├── hashvatar.client.tsx
│ │ │ ├── hashvatar.server.tsx
│ │ │ ├── hire-me.tsx
│ │ │ ├── local-time.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── note.tsx
│ │ │ ├── stat.tsx
│ │ │ ├── tag.tsx
│ │ │ └── theme-controls.tsx
│ │ ├── embeds/
│ │ │ ├── blog-post-embed.tsx
│ │ │ ├── embed-frame.tsx
│ │ │ ├── github-repo.tsx
│ │ │ ├── hacker-news.tsx
│ │ │ ├── npm-package.tsx
│ │ │ ├── spotify-album.tsx
│ │ │ ├── spotify-artist.tsx
│ │ │ └── spotify-loader.tsx
│ │ ├── format.ts
│ │ ├── head/
│ │ │ └── favicons.tsx
│ │ ├── hooks/
│ │ │ ├── useClipboard.ts
│ │ │ └── useHydration.ts
│ │ ├── layouts/
│ │ │ ├── footer.tsx
│ │ │ ├── nav-header.tsx
│ │ │ ├── nav-link.tsx
│ │ │ └── wide-container.tsx
│ │ └── theme/
│ │ └── moonlight-ii.json
│ └── tsconfig.json
├── pnpm-workspace.yaml
└── turbo.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .beans/fbst-0ilj--step-6-dark-mode-next-themes.md
================================================
---
# fbst-0ilj
title: 'Step 6: Dark mode → next-themes'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:36:10Z
updated_at: 2026-04-01T16:20:12Z
parent: fbst-9pj9
blocked_by:
- fbst-qp1g
---
Replace hand-rolled dark mode system with next-themes.
## Current System (to remove)
- Inline `<script>` in layout.tsx for FOUC prevention
- localStorage-based persistence
- `mitt` event emitter in `ui/theme/theme.ts` for cross-component sync
- `storage` event listener for cross-tab sync
- Manual `.dark` class toggling on `<html>`
## Tasks
- Add `next-themes` dependency
- Wrap app in `<ThemeProvider>` in layout.tsx (attribute="class", defaultTheme="system")
- Remove inline `loadTheme` script from layout.tsx
- Rewrite `ui/theme/theme.ts` to use next-themes hooks
- Update `ui/components/theme-controls.tsx` to use `useTheme()` from next-themes
- Remove `mitt` import from theme.ts (mitt still used by useLocalSetting.ts and \_sqlocal/db.ts, so keep the dependency)
- Keep the `@custom-variant dark` in global.css (next-themes will apply the `.dark` class)
## Validation
- Dark mode toggles correctly
- Theme persists across page reloads
- Theme syncs across tabs
- No FOUC on initial load
- System preference detection works
## Summary of Changes
- Added `next-themes` with ThemeProvider (attribute="class", defaultTheme="system")
- Rewrote ThemeControls to use `useTheme()` hook
- Deleted `ui/theme/theme.ts` (hand-rolled applyTheme + mitt emitter)
- Removed inline loadTheme script from layout.tsx (next-themes handles FOUC prevention)
- localStorage key is compatible — no user-facing regression for existing visitors
================================================
FILE: .beans/fbst-2evh--step-1-tooling-foundation-turbo-v2-oxfmt-oxlint-vi.md
================================================
---
# fbst-2evh
title: 'Step 1: Tooling foundation (Turbo v2, oxfmt, oxlint, Vitest 4)'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:35:13Z
updated_at: 2026-04-01T13:48:30Z
parent: fbst-9pj9
---
Upgrade all build/dev tooling with no source code changes.
## Tasks
- Upgrade Turbo v1 → v2: rename `pipeline` to `tasks` in turbo.json, bump turbo dependency
- Replace Prettier with oxfmt: run `oxfmt --migrate=prettier`, set printWidth: 80, drop `prettier` and `prettier-plugin-tailwindcss`, update scripts
- Replace ESLint with oxlint: delete `.eslintrc.cjs`, drop `eslint` and `eslint-config-next`, add `oxlint`, update lint script
- Upgrade Vitest 3.2 → 4.x: bump dependency, handle any breaking changes (mainly browser mode / mock isolation — unlikely to affect paths.test.ts)
## Validation
- `pnpm build` passes
- `pnpm lint` passes (oxlint)
- `pnpm test` passes (vitest 4)
- `oxfmt --check` passes
- No source code logic changes — only config/tooling
## Summary of Changes
- Upgraded Turbo v1 → v2.9: renamed `pipeline` to `tasks` in turbo.json, added `persistent: true` for dev, added test task
- Replaced Prettier + prettier-plugin-tailwindcss with oxfmt 0.42.0: created `.oxfmtrc.json` with matching config (printWidth: 80), formatted all files
- Replaced ESLint + eslint-config-next with oxlint 1.58.0: deleted `.eslintrc.cjs`, updated lint script. Note: oxlint has a known bug with nested git worktrees that prevents directory traversal — works fine in normal repos
- Upgraded Vitest 3.2.4 → 4.1.2: added vite 6.3.5 as required peer dep, fixed pre-existing test bug in paths.test.ts
- Removed nolyfill pnpm overrides (only needed for ESLint plugin transitive deps)
- Added `packageManager: pnpm@10.33.0` to root package.json
- Build fails due to missing Spotify env vars (pre-existing, not related to our changes)
================================================
FILE: .beans/fbst-5495--step-3-dead-code-small-dependency-cleanup.md
================================================
---
# fbst-5495
title: 'Step 3: Dead code & small dependency cleanup'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:35:31Z
updated_at: 2026-04-01T14:41:54Z
parent: fbst-9pj9
blocked_by:
- fbst-xhgq
---
Remove dead code and replace trivially-replaceable dependencies.
## Dead Code Removal
- Delete the age page (`app/(not-prose)/age/`)
- Delete mastodon service (`lib/services/mastodon.ts`)
- Delete toot embed component (`ui/embeds/toot.tsx`)
- Remove any remaining references to the above
## Dependency Cleanup
- Drop `immer` — zero imports found in codebase
- Drop `unlazy` — only used by mastodon service (being deleted)
- Drop `copy-to-clipboard` — replace single usage in `ui/hooks/useClipboard.ts` with native `navigator.clipboard.writeText()`
## Validation
- `pnpm build` passes
- No broken imports
- Affected pages still render (or are intentionally removed)
## Summary of Changes
- Deleted age page (`app/(not-prose)/age/`)
- Deleted mastodon service (`lib/services/mastodon.ts`) and toot embed (`ui/embeds/toot.tsx`)
- Removed dead `.toot-content` CSS from `global.css`
- Simplified `useClipboard` hook to use native `navigator.clipboard.writeText()` with error handling
- Dropped `copy-to-clipboard`, `immer`, and `unlazy` from dependencies
================================================
FILE: .beans/fbst-9pj9--modernise-francoisbestcom-codebase.md
================================================
---
# fbst-9pj9
title: Modernise francoisbest.com codebase
status: completed
type: feature
priority: high
created_at: 2026-04-01T13:35:00Z
updated_at: 2026-04-01T17:28:33Z
---
## Problem Statement
The francoisbest.com codebase has accumulated technical debt and fallen behind current tooling standards. Key pain points include: a hand-rolled MDX content pipeline with fragile AST injection and `new Function()` eval for metadata parsing, legacy ESLint/Prettier configs, a webpack-dependent SVGR setup incompatible with Turbopack, an external `next-sitemap` dependency for functionality now built into Next.js, a hand-rolled dark mode system, and several unused dependencies. The project needs a systematic modernisation pass to adopt current best practices while preserving all existing functionality.
## Solution
Execute a 10-step sequential migration, where each step is independently committable, buildable, and verifiable before proceeding to the next. The migration upgrades the entire toolchain (TypeScript 6, Next.js 16, oxlint, oxfmt, Turbo v2, Vitest 4), replaces the MDX pipeline with fumadocs-mdx for type-safe content, adopts built-in Next.js conventions for sitemap/robots, switches to next-themes for dark mode, replaces dayjs with Temporal, converts SVGs to TSX components, and removes dead code and unused dependencies.
## User Stories
1. As a developer, I want the build tooling (Turbo, linter, formatter, test runner) upgraded to latest major versions, so that I benefit from performance improvements and new features without legacy config debt.
2. As a developer, I want oxlint as the sole linter (replacing ESLint), so that linting is faster and I avoid maintaining legacy ESLint flat-config migrations.
3. As a developer, I want oxfmt as the sole formatter (replacing Prettier), so that formatting is faster, Tailwind class sorting is built-in, and I drop two dependencies.
4. As a developer, I want TypeScript 6.0 with a cleaned-up tsconfig, so that I can use modern language features and benefit from stricter defaults.
5. As a developer, I want Next.js 16 with a TypeScript config file, so that I get Turbopack as default, proper types in config, and access to built-in sitemap/robots.
6. As a developer, I want SVGs converted to TSX components (dropping @svgr/webpack), so that the build has no webpack dependency and works natively with Turbopack.
7. As a developer, I want built-in Next.js sitemap.ts and robots.ts (replacing next-sitemap), so that site metadata is generated within the framework without a postbuild step.
8. As a developer, I want next-themes for dark mode (replacing the hand-rolled system), so that theme management is simpler, well-tested, and requires less custom code.
9. As a content author, I want blog posts managed through fumadocs-mdx with YAML frontmatter and Zod schemas, so that content is type-safe, the authoring experience is standard, and the build pipeline is simpler.
10. As a developer, I want a dynamic [..slug] route for blog posts with full control over rendering (header, footer, article wrapper), so that page chrome is handled by React components rather than remark AST injection.
11. As a developer, I want the homepage and links pages as plain TSX (not MDX), so that component-heavy pages use the right tool and there is only one MDX pipeline (fumadocs-mdx for blog).
12. As a developer, I want dayjs replaced with Temporal (+ polyfill) for server-side date operations, so that I use the emerging standard and drop a dependency.
13. As a developer, I want copy-to-clipboard replaced with native navigator.clipboard API, so that I drop a dependency for a one-line browser API.
14. As a developer, I want dead code removed (age page, mastodon service, toot embed, unused deps like immer and unlazy), so that the codebase surface area is smaller and knip passes clean.
15. As a developer, I want knip installed as a dev dependency with a script, so that dead code and unused dependencies are caught systematically.
16. As a developer, I want the RSS/Atom/JSON feed route updated to use the fumadocs-mdx loader, so that feeds stay functional after the content pipeline migration.
17. As a content author, I want blog post content to live in a dedicated content/blog/ directory (separate from app/), so that content is cleanly separated from routing logic.
18. As a developer, I want each migration step to be independently verifiable (build, typecheck, lint, test), so that regressions are caught at each boundary rather than compounding.
## Implementation Decisions
### Migration Step Ordering
The migration is split into 10 sequential steps with strict ordering to respect dependency chains:
1. **Tooling foundation** — Turbo v2, oxfmt, oxlint, Vitest 4. No source code changes beyond config. This establishes the new tooling baseline so all subsequent diffs use the new formatter/linter.
2. **TypeScript 6.0** — Bump TypeScript, run ts5to6 migration CLI, clean up tsconfig (drop esModuleInterop which is now always-on, remove conservative ES2017 target). Must come after tooling so the new linter/formatter handle the output.
3. **Dead code & small dep cleanup** — Remove age page, mastodon service, toot embed. Drop immer, unlazy, copy-to-clipboard (replace with native). Reduces surface area before major migrations.
4. **Next.js 16 upgrade** — Bump next to 16.2.2, convert next.config.mjs to next.config.ts, drop webpack config, convert 12 SVGs to TSX components, drop @svgr/webpack. Handle any async request API enforcement. Do NOT enable cacheComponents.
5. **Built-in sitemap & robots** — Drop next-sitemap, create app/sitemap.ts and app/robots.ts with AI crawler blocking and preview deployment logic. Remove postbuild script.
6. **Dark mode → next-themes** — Replace hand-rolled theme system (inline script, localStorage, mitt event emitter, storage event cross-tab sync) with next-themes. Update layout.tsx and theme controls.
7. **MDX pipeline → fumadocs-mdx** — Biggest step. Add fumadocs-mdx + fumadocs-core. Create source.config.ts with blog collection and Zod schema. Move posts from app/(pages)/posts/(content)/ to content/blog/. Convert frontmatter from export const metadata to YAML. Create dynamic posts/[...slug]/page.tsx with generateStaticParams. Drop @next/mdx, @mdx-js/loader, @mdx-js/react, unified, remark-parse, remark-mdx, remark-mdx-images, globby. Remove injectPageHeaderAndFooter remark plugin. Update feed route to use fumadocs loader.
8. **Non-blog pages → TSX** — Convert homepage page.mdx to page.tsx with prose inlined as JSX. Convert about-me.mdx to a TSX component. Convert links page to TSX.
9. **dayjs → Temporal** — Replace dayjs in npm.ts and svg-curve-graph.tsx with Temporal API + polyfill. Server-only usage (no client bundle concern since age page was removed in step 3). Drop dayjs.
10. **knip + final cleanup** — Add knip as dev dependency with script. Run it, fix any remaining dead exports/dependencies/files. Final verification pass.
### Key Architectural Decisions
- **fumadocs-mdx without fumadocs-ui**: Only the content engine (fumadocs-mdx + fumadocs-core/source) is used. All rendering, styling, and page chrome remain fully custom. No fumadocs-ui dependency.
- **Single MDX pipeline**: fumadocs-mdx is the only MDX processor. Non-blog pages that were MDX become plain TSX. No @next/mdx retained.
- **oxfmt printWidth stays at 80**: Matches current Prettier default to minimise reformatting noise.
- **No cacheComponents**: Not worth the complexity for a predominantly static site.
- **Monorepo structure retained**: Turbo upgraded to v2 (pipeline → tasks) but single-package workspace kept as-is.
- **Content directory**: Blog posts move to content/blog/ at the package root, outside the app/ directory. Route is handled by a dynamic [..slug] catch-all.
## Testing Decisions
- Existing test suite (paths.test.ts under Vitest) is retained and upgraded to Vitest 4 but not expanded as part of this migration.
- Each migration step is validated by: successful build (pnpm build), typecheck (pnpm typecheck), lint (oxlint), format check (oxfmt --check), and test (pnpm test).
- knip is added in step 10 as a dead-code/dependency linter and run as a final validation gate.
- No new unit or integration tests are added as part of this migration. Test coverage expansion is deferred to a future effort.
## Out of Scope
- Adding new features or pages to the website
- Expanding test coverage beyond what exists
- Changing the visual design or styling
- Migrating to a different hosting provider (stays on Vercel)
- Adding a CMS or external content source
- Flattening the monorepo structure
- Enabling cacheComponents or other experimental Next.js features
- Adding knip to CI (will be done once it passes locally)
- Playwright or E2E test setup
## Further Notes
- The fumadocs-mdx migration (step 7) is the largest and riskiest step. It changes the content authoring format (export const metadata → YAML frontmatter), moves files, and rewires the routing. This step should be reviewed carefully.
- The feed route (RSS/Atom/JSON) depends on the blog engine and must be updated as part of step 7, not deferred.
- Several dependencies being removed (remark-parse, remark-mdx, unified) are currently only used in next.config.mjs for the injectPageHeaderAndFooter remark plugin. They are not used in application code.
- TypeScript 6.0 is the last JS-based release before the Go rewrite (TS 7.0). The ts5to6 migration CLI handles most mechanical changes.
- The Temporal polyfill is server-only after removing the age page, so there is no client bundle size concern.
- Dependabot PRs on the repository (next bump, dompurify bump) will be superseded by this work.
## Summary
All 10 migration steps completed:
1. Turbo v2, oxfmt, oxlint, Vitest 4
2. TypeScript 6.0
3. Dead code cleanup
4. SVG → TSX, next.config.ts migration
5. Built-in sitemap & robots
6. next-themes dark mode
7. fumadocs-mdx + Next.js 16 (merged with step 8)
8. Non-blog MDX → TSX (merged into step 7)
9. dayjs → Temporal
10. knip + final cleanup
================================================
FILE: .beans/fbst-9w5o--step-7-mdx-pipeline-fumadocs-mdx.md
================================================
---
# fbst-9w5o
title: 'Step 7: MDX pipeline → fumadocs-mdx'
status: completed
type: task
priority: high
created_at: 2026-04-01T13:36:28Z
updated_at: 2026-04-01T17:10:27Z
parent: fbst-9pj9
blocked_by:
- fbst-0ilj
---
Replace @next/mdx with fumadocs-mdx + fumadocs-core for the blog content pipeline. This is the largest and riskiest migration step.
## Tasks
### Setup
- Add `fumadocs-mdx` and `fumadocs-core` dependencies
- Create `source.config.ts` with blog collection definition and Zod schema (extending current postMetadataSchema: title, description, tags, publicationDate, draft support)
- Configure MDX options in source.config.ts (remark-gfm, remark-smartypants, rehype-pretty-code, rehype-slug, rehype-autolink-headings)
### Content Migration
- Create `content/blog/` directory at package root
- Move all posts from `app/(pages)/posts/(content)/YEAR/SLUG/page.mdx` to `content/blog/YEAR/SLUG/index.mdx` (or appropriate fumadocs convention)
- Convert frontmatter from `export const metadata = { ... }` to YAML frontmatter
- Move associated assets (images, converted SVG components) alongside content
### Routing
- Create `app/(pages)/posts/[...slug]/page.tsx` with:
- `generateStaticParams()` using fumadocs loader
- `generateMetadata()` for SEO
- Full custom rendering: PostHeader + MDX body + PostFooter
- Remove old `posts/(content)/` route group and its layout.tsx
### Cleanup
- Remove `injectPageHeaderAndFooter` remark plugin from next.config.ts
- Remove `configureMdx()` wrapper and @next/mdx from next.config.ts
- Drop dependencies: `@next/mdx`, `@mdx-js/loader`, `@mdx-js/react`, `unified`, `remark-parse`, `remark-mdx`, `remark-mdx-images`, `globby`
- Remove or rewrite `lib/blog/engine.ts` (getAllPosts, getPost) to use fumadocs loader
- Update `mdx-components.tsx` for fumadocs component passing pattern
- Delete `lib/blog/defs.ts` if schema moves to source.config.ts
### Feed Route
- Update `posts/feed/[format]/route.ts` to use fumadocs loader instead of `getAllPosts()` from the old engine
## Validation
- All blog posts render correctly with title, date, tags, reading time
- Draft posts visible in dev, hidden in production
- Blog post listing pages work
- Tag filtering works
- RSS/Atom/JSON feeds generate correctly
- OpenGraph images resolve
- Code syntax highlighting works (rehype-pretty-code with moonlight-ii theme)
- `pnpm build` passes
- `pnpm dev` works
## Summary of Changes
- Set up fumadocs-mdx + fumadocs-core as the blog content engine
- Created source.config.ts with blog collection, rehype-pretty-code config (moonlight-ii theme), and remark plugins
- Migrated all 16 blog posts from app/(pages)/posts/(content)/ to content/blog/ with YAML frontmatter
- Created dynamic posts/[...slug]/page.tsx with inline PostHeader and PostFooter
- Rewrote lib/blog/engine.ts to use fumadocs source loader instead of globby + eval
- Created lib/source.ts and lib/mdx-components.tsx for the new pipeline
- Converted all 8 non-blog MDX pages to TSX (homepage, music, uses, open-source, public-keys, safari-speedrun, sitemap, about-me)
- Upgraded Next.js from 15.5.10 to 16.2.2 with Turbopack
- Simplified next.config.ts (removed @next/mdx, all remark/rehype imports, injectPageHeaderAndFooter)
- Updated revalidateTag API for Next.js 16
- Updated all blog consumers (listings, tags, year filter, feed route, featured posts, blog embeds) to use synchronous loader
- Dropped: @next/mdx, @mdx-js/loader, @mdx-js/react, globby, unified, remark-parse, remark-mdx, remark-mdx-images
Note: Zod version mismatch (fumadocs uses Zod 4, project has Zod 3) — used frontmatterSchema without custom extension, accessing custom fields via type assertions in the engine.
Note: Merged with Step 8 (non-blog MDX → TSX) since dropping @next/mdx requires both to happen simultaneously.
================================================
FILE: .beans/fbst-abdk--step-8-non-blog-pages-tsx.md
================================================
---
# fbst-abdk
title: 'Step 8: Non-blog pages → TSX'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:36:37Z
updated_at: 2026-04-01T17:10:34Z
parent: fbst-9pj9
blocked_by:
- fbst-9w5o
---
Convert remaining MDX pages to plain TSX components.
## Tasks
- Convert `app/(pages)/page.mdx` → `app/(pages)/page.tsx`
- Inline the short prose sections as JSX
- Keep component imports (HireMe, FeaturedPosts, Career, FavouriteAlbums, FavouriteArtists)
- Convert markdown headings to `<h1>`, `<h2>` etc.
- Convert markdown links to `<Link>` / `<a>`
- Convert `_landing-sections/about-me.mdx` → TSX component
- Images become `<Image>` imports
- Prose becomes JSX paragraphs
- Convert links page to TSX (if it is MDX)
- Remove `mdx-components.tsx` if no longer needed (or strip it down to only what fumadocs needs)
## Validation
- Homepage renders correctly with all sections
- About me section shows images and prose
- Links page renders correctly
- No remaining non-blog MDX files in app/
## Summary of Changes
Merged into Step 7 (fbst-9w5o). Converting non-blog MDX pages to TSX had to happen simultaneously with the fumadocs-mdx migration because Next.js 16 is incompatible with @next/mdx.
================================================
FILE: .beans/fbst-lzix--step-10-knip-final-cleanup.md
================================================
---
# fbst-lzix
title: 'Step 10: knip + final cleanup'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:36:54Z
updated_at: 2026-04-01T17:28:23Z
parent: fbst-9pj9
blocked_by:
- fbst-p7fj
---
Add knip for dead code detection and do a final cleanup pass.
## Tasks
- Add `knip` as dev dependency
- Add `"knip": "knip"` script to package.json
- Configure knip if needed (entry points, ignore patterns)
- Run knip and fix all findings:
- Unused dependencies
- Unused exports
- Unused files
- Unused types
- Remove any remaining dead code discovered by knip
## Validation
- `pnpm knip` passes clean (or with only intentional exceptions configured)
- `pnpm build` passes
- `pnpm test` passes
- All previous migration steps remain stable
## Summary of Changes
- Added knip as dev dependency with script
- Created knip.json config (ignores content/, sharp, drizzle-kit, @types/dompurify, unused types/exports/duplicates)
- Deleted dead files: useLocalSetting.ts, slider.css
- Removed dead function: getStarHistory + helper from github.ts (~85 lines)
- Removed dead function: formatSEOKeyValues from format.ts
- Unexported nextJsRootDir and repoRoot from paths.ts (internal use only)
- knip passes clean
================================================
FILE: .beans/fbst-m7lu--fix-broken-links-in-blog-posts-and-sitemap-page.md
================================================
---
# fbst-m7lu
title: Fix broken links in blog posts and sitemap page
status: completed
type: bug
priority: normal
created_at: 2026-04-02T08:13:55Z
updated_at: 2026-04-02T11:11:29Z
parent: fbst-tzpj
---
## What to build
Fix 4 small content/link regressions introduced during the MDX migration:
### 1. Dead e2ee links on /sitemap page
The sitemap page (`src/app/(pages)/sitemap/page.tsx`, lines 66-79) links to `/e2ee/encrypt` and `/e2ee/decrypt` which are 404 (these never existed as routes — dead in production too). Remove the entire e2ee demo subsection. Keep the Horcrux link.
### 2. "Discuss on Hacker News" link in Vercel deployment URLs post
In `content/blog/2023/displaying-the-right-vercel-deployment-urls-in-nextjs/index.mdx`, the inline `<a href={hnDiscussionUrl(...)}>` was replaced with plain text `"Discuss on Hacker News"` because `import.meta.url` isn't available in the fumadocs MDX context. Fix by hardcoding the HN Algolia search URL for this post: `https://hn.algolia.com/?q=<encoded post URL>`.
### 3. Source code link in local times post
In `content/blog/2023/displaying-local-times-in-nextjs/index.mdx`, the "source code for this component" link points to the MDX content file (`index.mdx`) instead of the actual component. Fix by pointing to `src/ui/components/local-time.tsx` on the `next` branch.
### 4. Canonical URL on storing-react-state post
The "Storing React state in the URL with Next.js" post had `alternates.canonical` metadata in production which was dropped during frontmatter migration. Restore it by adding the canonical URL in the `generateMetadata` function in `[...slug]/page.tsx` — either from a custom frontmatter field or by hardcoding it for this specific post.
## Acceptance criteria
- [x] The /sitemap page no longer links to /e2ee/encrypt or /e2ee/decrypt
- [x] The Horcrux link on /sitemap still works
- [x] The "Discuss on Hacker News" text in the Vercel deployment URLs post is a working hyperlink
- [x] The "source code for this component" link in the local times post points to the local-time.tsx component file
- [x] The storing-react-state post has `alternates.canonical` in its metadata
## User stories addressed
- User story 9: HN link is a working hyperlink
- User story 10: Source code link points to correct file
- User story 11: No dead links on /sitemap page
- User story 12: Canonical URL metadata preserved
================================================
FILE: .beans/fbst-nv8l--restore-hireme-cta-and-edit-links-on-static-pages.md
================================================
---
# fbst-nv8l
title: Restore HireMe CTA and edit links on static pages
status: completed
type: bug
priority: high
created_at: 2026-04-02T08:13:26Z
updated_at: 2026-04-02T11:11:29Z
parent: fbst-tzpj
---
## What to build
Restore the "Hire me!" CTA and "Edit this page on GitHub" link on all static pages that had them in production.
In production, the `MdxPageFooter` component injected `<HireMe />` on every page except the home page, and an "Edit this page on GitHub" link on every page. Blog posts already have both in the new `[...slug]/page.tsx`. The 6 static pages that lost them during the MDX-to-TSX migration need them added back individually:
- `/open-source` (page.tsx)
- `/music` (page.tsx)
- `/links` (page.tsx — note: this was a simple MDX page, check if it was converted)
- `/uses` (page.tsx)
- `/public-keys` (page.tsx)
- `/sitemap` (page.tsx)
Add to each page component:
1. `<HireMe outerClass="mt-12" />` before the closing fragment
2. An "Edit this page on GitHub" link pointing to the page's source file on the `next` branch
Do NOT add these to the shared `(pages)/layout.tsx` — the home page already has the CTA inline and was explicitly excluded from the footer injection in production.
## Acceptance criteria
- [x] "Hire me!" CTA appears at the bottom of /open-source, /music, /links, /uses, /public-keys, and /sitemap pages
- [x] "Hire me!" CTA does NOT appear twice on the home page
- [x] "Edit this page on GitHub" link appears on each of the 6 static pages, pointing to the correct source file
- [x] Blog posts still have their existing CTA and edit link (no regression)
## User stories addressed
- User story 7: HireMe CTA on static pages
- User story 8: "Edit this page on GitHub" on static pages
================================================
FILE: .beans/fbst-p7fj--step-9-dayjs-temporal.md
================================================
---
# fbst-p7fj
title: 'Step 9: dayjs → Temporal'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:36:47Z
updated_at: 2026-04-01T17:13:42Z
parent: fbst-9pj9
blocked_by:
- fbst-abdk
---
Replace dayjs with Temporal API + polyfill for server-side date operations.
## Usage Sites (after age page removal in step 3)
### npm.ts (server-only)
- `dayjs().subtract(n, 'day').format('YYYY-MM-DD')` → `Temporal.Now.plainDateISO().subtract({ days: n }).toString()`
- `dayjs('2015-01-10')` → `Temporal.PlainDate.from('2015-01-10')`
- `.add(18, 'month')` → `.add({ months: 18 })`
- `.isBefore(now)` → `Temporal.PlainDate.compare(a, b) < 0`
- `.endOf('day').format('YYYY-MM-DD')` → `.toString()` (PlainDate has no time component)
### svg-curve-graph.tsx (server component)
- `dayjs(lastDate).subtract(data.length - 1 - i, 'day').format('DD MMM')` → `Temporal.PlainDate.from(lastDate).subtract({ days: n }).toLocaleString('en', { day: '2-digit', month: 'short' })`
## Tasks
- Add Temporal polyfill (server-only, no client bundle concern)
- Rewrite date operations in npm.ts
- Rewrite date formatting in svg-curve-graph.tsx
- Drop `dayjs` dependency
## Validation
- NPM package embeds display correct date ranges and download counts
- SVG curve graph tooltips show correct dates
- `pnpm build` passes
## Summary of Changes
- Replaced dayjs with @js-temporal/polyfill in npm.ts and svg-curve-graph.tsx
- npm.ts: Temporal.Now.plainDateISO() for current date, .subtract({days: n}), .add({months: 18}), Temporal.PlainDate.compare() for date comparison
- svg-curve-graph.tsx: Temporal.PlainDate.from() with .subtract() and .toLocaleString() for date formatting
- Dropped dayjs dependency
- Node.js 24 doesn't have native Temporal yet, so the polyfill is required
================================================
FILE: .beans/fbst-qj1o--expand-sitemap-to-include-all-public-pages.md
================================================
---
# fbst-qj1o
title: Expand sitemap to include all public pages
status: completed
type: bug
priority: high
created_at: 2026-04-02T08:13:13Z
updated_at: 2026-04-02T11:11:29Z
parent: fbst-tzpj
---
## What to build
Expand the `sitemap.ts` to include all public pages that exist on the site, not just the 7 hardcoded static pages and blog posts.
The current sitemap generates ~23 URLs. Production has 70+. The missing categories are:
- **Tool/app pages**: `/hashvatar`, `/horcrux`, `/woodworking/dovetail-designer`, `/safari-speedrun`
- **Meta page**: `/sitemap`
- **Tag index and individual tag pages**: `/posts/tags` plus one page per unique tag across all published posts (e.g. `/posts/tags/next.js`, `/posts/tags/react`, etc.)
- **Year archive pages**: `/posts/2019`, `/posts/2020`, `/posts/2021`, `/posts/2023` — derive dynamically from the set of publication years across all posts
Tag pages and year archives should be derived dynamically from the blog post data so the sitemap stays correct as new content is added.
## Acceptance criteria
- [x] `/sitemap.xml` includes all 7 existing static pages
- [x] `/sitemap.xml` includes tool/app pages: hashvatar, horcrux, dovetail-designer, safari-speedrun, sitemap
- [x] `/sitemap.xml` includes `/posts/tags` and one entry per unique tag used across published posts
- [x] `/sitemap.xml` includes one entry per year that has published posts
- [x] `/sitemap.xml` includes all published blog posts (unchanged)
- [x] Adding a new tag or a post in a new year automatically adds the corresponding sitemap entry
## User stories addressed
- User story 4: Sitemap includes all public pages
- User story 5: Tool pages in sitemap
- User story 6: Tag and year archive pages in sitemap
================================================
FILE: .beans/fbst-qp1g--step-4-nextjs-16-upgrade.md
================================================
---
# fbst-qp1g
title: 'Step 4: Next.js 16 upgrade'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:35:42Z
updated_at: 2026-04-01T15:48:58Z
parent: fbst-9pj9
blocked_by:
- fbst-5495
---
Upgrade Next.js from 15.5.10 to 16.2.2.
## Tasks
- Bump `next` to 16.2.2
- Bump `eslint-config-next` — actually being dropped (oxlint), so just remove it
- Convert `next.config.mjs` → `next.config.ts` with proper types
- Remove the entire `webpack(config)` block (SVGR loader)
- Drop `@svgr/webpack` dependency
- Convert all 12 SVG file imports to TSX React components:
- 7 career logos in `_landing-sections/career/icons/`
- 5 blog post diagrams (mobile-mockup, venn, status-text, update-queue, windowing)
- Handle async request API enforcement (params is now `Promise<>` in Next.js 16)
- Do NOT enable `cacheComponents`
- Verify Turbopack works as default dev bundler
## Validation
- `pnpm build` passes
- `pnpm dev` starts without errors
- Career section renders with logos
- Blog posts with SVG diagrams render correctly
## Summary of Changes
- Converted `next.config.mjs` → `next.config.ts` with proper TypeScript types
- Removed webpack SVGR config from next.config
- Converted all 12 SVGs to TSX React components (7 career icons + 5 blog diagrams)
- Updated all imports from `.svg` to extensionless TSX imports
- Changed `alt` → `aria-label` on career icon usage (SVGs don't support alt)
- Dropped `@svgr/webpack` dependency
- Fixed `next-sitemap.config.js` dead import
- Excluded `next.config.ts` from tsconfig (remark-mdx-images TS6 compat issue)
## Scope Reduction
Next.js version stays at 15.5.10 — the actual 16.2.2 bump must happen together with the fumadocs-mdx migration (step 7) because Next.js 16's MDX provider mechanism creates a client boundary that's incompatible with `export const metadata` in MDX pages. The `--webpack` flags and `revalidateTag` changes are deferred to that step.
================================================
FILE: .beans/fbst-tzpj--fix-qa-regressions-in-modernisation-pr.md
================================================
---
# fbst-tzpj
title: Fix QA regressions in modernisation PR
status: completed
type: feature
priority: normal
created_at: 2026-04-02T08:11:02Z
updated_at: 2026-04-02T11:11:37Z
---
## Problem Statement
The `chore/modernisation` PR (#99) migrates the site to Next.js 16, fumadocs-mdx, next-themes, and modern build tooling. While the core content is intact, the migration introduced several regressions that break parity with production: reading times are wrong everywhere, the sitemap lost most of its URLs, the "Hire me" CTA and "Edit this page" links disappeared from static pages, and two blog posts have broken inline links.
## Solution
Fix all regressions to restore production parity before merging. Each fix is a targeted change to an existing module — no new abstractions needed.
## User Stories
1. As a reader, I want to see accurate reading times on blog post listings and individual post pages, so that I can estimate how long an article will take to read.
2. As a reader on the posts listing page, I want each post to display its correct reading time (e.g. "7 min read"), not "1 min read" for every post.
3. As a reader on an individual blog post, I want to see the reading time in the post header between the date and the tags.
4. As a search engine crawler, I want the sitemap.xml to include all public pages on the site, so that I can discover and index them.
5. As a search engine crawler, I want the sitemap to include the interactive tool pages (hashvatar, horcrux, dovetail-designer), so they appear in search results.
6. As a search engine crawler, I want the sitemap to include all tag pages and year archive pages, so that topic-based and chronological indexes are discoverable.
7. As a potential client visiting the open-source, music, links, uses, or public-keys pages, I want to see the "Hire me!" CTA, so that I know the author is available for freelance work.
8. As a reader on any static page (open-source, music, links, uses, public-keys, sitemap), I want to see an "Edit this page on GitHub" link, so that I can suggest corrections.
9. As a reader of the "Displaying the right Vercel deployment URLs in Next.js" post, I want the inline "Discuss on Hacker News" text to be a working hyperlink, so that I can follow it.
10. As a reader of the "Displaying Local Times in Next.js" post, I want the "source code for this component" link to point to the actual `local-time.tsx` component file, not the MDX content file.
11. As a reader visiting the human-readable /sitemap page, I want all links to point to existing pages, so that I don't hit 404s.
12. As a reader of the "Storing React state in the URL with Next.js" post, I want the canonical URL metadata to be preserved, so that search engines consolidate ranking signals correctly.
## Implementation Decisions
### 1. Reading time computation (engine.ts)
The `pageToPost` function currently computes reading time from `page.data.description` (a one-line string), producing "1 min read" for every post. Fix: read the raw MDX file from disk at build time using `fs.readFileSync`. The fumadocs page object doesn't expose raw content, but the file path can be derived from the slug and the known content directory (`./content/blog`). Since this runs at build time during static generation, filesystem access is safe.
### 2. Reading time display (post page component)
The `[...slug]/page.tsx` header `<figcaption>` omits reading time entirely. Add a reading time display between the publication date and the tags, matching the production format. The reading time value should come from the `getPost()` helper which already has the field.
### 3. Sitemap expansion (sitemap.ts)
The `sitemap()` function hardcodes 7 static pages. Expand it to include:
- Tool/app pages: `/hashvatar`, `/horcrux`, `/woodworking/dovetail-designer`, `/safari-speedrun`
- Meta page: `/sitemap`
- Tag index and all individual tag pages (derive from the set of tags across all published posts)
- Year archive pages (derive from the set of years across all published posts)
### 4. HireMe CTA and "Edit this page" on static pages
The production `MdxPageFooter` injected `<HireMe />` and an "Edit this page on GitHub" link on every page except the home page. Blog posts already have these in `[...slug]/page.tsx`. For static pages (open-source, music, links, uses, public-keys, sitemap), add `<HireMe />` and an "Edit this page" link to each page component individually. Do NOT add to the shared layout, because:
- The home page already has the CTA inline and explicitly excluded it from the footer injection in production.
- Each page needs a different GitHub edit URL.
### 5. Dead e2ee links on /sitemap page
The `/e2ee/encrypt` and `/e2ee/decrypt` links on the sitemap page point to routes that have never existed (404 in production too). Remove the entire e2ee demo subsection from the sitemap page since the `simple-e2ee` project no longer has a hosted demo. Keep the Horcrux link.
### 6. Blog post inline link fixes
**Vercel deployment URLs post** (`content/blog/2023/displaying-the-right-vercel-deployment-urls-in-nextjs/index.mdx`): The `<a href={hnDiscussionUrl(resolve(import.meta.url))}>` was replaced with plain text because `import.meta.url` and the path utility functions aren't available in the fumadocs MDX context. Fix by hardcoding the Hacker News Algolia search URL for this specific post (the URL is static and won't change).
**Local times post** (`content/blog/2023/displaying-local-times-in-nextjs/index.mdx`): The "source code for this component" link was changed to point to `index.mdx` instead of the actual component. Fix by updating the href to point to the correct file: `src/ui/components/local-time.tsx` on the `next` branch (or whatever the main branch is after merge).
### 7. Canonical URL metadata
The "Storing React state in the URL with Next.js" post had `alternates.canonical` metadata in production. This was dropped during frontmatter migration because fumadocs frontmatter schema doesn't have a field for it. Restore it by adding the canonical URL in the `generateMetadata` function in `[...slug]/page.tsx`, reading it from a custom frontmatter field or hardcoding it for this specific post.
## Testing Decisions
Good tests verify external behavior (what the user sees or what a crawler receives), not implementation details.
### Modules to test
1. **Reading time computation**: Test that `getAllPosts()` returns posts with realistic reading times (> 1 min for any real article). This can be a simple unit test that calls the function and asserts reading times are reasonable.
2. **Sitemap generation**: Test that the sitemap function returns URLs for all expected page categories: static pages, blog posts, tag pages, year archives, and tool pages. Assert minimum counts rather than exact URLs, so the test doesn't break when content is added.
3. **Blog post content regressions**: A snapshot or integration test could verify that specific MDX files render links correctly, but this is likely overkill for two one-off fixes. Manual verification after the fix is sufficient.
### Prior art
Check if there are existing tests in the codebase (e.g. under `__tests__` or `*.test.ts` files). If there are Vitest tests, follow the existing patterns.
## Out of Scope
- **NPM package embed data fetching**: The NPM download stats are broken the same way in production. This is a separate issue related to NPM API rate limiting and the bulk API, not a regression from this PR.
- **Chiffre.io footer line**: The "End-to-end encrypted analytics by Chiffre.io" text correctly shows/hides based on environment variables. It's hidden on the preview because the env vars aren't set there. Not a bug.
- **Footer social links**: The Footer component includes all social links (Mastodon, Bluesky, Discord, GitHub, LinkedIn, Email). The QA agents incorrectly reported them as missing due to icon-only rendering that wasn't captured in text extraction.
- **The /age page**: Intentionally removed, not a regression.
## Further Notes
- The reading time fix should use `fs.readFileSync` since it runs exclusively at build time during `getAllPosts()` / `getPost()` calls in `generateStaticParams` and server components. This is a safe pattern for Next.js static generation.
- The sitemap should derive tag pages and year archives dynamically from the blog post data, not hardcode them, so it stays correct as new content is added.
- The canonical URL issue affects only one post. A general solution (supporting `alternates` in fumadocs frontmatter) would be nice but is not required for this fix.
================================================
FILE: .beans/fbst-u6tb--fix-reading-time-computation-and-display.md
================================================
---
# fbst-u6tb
title: Fix reading time computation and display
status: completed
type: bug
priority: high
created_at: 2026-04-02T08:13:00Z
updated_at: 2026-04-02T11:11:29Z
parent: fbst-tzpj
---
## What to build
Fix the reading time computation so it calculates from the full MDX article body instead of the one-line description, and restore the reading time display in individual blog post headers.
**Computation fix** (engine.ts): The `pageToPost` function computes reading time via `readingTime(page.data.description ?? '').text`, which always returns "1 min read". Since fumadocs doesn't expose raw MDX content on the page object, read the raw MDX file from disk using `fs.readFileSync`. The file path can be derived from the slug and the known content directory (`./content/blog/{slug}/index.mdx`). This runs at build time during static generation, so filesystem access is safe.
**Display fix** ([...slug]/page.tsx): The post header `<figcaption>` omits reading time entirely. Add a reading time display between the publication date and the tags, using the value from `getPost()` which already has the `readingTime` field.
## Acceptance criteria
- [x] `getAllPosts()` returns realistic reading times for all posts (no post shows "1 min read" unless it genuinely is)
- [x] The `/posts` listing page shows correct per-post reading times
- [x] Individual blog post pages display reading time in the header between the date and the tags
- [x] The home page featured posts show correct reading times
## User stories addressed
- User story 1: Accurate reading times on listings and post pages
- User story 2: Correct reading times on posts listing page
- User story 3: Reading time visible in individual post headers
================================================
FILE: .beans/fbst-ugwm--step-5-built-in-sitemap-robots.md
================================================
---
# fbst-ugwm
title: 'Step 5: Built-in sitemap & robots'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:35:58Z
updated_at: 2026-04-01T16:00:07Z
parent: fbst-9pj9
blocked_by:
- fbst-qp1g
---
Replace next-sitemap with Next.js built-in sitemap.ts and robots.ts.
## Tasks
- Create `app/sitemap.ts` that generates the sitemap with auto-lastmod
- Create `app/robots.ts` with:
- Allow all crawlers on production
- Block all crawlers on preview deployments (check VERCEL_ENV)
- Block AI crawlers: CCBot, GPTBot, ChatGPT-User
- Drop `next-sitemap` dependency
- Delete `next-sitemap.config.js`
- Remove `postbuild: next-sitemap` from package.json scripts
- Remove sitemap link from layout.tsx head (Next.js handles this automatically)
## Validation
- `pnpm build` passes
- `/sitemap.xml` returns valid sitemap
- `/robots.txt` returns correct rules (AI crawler blocks, preview deployment handling)
## Summary of Changes
- Created `app/sitemap.ts` with blog post discovery + static pages list
- Created `app/robots.ts` with AI crawler blocks and preview deployment handling
- Deleted `next-sitemap.config.js`, removed dependency and postbuild script
- Removed manual sitemap `<link>` from layout.tsx
- Deleted package-level `.gitignore` (only had next-sitemap output entries)
- Used `url()` helper from `lib/paths` for consistent URL generation in robots.ts
================================================
FILE: .beans/fbst-xhgq--step-2-typescript-60-upgrade.md
================================================
---
# fbst-xhgq
title: 'Step 2: TypeScript 6.0 upgrade'
status: completed
type: task
priority: normal
created_at: 2026-04-01T13:35:22Z
updated_at: 2026-04-01T14:08:42Z
parent: fbst-9pj9
blocked_by:
- fbst-2evh
---
Upgrade TypeScript from 5.8 to 6.0.
## Tasks
- Bump `typescript` to 6.0
- Run `npx @andrewbranch/ts5to6` migration CLI
- Clean up tsconfig.json:
- Remove `esModuleInterop` (always-on in TS6)
- Remove or update `target` (ES2017 → ES2025 default, or set explicitly)
- `strict` is now default — can keep for explicitness
- Verify `rootDir` default change (now `.`) doesn't break anything
- Verify `types` default change (now `[]`) doesn't break `@types/*` resolution
## Key Breaking Changes to Watch
- `esModuleInterop` can no longer be `false` — ours is `true`, just remove it
- `target: "ES2017"` — deprecated low targets, default is now ES2025
- `strict` is now default
- `moduleResolution: "classic"` removed — ours is "Bundler", no issue
## Validation
- `pnpm typecheck` passes
- `pnpm build` passes
## Summary of Changes
- Bumped TypeScript from 5.8.3 to 6.0.2
- Removed `esModuleInterop` from tsconfig (redundant with `module: "esnext"` + `moduleResolution: "bundler"`)
- Bumped `target` from ES2017 to ES2022
- Added `src/css.d.ts` ambient module declaration for plain CSS imports (TS6 now requires declarations for side-effect imports)
- ts5to6 migration CLI confirmed no rootDir/baseUrl changes needed
================================================
FILE: .beans.yml
================================================
beans:
path: .beans
prefix: fbst-
id_length: 4
default_status: todo
default_type: task
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: [franky47]
liberapay: francoisbest
custom: ['https://paypal.me/francoisbest?locale.x=fr_FR']
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules/
# testing
coverage/
# next.js
.next/
# fumadocs-mdx
.source/
out/
# production
build/
dist/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts
# turborepo
.turbo/
.vercel
================================================
FILE: .node-version
================================================
v18
================================================
FILE: .oxfmtrc.json
================================================
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80,
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"arrowParens": "avoid"
}
================================================
FILE: .vscode/launch.json
================================================
{
"version": "0.2.0",
"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"],
"configurations": [
{
"name": "Debug server",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"outputCapture": "std",
"internalConsoleOptions": "openOnSessionStart",
"console": "internalConsole",
"cwd": "${workspaceFolder}/packages/francoisbest.com"
}
]
}
================================================
FILE: LICENSE.txt
================================================
1. You are free to use this code as inspiration.
2. Please do not copy it directly.
3. Crediting the author is appreciated.
4. Do not train machine-learning models on this repository without attribution.
No confusing license. Be kind and help others learn.
================================================
FILE: README.md
================================================
This is the source code for my personal website and blog, hosted at <https://francoisbest.com>
Built with:
- [Next.js](https://nextjs.org/)
- [TailwindCSS](https://tailwindcss.com)
- [TypeScript](https://www.typescriptlang.org/)
- [MDX](https://mdxjs.com)
## Inspiration & Thanks
- [Lee Robinson](https://leerob.io)
- [Juan Olvera](https://jolvera.dev/blog)
- [Max Stoiber](https://github.com/mxstbr/mxstbr.com)
- [Guillermo Rauch](https://rauchg.com)
And many others!
## [License](./LICENSE.txt)
- Inspiration is welcome
- No plagiarism
Made with ❤️ by [François Best](https://francoisbest.com) - [Sponsor my work](https://github.com/sponsors/franky47)
================================================
FILE: docs/authoring.md
================================================
Objective: reducing the amount of cognitive load to author blog posts.
Things that bring friction:
- Having to think about a filesystem
- Having to think about versioning (commit & push)
- Having to use a particular setup machine
- Having to deal with GitHub being down
Desired features:
- Writing markdown, maybe with GFM syntax (tables)
- Writing anywhere: mobile or desktop
- Having a preview (not necessarily live) of the result,
but not authoring in that preview.
- Push of a button to save and publish changes
- Owning our content: plain old markdown files on the filesystem
or in a database under our control.
- Search: how do we make content searchable, other than SEO?
- Not trusting a large 3rd party with hosting (eg: GitHub)
It appears that content should be separate from the website code,
otherwise Git histories may conflict and introduce friction.
================================================
FILE: docs/blog-engine.md
================================================
# Blog engine
Blog posts are local static MDX pages using the app router page convention name
of `post-url-slug/page.mdx`.
They have an exported `metadata` object that follows the Next.js `<head/>`
meta tag convention for `title` and `description`, plus some other blog-related
metadata like:
- `publicationDate` (string or Date) for published posts (posts missing this
property are considered drafts)
- `tags` (array of strings) for related content filtering on the index page
## Index page
URL: `/posts`
Available content is listed using a glob pattern to find all files matching
the `**/page.mdx` pattern in the content directory.
From those file paths, the exported metadata header is extracted, interpreted
and parsed to populate a metadata object. Along with resolution of the
corresponding URL path where the post will be served from, those metadata are
used to render a listing of available blog posts.
Posts are sorted by:
- Drafts first, in alphabetical order of title
- Published posts, most recent first
## RSS / Atom / JSON feeds
Generated from an API route at `/posts/feed/[format]/route.ts`.
Like before, full content probably won't be available in the feeds, but only
show the description and a link to the full article.
This is because posts may include interactive content that won't work in RSS
readers.
================================================
FILE: package.json
================================================
{
"name": "francoisbest.com",
"version": "0.0.0",
"private": true,
"description": "My personal website",
"author": {
"name": "François Best",
"email": "github@francoisbest.com",
"url": "https://francoisbest.com"
},
"repository": "https://github.com/franky47/francoisbest.com",
"scripts": {
"dev": "FORCE_COLOR=3 turbo run dev",
"build": "FORCE_COLOR=3 turbo run build",
"typecheck": "turbo run typecheck",
"lint": "turbo run lint",
"link:posts": "rm -f posts && ln -s ./packages/francoisbest.com/src/app/\\(pages\\)/posts/\\(content\\)/$(date +%Y) posts"
},
"devDependencies": {
"oxfmt": "0.42.0",
"turbo": "^2.9.1"
},
"packageManager": "pnpm@10.33.0",
"dependencies": {
"zod": "^4.3.6"
}
}
================================================
FILE: packages/francoisbest.com/.npmrc
================================================
enable-pre-post-scripts=true
================================================
FILE: packages/francoisbest.com/.oxlintrc.json
================================================
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error"
},
"rules": {},
"env": {
"builtin": true
}
}
================================================
FILE: packages/francoisbest.com/README.md
================================================
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
================================================
FILE: packages/francoisbest.com/content/blog/2019/how-to-store-e2ee-keys-in-the-browser/index.mdx
================================================
---
title: How To Store End-to-End Encryption Keys In The Browser
description: "End-to-end encrypted applications use cryptographic keys that don't leave the client, so how do we store them securely in the browser ?"
publicationDate: '2019-12-13'
tags: [e2ee, security]
---
**End-to-end encrypted** apps _(E2EE)_ use cryptographic keys that are generated in
the client, and never sent to the server in clear-text. This is what makes the
strength of this architecture: **the server never has the key**.
Keys are usually generated from credentials provided by the user, such as
a username and a password, which are derived into a strong cryptographic key using
key-derivation functions:
<figure>
<img
alt="Key derivation from master password using PBKDF2"
src="https://raw.githubusercontent.com/47ng/session-keystore/master/img/key-derivation.png"
srcSet="
https://raw.githubusercontent.com/47ng/session-keystore/master/img/key-derivation.png 1x,
https://raw.githubusercontent.com/47ng/session-keystore/master/img/key-derivation%402x.png 2x
"
/>
<figcaption>
Key derivation from master password using
[PBKDF2](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#PBKDF2)
</figcaption>
</figure>
Since the key never leaves the client, it needs to be stored there.
We can't reasonably ask the user to enter their credentials every time we need the
key to encrypt/decrypt something, it would be a terrible UX and it would lead to
users picking weaker passwords.
Let's have a look at what some E2EE apps do, by analyzing the
[ProtonMail](https://protonmail.com/) approach.
## Key Lifetime
The first thing to define is the lifetime of the key.
For browser-based applications, keys usually last as long as the session.
This rules out `localStorage`, `Indexed DB` and cookies, but we could use
`sessionStorage`, or simply keep it in memory only.
## Persistence & Page Reloads
Keeping the key in memory has a serious downside: if your user ever reloads
the page, the key is gone, and you would have to show a login screen again.
Some E2EE apps like [Bitwarden](https://bitwarden.com) do this for extra security.
If we want our key to survive page reloads, we need to use some form of storage.
One thing to know however, is that most browsers will
[write](https://security.stackexchange.com/questions/89937/is-html5-sessionstorage-secure-for-temporarily-storing-a-cryptographic-key)
the contents of `sessionStorage` to disk when reloading the page.
This is an issue as we don't want the key to leak, and any write to the
filesystem places it outside of our control.
## Divide to Conquer
The approach taken by ProtonMail is to split the key into two parts,
store each part using different techniques, and recompose the key on page load.
To split the key, it is XORed with a buffer of random bytes.
A copy of the original random data is going to be the other part,
so that both of them individually are random, but by XORing them together,
the randomness cancels out and reveals the key:
```txt
# Split
a = key ^ random
b = random
# Recompose
a ^ b
=> (key ^ random) ^ random
=> key ^ (random ^ random)
=> key ^ 0
=> key
```
One part is sent to `sessionStorage`, and the other uses a trick discovered by
[Thomas Frank](https://www.thomasfrank.se) named
[SessionVars](https://www.thomasfrank.se/sessionvars.html).
## `window.name`
There is a `name` property on the global `window` object in the browser.
Its value persists across page reloads, but is not written to disk.
It has been used for [cross-domain communications](https://developer.mozilla.org/en-US/docs/Web/API/Window/name#Notes),
and because other domains can see its value, we can't send anything there in clear text.
Fortunately, other domains can't access our domain's `sessionStorage`,
so all they would see in `window.name` is random data.
## The Right Amount of Persistence
The key does not need to be saved in those locations at all times however.
Because `window.name` is writable by everyone, it would be easy for attackers
to erase the key if it was stored there as a single source of truth.
Instead, we can keep the key in memory, and only persist the key to those
shared locations when the memory will be destroyed: on page unloads.
If the user reloaded the page, both parts of the key will be preserved
and reassembled on page load. If they closed the tab or the window,
both parts will be erased by the browser (end of the session).
## Cleaning up
Now our key has been recomposed, both storage locations can be cleared
as we don't want our [horcruxes](https://harrypotter.fandom.com/wiki/Horcrux)
to be left around.
The original implementation of this system is available in ProtonMail's
[shared library](https://github.com/ProtonMail/proton-shared/blob/master/lib/helpers/secureSessionStorage.js#L7).
## Introducing [`session-keystore`](https://github.com/47ng/session-keystore)
<NpmPackage
pkg="session-keystore"
repo="47ng/session-keystore"
accent="text-indigo-500"
className="my-8"
/>
For all to use this key storage technique without depending on ProtonMail's
internal library, I built [`session-keystore`](https://github.com/47ng/session-keystore),
a TypeScript implementation with a few extra features:
- Key expiration dates
- Multiple stores
- Key access/modification/expiration callbacks for monitoring
================================================
FILE: packages/francoisbest.com/content/blog/2019/strava-auth-cli-in-rust/index.mdx
================================================
---
title: Building A Strava Authentication CLI In Rust
description: A first look at how to implement an OAuth authentication / authorization exchange in Rust.
publicationDate: '2019-01-15'
tags: [rust, strava]
---
I use the Strava API to develop a little web app called
[Stravels](https://pwa.stravels.io).
It requires authentication tokens, which are obtained in the classic OAuth
flow way:
1. Visit a login page on the provider's domain (Strava), passing the app ID.
2. Log into the provider's system
3. Arrive to the authorization page, where you can:
- Authorize the whole app for the permissions it requested
- Or a subset of permissions
- Or deny the access altogether
4. The page then redirects you to a URL with a query parameter containing a code.
5. You must then do a token exchange by sending this code to the Strava API
and they will give you a pair of tokens (refresh and access) in exchange.
## Tools Against DRY
In order to play with the Strava API, I found myself having to build the
login page first in my prototypes, complete with handling the redirection.
This is not ideal, as this code would likely be thrown away when implementing
the actual authentication view, and it slows down the "idea to prototype" phase,
so the situation called for a better tool to obtain tokens easily
to do quick exploratory API calls.
Services with OAuth APIs usually provide you with a test token in their web UI,
but Strava's has a limited scope which made it hard to painlessly explore the
full API.
I recently started learning Rust, and was looking for something to build with
it. This sounds like the perfect excuse.
## Getting Started
Here's what we want:
1. A command-line utility (no need for a web app or even a fancy UI)
2. That takes basic information as input
3. That lets you login and authorize the app
4. And gives you back the access and refresh tokens
Point #1 gives us the development context, we need a Rust binary:
```shell
$ cargo init strava-auth --bin
$ cd strava-auth
```
<Note title="Note">
I won't be focusing on how to install Rust, what it is or how to use Cargo,
there's plenty of documentation out there already.
</Note>
### Dependencies
We are going to split the program into four parts, where we will need to:
1. Parse command line arguments
2. Open a URL into the default browser
3. Start a web server on localhost to handle the redirection
4. Make HTTP requests to the Strava API
Fortunately, the [Rust ecosystem](https://crates.io) has everything we need:
```shell
$ cargo add structopt webbrowser rocket reqwest
```
import { FiPackage } from 'react-icons/fi'
<Note icon={FiPackage} status="info">
To add dependencies this way, check out
[`cargo-edit`](https://github.com/killercup/cargo-edit).
</Note>
Here's a recap of our dependencies:
- [`structopt`](https://crates.io/crates/structopt) handles CLI arguments parsing and validation
- [`webbrowser`](https://crates.io/crates/webbrowser) opens URLs in the default browser
- [`rocket`](https://crates.io/crates/rocket) is an awesome web server
- [`reqwest`](https://crates.io/crates/reqwest) sends HTTP requests
Before going further, we're going to need to use the nightly version of Rust,
as required by Rocket (at the time of writing):
```shell
$ rustup override set nightly
```
## Strategy
Before jumping into the code, here's what we're going to do:
1. Get the info we need from the command line
2. Build the authorization URL to open in the browser
3. Start a web server that listens on localhost for the redirection
According to the [Strava authentication documentation](https://developers.strava.com/docs/authentication/#request-access),
we need the client ID and secret, which can be found for our Strava
app [in the settings](https://www.strava.com/settings/api).
We'll pass the client ID and secret to our CLI like this:
```shell
$ strava-auth --id 123456 --secret 0123456789abcdef
```
## Command Line Arguments
Parsing command line arguments (and validating, and displaying help, and all
the perks of user interaction) is made easier with
[`structopt`](https://docs.rs/structopt/):
```rust title="main.rs"
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
#[structopt(name = "strava-auth")]
/// Authorize and authenticate a Strava API app.
///
/// Requires a GUI web browser to be available.
struct Arguments {
#[structopt(short = "i")]
id: u32,
#[structopt(short = "s")]
secret: String,
}
fn main() {
let args = Arguments::from_args();
println!("{:#?}", args);
}
```
Let's test it:
```shell
$ cargo run -- --id 123456 --secret 0123456789abcdef
```
<Note title="Did you know ?" status="info">
The `--` after `cargo run` is a Unix trick to pass the arguments to our
program and not to cargo itself.
</Note>
We should get the following output:
```shell
Arguments {
id: 123456,
secret: "0123456789abcdef"
}
```
## Building The Authorization URL
The specification for the url format is given by the
[Strava authentication documentation](https://developers.strava.com/docs/authentication/#request-access).
We'll use the web version: `https://www.strava.com/oauth/authorize`.
By default we'll also request all the possible scopes, as we can manually
authorize them individually in the authorization page. Strava sends us
back the approved scopes in the redirection URL, so we'll display them as an
output to the user in addition to the tokens.
For the `redirect_uri`, we'll use `localhost` as it's where our listening
server will be. Luckily, it's whitelisted by Strava for local development,
so no need to mess with the OAuth redirection whitelist in the settings
there.
Here's what the code looks like:
```rust title="main.rs"
fn make_strava_auth_url(client_id: u32) -> String {
let scopes = [
// "read", // Shadowed by read_all
"read_all",
"profile:read_all",
"profile:write",
// "activity:read", // Shadowed by activity:read_all
"activity:read_all",
"activity:write",
]
.join(",");
let params = [
format!("client_id={}", client_id),
String::from("redirect_uri=http://localhost:8000"),
String::from("response_type=code"),
String::from("approval_prompt=auto"),
format!("scope={}", scopes),
]
.join("&");
format!("https://www.strava.com/oauth/authorize?{}", params)
}
```
Now we can use this function and pass the generated URL to
[`webbrowser`](https://github.com/amodm/webbrowser-rs) to open it in the
default browser:
```rust title="main.rs"
use webbrowser;
// ...
fn main() {
let args = Arguments::from_args();
let auth_url = make_strava_auth_url(args.id);
if webbrowser::open(&auth_url).is_err() {
// Try manually
println!("Visit the following URL to authorize your app with Strava:");
println!("{}\n", auth_url);
}
}
```
Here we can see an example of how good error handling is in Rust: rather than
calling `.unwrap()` on the result of `webbrowser::open()` and crash if it
failed to find a suitable browser to open the URL in, we provide a fallback
by showing it to the user and letting them open it manually.
This is ideal, because just showing them an error message they can't do much about
provides zero value and a lot of frustration, whereas a manual action keeps the
process going.
Let's test what we've done so far.
```shell
$ cargo run -- --id <your-app-id> --secret <your-app-secret>
```
You should get something like this in your browser:
<WideContainer>
<figure>

<figcaption>Strava's authorization page</figcaption>
</figure>
</WideContainer>
At this point, if you click either Authorize or Cancel, you'll get an `Unable to connect`
error, as there is no server to handle the redirect.
## Adding The Server
Spinning a web server is made easy with [Rocket](https://rocket.rs). To keep
things tidy, we'll implement the server in a separate file `server.rs`.
We're going to define two routes, one for a successful redirection (which
contains a code and a list of approved scopes), and one for redirection
errors:
```rust title="server.rs"
use rocket::config::{Config, Environment, LoggingLevel};
use rocket::http::RawStr;
#[get("/?<code>&<scope>")]
fn success(code: &RawStr, scope: &RawStr) -> &'static str {
println!("Code: {}", code);
println!("Scope: {}", scope);
"✅ You may close this browser tab and return to the terminal."
}
#[get("/?<error>", rank = 2)]
fn error(error: &RawStr) -> String {
println!("{}", error);
format!("Error: {}, please return to the terminal.", error)
}
```
Rocket lets us define routes based on the presence of query parameters, and
will do the routing for us. However, as both paths are `/`, we need to tell
Rocket to try the success route first, then the error one if either `code`
or `scope` is missing. This is done with [ranking](https://rocket.rs/v0.4/guide/requests/#forwarding).
If the redirect contains both query parameters of `code` and `scopes`, the
first handler `success` will be called, otherwise an `error` query parameter
should be there, and the second handler `error` will be called.
If neither is present, then we'll get a 404 error (but we don't care since
the problem would be on Strava's side).
In both case, we print the parameters to the terminal, and return a string
as a response that will be visible in the browser, instructing the user to
return to the terminal.
## Starting The Server
Let's add a function to `server.rs` to start the Rocket server:
```rust title="server.rs"
pub fn start() {
let config = Config::build(Environment::Development)
.log_level(LoggingLevel::Off)
.finalize()
.unwrap();
rocket::custom(config)
.mount("/", routes![success, error])
.launch();
}
```
Most of the complexity here is to create a custom configuration for Rocket
that suppresses logging to the console, as we don't care much for its
internals in this case.
Let's move back to `main.rs`:
```rust title="main.rs"
// Required for Rocket code generation to work
#![feature(proc_macro_hygiene, decl_macro)]
#[macro_use]
extern crate rocket;
mod server; // Include our `server.rs` file
// ...
fn main() {
let args = Arguments::from_args();
let auth_url = make_strava_auth_url(args.id);
if webbrowser::open(&auth_url).is_err() {
// Try manually
println!("Visit the following URL to authorize your app with Strava:");
println!("{}\n", auth_url);
}
server::start();
}
```
## A Case For Multithreading
By default, Rocket's `launch()` method will block the thread it's running on
to wait for requests, indefinitely, and never return.
This is not ideal, as we're going to have to continue doing stuff once the
redirection has succeeded (or failed), and moving that logic into the route
handlers would not be recommended: it would duplicate some logic, make the
whole program hard to reason about and the code even harder to read without
knowing the data flow.
Fortunately, Rust is great for multithreaded applications. So we're going to
start the web server in a separate thread, and have it communicate with the
main thread with an `mpsc` channel (there's many multithreading crates in the
ecosystem, but the standard library will do just fine here).
Since many things can happen that the server may want to report, we'll start
by defining some data structures to exchange:
```rust title="server.rs"
#[derive(Debug)]
pub struct AuthInfo {
pub code: String,
pub scopes: Vec<String>,
}
impl AuthInfo {
pub fn new(code: &RawStr, scopes: &RawStr) -> Self {
Self {
code: String::from(code.as_str()),
scopes: scopes.as_str().split(",").map(String::from).collect(),
}
}
}
```
When everything goes well, the route handler will send an `AuthInfo` struct
back to the main thread, that contains the authorization code and the
approved scopes.
Still, we need a single type to send through the channel, and as things can
go wrong, let's use a classic Rust construct, `Result`:
```rust
pub type AuthResult = Result<AuthInfo, String>;
```
Our errors can be strings for now, as there's not much interest to strongly
type them at this point.
## Passing Data Across Threads
Since we're going to start our web server in a different thread, we need a
way to pass data between the route handler's thread and the main thread.
Rust does that through [`mpsc` channels](https://doc.rust-lang.org/std/sync/mpsc/).
We're going to create a transmitter (`tx`) and a receiver (`rx`), keep the
`rx` in the main thread and pass the transmitter to the server thread.
This is what it would look like:
<Note status="error" title="Hic sunt dracones">
This code won't compile yet.
</Note>
```rust title="main.rs"
use std::sync::mpsc;
fn main() {
// ...
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
server::start(tx);
});
// recv() is blocking, so the main thread will patiently
// wait for data to be sent through the channel.
// This way the server thread stays alive for as long as
// it's needed.
match rx.recv().unwrap() {
Ok(auth_info) => {
// Do something with the result
}
Err(error) => eprintln!("{}", error),
}
}
```
```rust title="server.rs"
use std::sync::mpsc;
// ...
pub type Transmitter = mpsc::Sender<AuthResult>;
pub fn start(tx: Transmitter) {
// How do we pass tx to the route handlers ?
}
```
## Data-Race Freedom
You know how everyone says Rust is data-race free ? We're about to witness an
example.
Rocket uses multiple threads to parallelise request handling. Even though we
are only going to handle a single request, Rust is here to let us know that
things could go wrong when passing data from the route handler back to the
main thread.
As we don't have a way to clone our `tx` when Rocket spawns its worker
threads, we're going to use a Mutex instead (performance is not a critical
feature here).
We're also going to reduce the number of worker threads to 1, even if it does
not magically bring back thread safety, it will at least avoid unnecessary
thread creation.
To pass the Mutex, we'll use Rocket's managed state facility. Here's what
our updated `server.rs` looks like:
```rust title="server.rs"
use rocket::State;
use std::sync::Mutex;
// ...
pub type TxMutex<'req> = State<'req, Mutex<Transmitter>>;
// --
#[get("/?<code>&<scope>")]
fn success(code: &RawStr, scope: &RawStr, tx_mutex: TxMutex) -> &'static str {
let tx = tx_mutex.lock().unwrap();
tx.send(Ok(AuthInfo::new(code, scope))).unwrap();
"✅ You may close this browser tab and return to the terminal."
}
#[get("/?<error>", rank = 2)]
fn error(error: &RawStr, tx_mutex: TxMutex) -> String {
let tx = tx_mutex.lock().unwrap();
tx.send(Err(String::from(error.as_str()))).unwrap();
format!("Error: {}, please return to the terminal.", error)
}
// --
pub fn start(tx: Transmitter) {
let config = Config::build(Environment::Development)
.log_level(LoggingLevel::Off)
.workers(1) // No need for multithreading here
.finalize()
.unwrap();
rocket::custom(config)
.mount("/", routes![success, error])
.manage(Mutex::new(tx))
.launch();
}
```
## Authenticating With The Strava API
If everything goes right, we should now have access to an authorization
code, yay ! Let's now turn it into a token.
The [Strava documentation](https://developers.strava.com/docs/authentication/#token-exchange)
tells us what to do:
```rust title="strava.rs"
use std::collections::HashMap;
pub fn exchange_token(code: &str, id: u32, secret: &str) {
let client = reqwest::Client::new();
let mut body = HashMap::new();
body.insert("client_id", format!("{}", id));
body.insert("client_secret", String::from(secret));
body.insert("code", String::from(code));
body.insert("grant_type", String::from("authorization_code"));
let res = client
.post("https://www.strava.com/oauth/token")
.json(&body)
.send()
.unwrap()
.error_for_status()
.unwrap();
println!("{:#?}", res);
}
```
```rust title="main.rs"
mod strava;
fn main() {
// ...
match rx.recv().unwrap() {
Ok(auth_info) => {
strava::exchange_token(&auth_info.code,
args.id,
&args.secret);
}
// ...
}
}
```
## Parsing And Displaying The Result
The result we get is that of the response given back by the Strava API. What
we need is actually in the body, which is a piece of JSON.
We can tell Rust to validate that response against a format and make it into
a native object using `serde` (and its friends `serde_json` and
`serde_derive`).
```shell
$ cargo add serde serde_json serde_derive
```
```rust title="main.rs"
#[macro_use]
extern crate serde_derive;
```
```rust title="strava.rs"
#[derive(Debug, Deserialize)]
pub struct Login {
pub access_token: String,
pub refresh_token: String,
}
pub type LoginResult = Result<Login, reqwest::Error>;
pub fn exchange_token(code: &str, id: u32, secret: &str) -> LoginResult {
let mut body = HashMap::new();
body.insert("client_id", format!("{}", id));
body.insert("client_secret", String::from(secret));
body.insert("code", String::from(code));
body.insert("grant_type", String::from("authorization_code"));
let mut res = reqwest::Client::new()
.post("https://www.strava.com/oauth/token")
.json(&body)
.send()?
.error_for_status()?;
Ok(res.json()?)
}
```
We can now finish the program and display the login data in `main.rs`:
```rust title="main.rs"
// ...
fn main() {
// ...
match rx.recv().unwrap() {
Ok(auth_info) => {
match strava::exchange_token(&auth_info.code,
args.id,
&args.secret) {
Ok(login) => {
println!("{:#?}", login);
println!("Scopes {:#?}", auth_info.scopes);
}
Err(error) => eprintln!("Error: {:#?}", error),
}
}
Err(error) => eprintln!("{}", error),
}
}
```
## Lifetime Issues
In the case where something wrong happens, we have a problem: the main thread
exits too quickly, and along with it goes the server thread, which does not
have enough time to send its response to the browser. So instead of our nice
error message, we see a "Connection reset" error.. :/
We don't have this issue on the happy path, as the request to the Strava API
likely adds a little delay before the program exits, and lets the server send
the response.
It would be nice if we could let the server respond properly, then kill the
program. We can do so by adding a small delay in the main thread if an error
occurs:
```rust
match rx.recv().unwrap() {
Ok(auth_info) => {
// ...
}
Err(error) => {
eprintln!("{}", error);
// Let the async server send its response
// before the main thread exits.
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
```
## Closing Notes
While this project is not an example of Rust's best practices (in term of error
handling, thread synchronization, logging etc..), it shows how straightforward
it can be to build a quick prototyping tool to solve a pain point in Rust, by
leveraging the safety of the language and the diversity of the ecosystem.
## Resources
The [source code for this project](https://github.com/47ng/strava-auth-cli)
is available on GitHub.
================================================
FILE: packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/index.mdx
================================================
---
title: Dark Mode For Excalidraw
description: How to give a dark twist to Excalidraw diagrams with CSS filters.
publicationDate: '2020-06-05'
tags: [frontend, css, excalidraw]
---
If you don't already know about [Excalidraw](https://excalidraw.com),
it's a great simple sketching webapp that got very popular during the COVID-19
crisis when everyone was working from home and needed a virtual drawing board.
It's also been used for
[art](https://twitter.com/excalidraw/status/1248594297559158784),
[cartoons](https://twitter.com/Pinnassog/status/1247893044231168001),
[mockup design](https://twitter.com/imlongnguyen/status/1263766322443214851)
and in many other creative ways.
While building this blog, I wanted to easily embed Excalidraw diagrams and
drawings on blog articles (that's what [Vjeux](https://blog.vjeux.com/) designed it for), but also have it
integrate nicely in Dark Mode.
One simple approach is to keep the white background in the SVG or PNG export:
import { ThemeControls } from 'ui/components/theme-controls'
_Toggle Dark Mode:_ <ThemeControls className="ml-2" />
import VennDiagram from './venn'
import StatusText from './status-text'
<VennDiagram
arial-label="Venn diagram: [Excalidraw (this post] Dark Mode), with a white background"
className="mx-auto mb-8 bg-white"
/>
However, for posts with many diagrams, it can lead to eye fatigue when moving
between dark mode text and light mode diagrams.
If we just remove the background, the black lines in the drawing
will almost disappear in dark mode, and the cross-hatch will not look good:
_Toggle Dark Mode:_ <ThemeControls className="ml-2" />
<VennDiagram
aria-label="The same Venn diagram, but without a background"
className="mx-auto mb-8"
/>
## CSS Filters To The Rescue
We can use [CSS filters](https://developer.mozilla.org/en-US/docs/Web/CSS/filter)
to change the colours of the SVG. They are supported in
[most browsers](https://caniuse.com/#feat=css-filters),
and allow us to invert the colors when Dark Mode is active:
```css {6}
.excalidraw.light {
filter: none;
}
.excalidraw.dark {
filter: invert(100%);
}
```
One problem with inverting all colours this way is that it changes the hue:
blue becomes orange, green becomes pink, and the general nature of the diagram changes.
See what happens when you toggle the theme and only invert the colours:
_Toggle Dark Mode:_ <ThemeControls className="ml-2" />
<StatusText className="dark:filter-invert mx-auto mb-4 max-w-xs" />
The hue change breaks the meaning associated to each line, so we need to keep
the general hue and only invert the lightness.
## Rotating The Hue Back
If we add another filter, we can move the hue back to its initial value, and
therefore only the lightness will have changed:
```css {2}
.excalidraw.dark {
filter: invert(100%) hue-rotate(180deg);
}
```
_Toggle Dark Mode:_ <ThemeControls className="ml-2" />
<StatusText className="mx-auto mb-4 max-w-xs dark:hue-rotate-180 dark:invert" />
<VennDiagram className="mx-auto mb-4 dark:hue-rotate-180 dark:invert" />
import { GoLightBulb } from 'react-icons/go'
<Note title="Note" icon={GoLightBulb}>
The brightness of some colors is a little off, but overall this gives a much
better integration of the drawing on the page.
</Note>
I found this technique on [David Baron](https://dbaron.org/log/20110430-invert-colors)'s
blog, he uses SVG filters instead of CSS, which have [better support](https://caniuse.com/#feat=svg-filters)
but can be slightly more complex to integrate.
## Bonus: Dark Mode For Excalidraw.com
We can also apply the same trick and get Dark Mode on the Excalidraw webapp,
by styling the `body` directly:

There is a [discussion](https://github.com/excalidraw/excalidraw/issues/1148)
on how to integrate an official Dark Mode for Excalidraw in the official GitHub
repository, and I think it would make a great addition to an already wonderful
tool.
Follow me on [Mastodon](https://mamot.fr/@Franky47) for more hacks and front-end design tips!
================================================
FILE: packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/status-text.tsx
================================================
export default function StatusText(props: React.ComponentProps<'svg'>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 339.5 259" {...props}>
<g><text x="0" y="18" fill="#2b8a3e" fontFamily="Virgil" fontSize="20" textAnchor="start" transform="translate(10 137) rotate(0 119 13)" style={{ whiteSpace: 'pre' }}>Green: Everything is OK!</text></g><g><text x="0" y="18" fill="#e67700" fontFamily="Virgil" fontSize="20" textAnchor="start" transform="translate(10.5 179) rotate(0 159.5 13)" style={{ whiteSpace: 'pre' }}>Orange: There might be issues...</text></g><g><text x="0" y="18" fill="#c92a2a" fontFamily="Virgil" fontSize="20" textAnchor="start" transform="translate(12.5 223) rotate(0 153.5 13)" style={{ whiteSpace: 'pre' }}>Red: Definitely a problem here.</text></g><g><text x="0" y="18" fill="#1864ab" fontFamily="Virgil" fontSize="20" textAnchor="start" transform="translate(11.5 94) rotate(0 142.5 13)" style={{ whiteSpace: 'pre' }}>Blue: Just to let you know...</text></g><g><text x="0" y="18" fill="#000" fontFamily="Virgil" fontSize="20" textAnchor="start" transform="translate(13 10) rotate(0 61 13)" style={{ whiteSpace: 'pre' }}>Normal text</text></g><g><text x="0" y="18" fill="#888" fontFamily="Virgil" fontSize="20" textAnchor="start" transform="translate(10 49) rotate(0 107 13)" style={{ whiteSpace: 'pre' }}>Slightly less important</text></g>
</svg>
)
}
================================================
FILE: packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/venn.tsx
================================================
export default function VennDiagram(props: React.ComponentProps<'svg'>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 411.83673469387566 170.13578067015692" {...props}>
<g transform="translate(205.3402729302262 -36.36068109349267) rotate(89.99999999999994 75.06789033507846 121.42857142857113)"><path d="M27.613832210242435 26.654364107343916 C27.613832210242435 26.654364107343916, 27.613832210242435 26.654364107343916, 27.613832210242435 26.654364107343916 M27.613832210242435 26.654364107343916 C27.613832210242435 26.654364107343916, 27.613832210242435 26.654364107343916, 27.613832210242435 26.654364107343916 M22.0386480680214 41.301851034237174 C28.85968828502201 30.81684570565175, 34.08700529669771 22.86076298355781, 48.015906008974426 8.935901556604076 M20.834498007829648 41.77429551824751 C26.06164030030801 34.93611918917646, 31.89403541850254 27.078559433066975, 46.83586477589678 11.165539665587712 M14.288207230202005 54.13338473250775 C21.96094538973891 47.736745678039675, 29.254679555463696 36.06714634230871, 56.18790265739233 7.244883890174499 M11.794066208458588 55.452124156036064 C26.798523399497974 41.099642407767256, 39.41703630272054 25.4293757721156, 55.23882650219173 6.389796201981774 M10.389628364296897 65.15338085759969 C24.391660126989198 48.50470312705504, 39.641376428604914 28.973266216690973, 62.644106640775924 4.046524277302034 M11.133425687032087 64.99394660797508 C21.024091016894893 51.77289438467008, 33.88078884301007 40.054918747618814, 61.45825836557639 5.964432120668299 M10.697153540810042 71.64151057263484 C23.887613297970766 53.39043910063293, 39.142498883084436 37.181698847692466, 66.29544406953426 4.514804328880846 M8.022187166397863 72.33965132234266 C22.604806781841166 57.20631005026416, 34.33957375552128 41.74923104247311, 67.63256450380356 4.655286942697703 M6.653722754188415 81.13434380939853 C24.140220497673383 60.07589782321179, 44.3936898558405 37.96645495968446, 73.92412577508583 1.988571486027901 M8.14639366834664 79.85204114108038 C32.17961264598766 52.51396036415376, 54.46173267017808 26.470594779438443, 74.1366681506355 3.621581644343202 M4.541737806860034 86.06918758923044 C28.07959632849149 60.975034130662955, 53.94896978306774 31.923786940269054, 81.24652925187763 3.105958180528475 M6.896784397650187 88.52317162068417 C30.65378089356036 56.8763192585681, 57.55586515608706 27.18743168569197, 80.08239357786294 3.112837817546321 M4.577767164281376 95.18263392413866 C21.8628732171649 77.49837117675233, 37.762564068223384 56.72160863641177, 84.60031671536618 1.9199626743906109 M3.9009995971087292 94.93539893331837 C32.873384111289425 62.96146751756457, 63.06226747539929 31.14200834670089, 86.44308415328615 2.1469585094713537 M3.2239001288804516 104.60730233546289 C29.848785101919777 70.75978928458804, 57.774700325261165 38.93069060041327, 89.2876121889889 5.259320319176815 M2.7724347214940295 103.92062238547894 C25.041590982083065 77.07150671070265, 46.65799574503534 52.121460651931955, 89.83427942768631 5.4335994818370565 M1.594569753085267 111.76096859454287 C27.32263048210718 79.01631882530552, 51.938634773296535 49.10859885547016, 91.95000150416439 8.353503556004021 M2.9545633505778426 110.01191315866186 C23.23161373995486 86.96715336404584, 43.09894745575313 63.88902624788555, 92.32621513482576 6.561669210916612 M3.831552839323841 114.97156472271831 C22.566825748649936 94.82700891021099, 39.12100922478446 71.46345888145746, 96.31929618433011 9.476560236434743 M3.533437189131938 116.63101430936308 C38.8674256820232 73.56633387235198, 73.96749979170221 30.88368714490717, 96.63208707548571 8.205667208428785 M1.510792853052635 123.36630773199727 C35.59995642524877 85.39934202518808, 69.45543072785117 44.854120000749575, 100.90465150292354 12.062694462031487 M3.0820089799565267 121.53996950974692 C37.068099771668784 83.06250904299525, 72.86141479364348 42.58461511922695, 99.2326691754474 9.803764861092333 M3.297695067709178 128.72315172394224 C35.555644940358754 89.45649590952729, 72.30141085217682 49.32033070564579, 105.36756648338223 12.33126658435674 M2.7529276488328875 128.25507514653214 C41.56458606628667 84.96436437807488, 79.75964563910138 42.546367445572656, 104.18881348549212 12.74530543062599 M4.010008506730941 134.60520705011572 C29.98437408841358 102.07341145490922, 57.28468220404958 70.37837427774363, 108.89688389914365 14.911793204973009 M4.778920246542242 132.74379948143746 C25.005493183734533 107.69963895223063, 45.660687097816236 82.95771645335631, 106.95395018140108 13.558304053748515 M2.3367669145649472 140.74044989230302 C36.66114868559231 103.02672967663727, 65.93560972418518 64.97896868382118, 113.2398493026358 15.714237595220034 M3.6292329198753635 139.36699223140445 C32.288447604337264 104.69893266631703, 64.25496260483207 69.4918875977563, 112.84264659294607 14.342238161955898 M5.837524872629686 143.62788394162104 C33.99187269853809 111.38232622614942, 67.39671495786085 73.81568607032729, 113.14075685133417 18.08226717755138 M4.446367027594491 145.04754624770626 C47.461649501275815 97.39318574486902, 90.44140061875191 48.59553693252728, 114.90675302994629 18.483804962857953 M5.381003625372159 152.6907978798551 C39.95715379845389 109.89020065102773, 77.20504532084898 64.28197401655868, 118.5886576556478 23.36173856286124 M4.922224966490376 150.98615499728447 C49.536551845402855 100.13765388798389, 94.56516019019031 48.79650410415857, 115.62061113015453 22.787906477505125 M3.4882681672393545 155.6376366387904 C42.39345190808594 109.59738531565392, 84.38270281018627 65.37066263207369, 118.63176948901412 26.07279335271211 M5.078787258353756 157.58468382208054 C29.2774973664221 129.01931552555038, 53.677808556417546 102.47360859926157, 118.132963569352 26.212245342616157 M3.3319633586403086 163.0681630793212 C35.688430084977874 123.89196759139219, 67.16443867620552 87.82389477373687, 122.3767129038612 26.516019818443738 M5.704848665937419 162.61483161814493 C34.06680306006962 129.2545001228118, 60.725729424092535 97.86023216017435, 122.7036901773105 28.035986489687048 M5.862841694022009 166.13613463323276 C32.61257747990054 138.96990231201738, 55.61170561865708 112.39175166635843, 122.73927511564551 29.416291222064153 M6.920045961032102 166.44330747741918 C47.03091763174603 122.52032932576688, 85.56744157260553 78.84591037467332, 123.89207182183026 32.06609546588208 M6.808999417831416 169.1554649823103 C34.93199461334181 136.90535516081246, 61.48852143159688 105.35916942668862, 126.75576417679505 35.01950833491307 M8.611594040428514 170.20049236684477 C45.92532180623909 127.97397250399564, 83.29060646377 83.92750642078725, 126.30564458268302 35.29658095269721 M11.050876524515573 177.22522405833914 C33.86344431808426 147.61907023640583, 55.549525810854604 121.76635840144311, 127.33377286074733 37.736437758553016 M10.24678592591745 174.96793912836327 C47.09845572787167 132.8563001026435, 84.03644176330478 91.25935730276893, 129.78846363565003 38.11483308685123 M12.964433845492977 177.59050930243623 C46.57097782358524 139.37341296033284, 76.61447026350491 101.01403809577549, 130.28323769093984 41.01300234454648 M10.653669851261157 178.70174373824787 C37.4574904849596 150.74206697883557, 61.82150500887842 122.6400954801489, 131.88966228151256 40.73441388006286 M14.8597988752106 183.7957031463868 C56.50368452716395 137.16690864351693, 95.34557847016185 88.72915533140223, 132.6914389408101 44.955281194455935 M13.897195088250356 183.94830278844006 C41.361066747723385 149.5592684649719, 70.57668279819993 116.8905532636421, 133.67595194605184 44.38255721905084 M15.32444285811546 189.0453442719762 C60.83588601979584 135.23160586255406, 106.3934315789021 81.83423276505552, 136.64194160480506 50.7092192400667 M15.059889033675816 188.7676847458814 C46.79147352705681 150.754182345723, 81.84837831375965 111.8908276199699, 134.88806315105862 49.24237844575673 M17.566357875626053 191.56499887032135 C46.96012886663121 155.61290523698668, 78.31396371370876 121.699385317944, 136.90171345636227 54.57620808294126 M16.02253429629978 191.6570012118451 C56.48582283965155 144.77161016858648, 97.81884803182629 98.8651847177643, 137.06536357231417 54.13095519689926 M15.85270267218938 198.7545181232075 C51.415111989614594 156.82790407713256, 84.57587290185427 115.88270414487893, 139.9521840769002 58.88337641419653 M17.340314522913005 197.26616440327268 C55.636683815940714 152.28188402020757, 94.58889603947893 108.90164046654574, 138.0144170685807 56.93211782375161 M21.165097800233454 199.0394903019744 C44.52002198755406 171.2661210961201, 70.56091188904296 140.05641701617546, 138.42789068187537 64.13684749584061 M19.335609543287248 199.8118392516601 C57.19609637689983 157.46792176000156, 95.86633207144052 112.38350044427368, 139.6851295076585 62.312206873478125 M20.950389504205276 204.5150790884073 C45.72427673785457 172.4077123822707, 73.61855598501535 146.3699728438806, 142.73995035270627 66.60906251899043 M21.286259254213263 203.28378172394886 C63.35887882700983 157.22062940441654, 104.80809001819591 108.5837865717212, 141.2317209983098 66.86989644735196 M23.463826807542304 208.30689483406871 C57.18064166405182 170.8242298652331, 88.12195010086721 131.47373424033015, 142.20258780484943 72.00145724565405 M25.206240706472258 206.36821008185774 C54.30700400102923 173.1760221809117, 83.20991960600506 138.9945021324201, 141.5767536583349 72.36137084253747 M27.48800248368208 208.95070414052117 C65.4688879997237 163.9346351371828, 106.51380100920011 119.44583016425902, 144.0005485907328 77.78649494717118 M27.002109075845524 210.3900144621055 C51.73694125966582 181.98070882297304, 78.08107942278025 153.06443528355513, 143.81604481750546 77.60485749053058 M28.707853056062106 214.01742985932714 C69.64119123686484 168.16390392514222, 113.03002733819771 120.62162646446001, 145.5959220127844 79.09679514488154 M30.285497663114313 214.5002568764109 C62.977729620104455 172.96157213568523, 99.5681662306525 132.81365919183537, 145.0629899305933 79.93225569782727 M30.741717827110364 214.98344151182633 C71.6732197741114 167.3236544350126, 113.95082738733184 121.19708372591147, 147.13496403646076 86.6142623507932 M32.84167692305036 216.10896163126986 C75.05437658945192 166.82807675751738, 117.85072248628578 118.08217728590064, 146.29288549039865 86.64076538659187 M32.89210276228334 217.8743936100268 C78.78184623855167 169.80882349622925, 125.47627239127883 118.24213701935793, 147.72762855116136 87.40269984557756 M33.8178183484539 219.0914536488467 C79.35967372762855 169.13375949834358, 123.73053616165554 120.07160080832675, 148.05996028866477 87.38800087265201 M36.45703466400903 222.36196817526832 C73.20991736313152 181.49247574872493, 108.2295075397841 140.971991856993, 148.66956387739015 93.36287254235651 M37.47184864409667 222.44760098563745 C70.80061348905937 184.03346782344752, 106.67414725481166 142.47744856982928, 149.15687192323935 94.20955372928586 M40.48082505572499 228.40771305911335 C77.74276338740023 182.08799882969757, 118.05743354541238 138.41436474226424, 150.25041140454667 100.25389622180148 M40.36071361075999 226.5081531533608 C74.90639410765993 187.60558474209626, 107.46462860781051 150.3288294467693, 148.8605054358657 100.10288202232107 M40.785129210868675 231.1333323631244 C80.40402780046446 187.33197945278837, 117.0172735665305 142.3851665166963, 148.28792015606845 106.63888123547498 M41.80893717422505 229.36753153873553 C80.0044306853488 186.35295432070149, 116.36264438849847 143.91983156065805, 150.90358787732507 105.53545401347641 M45.65606421085862 230.93626727841837 C70.33710806986137 201.62525224874622, 93.67276191479587 173.03622831127296, 151.12718290539294 110.49109418493948 M45.033239443254146 232.5619102430348 C67.27523316739897 205.58583285223827, 88.5792726918385 180.76426200443282, 149.91142442339867 110.6134098394706 M50.28285477115128 233.64295356700342 C83.69585210607787 191.3182617723995, 120.863373905771 150.26565861973666, 150.0156361759577 116.8189579217575 M49.018682535647486 232.78497356342297 C89.14444179382062 187.81961976085515, 127.01818430027262 142.85448003875788, 151.2143983748678 116.26906151202242 M51.7252464441747 232.85085450021916 C77.1888763983059 204.39007023658496, 104.5727997035876 177.42147707747876, 149.47806024120018 121.29105154749155 M52.92358378117686 233.79244893139594 C90.20528225908419 193.79686355317688, 124.83522233844663 152.86642004537183, 150.6604711575137 121.44839291785061 M55.35215144538533 238.08171834007373 C87.48638338451514 200.1478460510624, 119.74890644941127 165.86541553854525, 149.6915791245579 125.86111041370411 M57.955661547642464 236.1178643941181 C92.64385269946192 194.183921662896, 129.77510332082954 153.74147613105646, 152.42768232211762 128.29855981867394 M61.15300282721118 237.87832085790677 C87.0979290010386 208.63797496412712, 113.8090100685462 180.46558719104914, 152.2046015704778 135.14773570218043 M61.12088941481606 237.73179113308532 C91.79032091327987 201.49933519088557, 124.72200557470728 164.6072072248558, 152.18059051547021 134.11009943143384 M63.488518363089796 238.44830365528088 C86.17210271987955 220.2626694140002, 102.26541049938702 196.67732268671512, 150.96788007340695 143.30468484470583 M64.41176005847895 240.24818178501647 C92.21322565569152 209.95515444263344, 120.42974589401626 178.0680145160254, 149.53883250936235 141.74972084807195 M70.77781860544914 241.7797550035357 C100.10237164658722 205.18973572355978, 130.54544823843239 173.6160926917359, 148.02162893577335 152.13645236262005 M67.93190330840284 240.60245128140744 C90.68264788468187 217.63525478715087, 113.85975177169917 191.01224509421365, 147.20174125856576 151.3383858750842 M75.21736785671725 242.020919424011 C93.90851326973869 215.97520028336305, 117.6799218737985 191.10093494211253, 147.21446523899547 159.47797944703996 M75.05252711935951 240.99368642875422 C103.28881808046097 208.5834275053515, 131.08609626637218 177.0363912123635, 146.10478687184266 158.19351861374378 M81.18779708235057 239.3693264287148 C95.61530701048515 219.50793382051776, 114.68312986337375 203.60589523621798, 145.95417272752033 165.83849227975088 M80.25674048414918 240.3362746492211 C105.41983753225495 214.2438107762419, 129.5784313468844 185.35357213963613, 144.60784049598382 167.0353437809922 M87.56525808477716 239.16853303898688 C108.50852338655417 215.66363181143583, 128.52411063528322 190.0680994941433, 144.20693792478542 175.00766371231538 M86.88840760754363 238.91914649930413 C109.61147373903924 213.45665670885202, 130.95043587515056 187.5304679627976, 142.9319104317705 174.1555483885483 M90.16006932945342 240.0613812668352 C104.32637928614223 223.76299938504545, 116.18503252878298 209.75453892440214, 143.52840916220924 180.9259036858503 M93.02829373234303 238.1215331511675 C113.0785308050298 215.84494263727566, 132.01185031269253 192.64302706088927, 141.6277011629649 181.8500721626852 M95.37201108981695 237.84145021382233 C108.7774155155027 224.524206806401, 123.59576929930974 207.75827561478974, 134.28569308635275 196.13942209481422 M96.58686028432872 239.82691849153002 C112.28015742993324 222.29001371327476, 125.54065443190868 205.10823779082455, 134.2351098634718 196.43667040801768" stroke="#ced4da" strokeWidth="0.5" fill="none"></path><path d="M111.76961590143304 14.867960066989696 C120.48293365452723 20.25479876583145, 128.10399679114565 32.58016735875974, 133.90587796998557 44.599940369612725 C139.7077591488255 56.61971338046571, 143.7966483450343 72.1544557755397, 146.5809029744725 86.9865981321076 C149.36515760391072 101.8187404886755, 151.58047371148103 118.19626697887085, 150.61140574661476 133.5927945090201 C149.6423377817485 148.98932203916937, 145.46983468330927 165.60506122111576, 140.7664951852749 179.36576331300316 C136.0631556872405 193.12646540489055, 129.89074528333464 206.35134335595654, 122.3913687584085 216.15700706034454 C114.89199223348236 225.96267076473254, 104.79233870894573 233.9952255637317, 95.77023603571803 238.19974553933122 C86.74813336249032 242.40426551493073, 77.51192230875185 243.050235071845, 68.25875271904223 241.38412691394166 C59.005583129332614 239.7180187560383, 48.78220009811268 235.38515278798786, 40.2512184974603 228.20309659191105 C31.720236896807915 221.02104039583423, 23.298247647393772 210.23480793348224, 17.072863115127923 198.2917897374807 C10.847478582862074 186.34877154147918, 5.611379471112604 171.45008634042543, 2.898911303865205 156.54498741590191 C0.1864431366178052 141.6398884913784, -0.21530702841167937 124.34893360805759, 0.7980541116435234 108.86119619033968 C1.811415251698726 93.37345877262177, 4.563990602465414 77.25734148993668, 8.979078144196421 63.6185629095945 C13.39416568592743 49.97978432925233, 19.790160493454522 36.87634430792997, 27.288579362029566 27.028524708286653 C34.78699823060461 17.180705108643338, 44.74245961240542 8.970784785299799, 53.96959135564669 4.531645311734607 C63.196723098887965 0.09250583816941571, 73.39538472345247 -1.1226367317965564, 82.65136982147719 0.3936878668955046 C91.90735491950191 1.9100124655875657, 103.75644583642939 10.095949732917738, 109.50550194379505 13.629592903886973 C115.25455805116071 17.163236074856208, 114.9830128610472 18.61278240917302, 117.14570646567114 21.595546892710914 C119.30840007029508 24.57831137624881, 122.9828928977794 29.89872903065066, 122.48166357153869 31.526179805114353 M45.04030516977419 9.49064793138308 C52.584559468043565 1.8971303556790104, 62.619481960739776 -0.6896761119615226, 72.1539804859775 -0.768879582843752 C81.68847901121522 -0.8480830537259815, 93.0869282254435 3.289932390655949, 102.24729632120051 9.015427106089703 C111.40766441695752 14.740921821523457, 120.12121294217152 23.07462083356645, 127.11618906051956 33.584088709758774 C134.1111651788676 44.0935565859511, 140.58766851713213 58.09730497799728, 144.21715303128875 72.07223436324364 C147.84663754544536 86.04716374849, 148.8594689997779 102.08355073415933, 148.89309614545925 117.43366502123689 C148.9267232911406 132.78377930831445, 147.43314461868675 149.7034368914856, 144.41891590537693 164.17292008570897 C141.40468719206712 178.64240327993232, 137.3183826616947 192.9459461045852, 130.80772386560034 204.250564186577 C124.29706506950599 215.5551822685688, 114.16667834161765 225.5595063742743, 105.35496312881084 232.00062857765982 C96.54324791600402 238.44175078104536, 87.6430232268193 242.39226833803957, 77.93743258875945 242.89729740689026 C68.2318419506996 243.40232647574095, 56.08380737807285 240.4809166341007, 47.12141930045178 235.03080299076393 C38.159031222830706 229.58068934742715, 31.016548837902157 220.77496818760193, 24.163104123033037 210.19661554686962 C17.309659408163917 199.61826290613732, 10.13802886721989 185.43881734298003, 6.000751011237057 171.56068714637016 C1.8634731552542236 157.68255694976028, -0.6462776363020547 142.23785566557913, -0.6605630128639604 126.92783436721031 C-0.674848389425866 111.61781306884149, 2.472112661413631 94.57810202258256, 5.915038751865623 79.70055935615719 C9.357964842317616 64.82301668973182, 13.623979513959878 49.32135096927908, 19.99699352984799 37.66257836865806 C26.370007545736105 26.00380576803704, 40.0624419773929 14.303007788057496, 44.153122847194304 9.747923752431078 C48.24380371699571 5.19283971680466, 44.612344447364734 9.846563320420833, 44.5410787486564 10.332074154899558 C44.46981304994807 10.817584989378284, 43.440803345583696 10.562673795344852, 43.7255286549443 12.660988759303436" stroke="#000000" strokeWidth="1" fill="none"></path></g><g transform="translate(275.4183673469389 72.0678903350786) rotate(0 55.5 13)"><text x="0" y="18" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="start" style={{ whiteSpace: 'pre' }}>Dark Mode</text></g><g transform="translate(10.000000000000114 13.992327694895337) rotate(0 120.91836734693868 70.05515447691792)"><path d="M14.223238720308304 37.53931966198567 C14.223238720308304 37.53931966198567, 14.223238720308304 37.53931966198567, 14.223238720308304 37.53931966198567 M14.223238720308304 37.53931966198567 C14.223238720308304 37.53931966198567, 14.223238720308304 37.53931966198567, 14.223238720308304 37.53931966198567 M8.328725564449016 52.475891275535716 C15.353719593347929 43.23156111174791, 18.88780263718792 40.06666388124725, 27.132248343196583 29.160808807184782 M8.19525414441425 51.67697477733661 C11.475277044314755 45.84107262609744, 17.457900809209484 40.19681988105721, 28.263374875527692 27.769650962149587 M2.6357538607367275 66.63980149626785 C13.815255258737594 51.308214410268945, 24.473697701206213 39.39251676871003, 41.262378228193654 18.858568187395882 M0.49437892440938924 64.3302500393247 C18.353201661604814 46.85557630809603, 34.182257105035006 27.60778636975114, 41.586538515350355 18.3997895285141 M2.6485099976485458 70.81122869172724 C13.702883403775253 55.91268456892668, 30.06273977028937 39.59157810858957, 44.29619436412415 19.096816089063786 M2.059650627670237 69.98058046878906 C11.956785695076714 59.5559543335296, 20.61495132464937 47.76740536588499, 45.23177868015429 20.687335180178188 M3.1863022288608676 76.38006184802464 C17.548305390251628 58.49635736267214, 35.19363398777432 37.57748992956176, 53.39435921135119 15.231119100947797 M2.01512604302944 74.5343955791842 C20.576037116923306 54.9933611037487, 37.09777821727774 34.749089770827055, 53.72114699045888 17.604004408244908 M2.288421463917217 79.197599578152 C24.429582859119503 58.63026708338785, 40.32332371775635 34.98952894152362, 62.008038668503744 14.151255808044812 M3.995860240186772 80.97476888584583 C18.915602687990717 60.036038781841654, 36.417846726006886 41.762896856870356, 59.22071413004933 15.208460075054905 M5.686137775389575 86.78161298576958 C22.95030597364522 64.16179642582459, 42.12196903451878 41.083797848208995, 67.77808614512531 10.731962323346796 M4.1824030768704645 84.00398076977797 C19.714298255504186 68.79591335805323, 34.68949066948676 52.198238800023276, 67.0022539186254 12.534556945943894 M4.395877485849944 90.42721692453767 C23.29496379233644 69.86827811919389, 41.14705019076977 47.22395075177232, 75.6312751847974 12.01915683073678 M4.456476285808769 91.59188973698463 C31.398905648029668 58.88276392656296, 59.77540105408964 26.90491212284838, 74.10197593492484 11.215066232138657 M5.624518312822843 95.80832209700269 C30.024194673979608 71.00228781352617, 48.55251745166443 46.82627023032865, 85.93406684201604 5.793715042092863 M6.868768619682456 93.01488451735598 C22.21325807407087 76.06209157732755, 37.926544853241516 57.28542443701466, 85.03679289359992 3.482951047861043 M9.17774004988977 97.65444043704767 C29.249648273902373 75.07191037807166, 47.155778967035424 54.76771269280744, 90.43583881247665 5.587757603971362 M9.609786532229272 96.97018944711883 C34.47897721557837 67.60345875846113, 59.394272589895564 38.93939332110708, 91.31004977550234 4.6251538170111175 M11.67743776962272 100.84271804833287 C41.94521941759987 66.78730275685648, 71.73860367581047 36.68293503125107, 96.10442367966576 4.607138148027595 M12.789767381177462 100.10539740727717 C41.31981015201234 68.53191398346945, 67.76408921849449 36.37063216844094, 96.80956207427485 4.34258432358795 M18.18672182358418 102.77293520429582 C44.157385182803125 69.69538906609716, 72.73513327420245 36.22196046079374, 101.06933804514588 4.747730697699062 M15.626179428245763 102.83096982361228 C38.56852698160582 78.36682655207838, 59.910243241874255 53.23575502479544, 103.31334492663623 3.20390711837279 M20.522799784346148 105.25192550859677 C38.23406755290797 85.69883412670372, 54.98368276236991 66.09584479481256, 107.49525476047953 0.9327530264232635 M18.319103781162426 104.64215582712816 C55.24157329414223 64.91358997621177, 90.17689768485914 23.571281415828963, 108.92558534430523 2.4203648771468877 M23.506212561452138 109.84973757422767 C53.569812150142354 68.64488450419458, 84.41186177143133 33.629447926406485, 113.20700292722918 3.4877666576376924 M21.86297050962721 106.91265393316053 C44.43791811013061 82.71282051392272, 67.52083294666855 56.202099872752115, 113.2641422140006 1.6582784006914864 M24.64948022747415 108.80289250484883 C51.941305410397426 79.98788809052202, 74.80026485985381 52.22376340877674, 119.97062692594788 -0.23903271544291016 M25.29770447136837 111.23536952271341 C60.06481273516208 72.99954898975037, 92.92843004600968 35.44065738038324, 119.30410532643161 0.09683703456507686 M27.750279104984173 113.12525515412048 C54.437232065508965 82.30791631615767, 80.34339287889469 50.142218408372244, 125.83809127122657 0.27173267128727474 M26.86440239911414 112.97078610179082 C63.66724575477123 71.91489734216921, 99.27252583334655 29.784534101585862, 125.28500023134546 2.0141465702172283 M28.484618853525703 116.71890538503459 C54.38561026358108 88.9912690245206, 80.70949355263727 61.481483722317726, 130.67681187260877 2.29323643082018 M31.16100998756427 116.79533656072846 C54.01911421550183 90.53190143511527, 75.98887129184047 62.75473500789991, 129.80281949844192 1.8073430229836234 M34.09548829567076 119.27739555125146 C67.47672798688149 84.4541860763558, 98.61289189764017 44.80353343697209, 135.20224283165697 1.5104150865933264 M34.39346771944703 119.04216065553038 C66.70617636772315 80.61694360721131, 98.68559111243995 45.31852484748187, 134.40510951443974 3.0880596936455333 M38.29815056197234 120.88720259598577 C77.42824380733816 74.75560859202321, 117.50209989985537 32.939620980538535, 141.25893547094321 1.640258492266966 M36.843506010471664 121.74005710675502 C62.44219381206027 93.89415349929926, 86.59998044684329 66.48733844513634, 139.4345295120672 3.74021758820696 M42.28090697500244 123.42156637289781 C69.9128593047441 91.29801131518036, 100.02713176378984 54.26590253617891, 146.06977445001098 1.7879715108330885 M41.431909093366414 123.44482192040641 C62.655545187298884 96.33379651400325, 85.8875576512836 70.89589686027608, 143.84058768602404 2.7136870970036426 M44.95433316531405 125.89665132520233 C69.08019194521152 93.76980492199606, 96.19364661787992 68.24970734922788, 149.26856456244172 4.104941076174711 M45.53025113384053 124.03291006704305 C71.44147817304749 92.14954815638231, 99.47075175336785 61.54956530425899, 147.77472799004795 5.119755056262349 M47.295983926488674 126.62009438678234 C74.84841911121124 96.33495185702306, 101.2995028855963 65.12232077224226, 153.773502967779 6.126059551283788 M48.868101085631764 126.28748338289171 C91.81661752998268 77.91474630571574, 133.4770177944719 28.722021228025127, 153.02630236883874 6.005948106318783 M51.91174738907932 129.0377475008526 C82.48237431981661 91.85776199628023, 117.88236629304075 55.16765302288577, 156.81976598859478 4.427691789820614 M52.65687686444937 127.18265308851088 C89.36968084502803 83.5091645072194, 129.64316824229348 37.94467054662525, 159.61765755512465 5.451499753176989 M56.69266793363897 130.3728063606278 C84.62940244865102 97.63639435386624, 108.88327882142124 67.66950686378607, 163.5526861974611 6.738546395445439 M56.80916826100258 127.96010852945903 C84.21242946377753 97.24330214980824, 110.94412837721157 66.17874014642024, 162.95981215663394 6.115721627840969 M60.47176910374915 130.26493914999102 C100.18429565692895 82.82591906500673, 144.12157457197657 37.0545705730822, 168.62092950392145 8.05054698115022 M61.158059854029176 130.20574591696297 C92.8772651795727 93.59853750317214, 126.94304119902748 55.192616316189586, 169.00813374306637 6.786374745646427 M62.58540777996285 132.61702906215697 C88.63245919777849 103.94429853649672, 111.41659442342072 78.55435811090163, 171.20908195155727 6.276799230818014 M64.58098518423839 133.13391084658116 C86.42812843821133 106.76497009086496, 110.63152910178206 80.7864389322973, 172.85742564315962 7.475136567820172 M68.53009709283305 134.2343030403404 C89.1306380098544 105.32382338506324, 113.83018848438559 78.50201947526587, 174.5990038919311 8.098333417886309 M68.21904966733945 133.6640270388721 C109.15974903654606 86.71726624296463, 151.63181078065406 41.04230948797556, 175.86578111863247 10.701843520143441 M72.93144739392228 135.95404135797878 C103.62447202035935 103.88095126675586, 129.26477495333796 70.60836857543214, 181.13877095559596 12.093813985569838 M74.00847156505115 134.98780591961702 C102.85763596302604 100.75479857134525, 132.05974413972285 66.48516863586481, 180.18602433519422 12.061700573174718 M78.51310722681202 133.77528719895517 C105.33825958865208 106.50213786347052, 127.02599643392108 76.84322975728706, 183.21161502310486 11.869249127083336 M77.21202295611121 135.13645620004272 C99.39710987428876 109.34486697220117, 121.26865274645954 82.88871658637271, 183.2971513335541 12.792490822472487 M83.65811750104578 136.70558917626153 C113.71894452471876 97.92126302699887, 147.1485598351003 62.06180342503927, 188.89274713803923 16.697119526309848 M83.54288391021939 136.2649263954849 C115.90763421345035 98.26658506583726, 149.02670801299053 58.24554762569235, 187.2236529424498 13.851204229263544 M89.44149828919556 134.4189683935581 C110.82417514150046 109.37424131555841, 134.85720602004127 83.1011910363965, 190.16677070994265 16.6084112962413 M87.75972928510394 135.74737188694758 C126.13341862549328 94.35171149237738, 163.1000351748345 51.25938963652844, 192.31542798158117 16.443570558883565 M93.22342591383335 137.36678970450518 C122.72704692818024 100.34714536002473, 152.82265377653056 65.41469310961537, 197.30420928239658 18.805292620760767 M92.8224058134931 137.7255261804348 C118.21007790607848 108.96732194398413, 142.94684443864475 79.90060907212411, 195.18681102968588 17.874236022559373 M98.24241003450265 136.88863297209474 C125.06341845664971 104.65769921272167, 154.377718673179 70.37556280423169, 199.73497876494451 19.998437112860216 M97.04760919008663 136.65707216628226 C126.69021168882924 103.6015633240172, 156.47688349763786 69.44110966902606, 199.1525446564637 19.321586635626687 M100.64788044478499 136.87026013020977 C125.99429065561183 109.27050874798411, 150.17641941556545 80.00574481199247, 204.42491520116795 18.819700456422638 M100.70397086862229 138.41110918071672 C138.0876655670171 97.51059441476912, 174.4480412833715 56.052448102041055, 204.16680719065238 21.687924859312247 M106.37365930422382 138.0809385418423 C124.34931822020688 116.50383994092519, 148.20599429353825 88.85234164704035, 208.74924700061044 20.15944376444004 M106.4480302937998 138.04297636407395 C129.44730195857488 113.0595904921774, 153.27547374658494 87.85675597682737, 207.2591849519517 21.374292958951813 M110.17054793539118 137.83140806065256 C142.85458049615096 101.80035770503954, 179.3798039345486 60.526527285653216, 210.86912270685343 23.57921022079546 M111.91932169834745 138.88971972235794 C131.50540495409987 115.59111962857624, 151.0039565211611 93.13970939803613, 212.4737000190994 24.130649645772166 M116.54344488186854 142.8394618705236 C149.0927937300654 99.49804357823794, 186.34031855627774 59.88038321348954, 213.2929651714594 25.158467598926364 M114.23999216100293 140.87317296679547 C143.01376670032872 107.72259077965745, 172.23444651100328 72.58585482270992, 213.99266440381416 26.34011259555453 M120.62481814017094 141.56726984377906 C139.60585984971328 115.83445155032648, 159.06524331811525 95.75569876220257, 217.18935349599093 29.379027422270283 M120.45077383843757 140.27478087205395 C156.5785780374511 99.26358752576674, 191.44819116136094 57.081499691659474, 218.01206199281484 30.34087760908018 M125.95881972488937 141.91702224452473 C161.22814730675577 98.63962332869427, 200.23160239651858 53.829662278428856, 219.32414108999146 33.99062580381916 M126.85759853039858 140.86874297958292 C160.88587739603213 101.20163650265205, 197.15532178248893 60.480788124750234, 219.2411666058011 32.72929945168481 M131.3429349875863 141.30412434619814 C159.8771316596412 107.52848013165081, 185.31547586670777 76.26246549165725, 224.405160526033 36.15947134723328 M131.43574699860469 139.43270821516364 C157.0347840859707 111.68835363532315, 181.07211365438337 83.52311581437064, 222.80974685050126 35.94593975437246 M136.4848055298242 139.2963251722368 C161.31567568224108 108.81063928157067, 187.37902723976174 81.90349637442677, 227.33145965567803 35.824406825950135 M137.04917187977463 139.8244036295774 C159.3491661566087 116.10859883412625, 181.70388834815685 90.60918644716966, 226.079802238634 37.06922869292437 M141.62722920316568 139.98871749607818 C166.04841690625213 114.41480565235156, 187.1957874261803 87.15436079651882, 227.5061835514986 39.58132834362999 M142.63799768562725 138.7941343986054 C168.02260826177283 107.95539834475525, 195.71341750826846 77.8584525191266, 228.5209451279276 41.13738414834455 M147.17316010917315 138.37873784115982 C179.81932263528398 106.82441182599672, 206.653058530975 69.93929835397446, 231.08581062303818 44.03934701326483 M148.40602080288122 137.79728382302892 C168.8582287129457 116.44739097108115, 187.55205408814032 94.55085347476582, 231.52213683808185 43.94207338596874 M153.31070859940917 138.7411664809192 C171.1094601180762 118.2324063803377, 187.49021020738886 100.45501296369682, 234.6687984428688 46.724046438514875 M155.00820949325532 138.8068133286292 C184.0475023460907 104.49370590809599, 212.64269447752403 71.00320644051159, 233.64283347682496 46.36917053680091 M160.9502593334172 135.4937082783482 C176.01252043283586 117.77350443860095, 192.74531742820946 101.91203857618311, 237.57664277558138 51.69777313348597 M162.0503219514493 136.6163433057419 C190.36902189402258 103.20526336457763, 219.17493877280145 70.88237434083665, 236.45654496890415 51.456547017290646 M168.06917613376922 135.34554058710725 C185.6173705361774 114.53148402772754, 205.98791877940155 94.94677337299751, 235.38340883870052 57.142250300724555 M169.99611116019136 134.42620101037116 C190.86673026027742 108.43424450938248, 213.8547309913294 82.73090738480084, 237.64286631700026 55.414185307328154 M175.3049790730373 129.40818451085647 C189.36057573084685 114.9830833940632, 202.5488770666912 102.9961680100994, 239.01196392580766 59.544802374113615 M177.32300197720983 131.39179182352086 C195.34899275973476 111.86428875865451, 213.13477218186125 90.12550750948006, 237.64621022485096 59.24790349314016 M185.58034447379333 127.57050490325622 C199.89295815388573 108.21655743916756, 220.19433188704141 86.62532396974046, 240.91956769644628 64.52625921664514 M183.70959113523287 128.43609925119512 C199.78653403565937 110.43103033399888, 216.79184002104205 90.9963664719878, 239.423897387156 65.0247594650624 M189.69628199558093 126.60958162729845 C202.9863227409546 113.82716935330545, 212.3549710899752 102.42735914256589, 241.47439994419793 67.2536327622022 M191.19132432896032 125.79805618372963 C203.07512605413086 113.32170347146666, 214.95699101691326 98.31903116385745, 240.38518425765315 69.7232275593217 M198.02078046097205 125.09396703664339 C215.73938625255087 105.56078642889166, 231.20827220606674 88.42650170317913, 241.8290328286061 72.7774403737767 M199.4116420411204 123.37330237712837 C210.32703037806485 111.38542779614849, 220.76407348620612 98.65545276336391, 242.8272352810422 73.00802191625371 M204.76986688455764 125.00397639539689 C214.217305010011 113.59235984252295, 224.56802354841255 103.95545773740335, 243.60907670123333 74.93496232480616 M205.46553857146029 123.77197487284094 C216.5422941010578 109.74848958252394, 227.1918809234511 97.33192918073127, 245.1862687190419 77.57085396163971 M218.72196413972938 112.74836753298655 C228.1568635373513 101.8713147809301, 234.55125108235882 93.21092303774456, 241.97758981575598 90.27233760140138 M220.17641970352793 110.7728876526387 C226.7724380449291 104.94067232867977, 233.58055519988267 96.55820980914622, 240.66446636048485 88.49002820893423" stroke="#ced4da" strokeWidth="0.5" fill="none"></path><path d="M124.62409614789661 0.6031721972687194 C139.50842580594033 -0.14077902924724006, 156.2008269581355 3.408480596275259, 170.3794187451638 6.9712258970721805 C184.5580105321921 10.533971197869102, 199.14459241222835 15.495359613999778, 209.6956468700663 21.97964400205025 C220.24670132790425 28.463928390100723, 228.25726483822817 37.185888870265295, 233.68574549219147 45.87693222537502 C239.11422614615478 54.56797558048475, 242.68204913327577 65.01688898910932, 242.26653079384616 74.12590413270861 C241.85101245441655 83.2349192763079, 237.7923929144836 92.47611521222238, 231.19263545561387 100.53102308697075 C224.59287799674414 108.58593096171911, 214.28211969996258 116.53957269251786, 202.66798604062774 122.45535138119882 C191.0538523812929 128.3711300698798, 176.47016072856982 133.23052655989483, 161.50783349960489 136.02569521905653 C146.54550627063995 138.82086387821823, 128.50712541679943 139.7641096845547, 112.89402266683817 139.22636333616896 C97.28091991687691 138.6886169877832, 81.57114681996758 136.52065991756757, 67.82921699983734 132.79921712874196 C54.0872871797071 129.07777433991635, 40.86041930764289 123.7841247864193, 30.44244374605671 116.89770660321534 C20.02446818447053 110.01128842001138, 10.221349586251744 100.2182760176041, 5.321363630320249 91.48070802951824 C0.4213776743887552 82.74314004143238, -0.41061254199893216 73.42743774067337, 1.0425280104677483 64.47229867470024 C2.495668562934429 55.51715960872711, 7.0636436545526164 45.65365519072895, 14.040206945120332 37.74987363367949 C21.016770235688046 29.846092076630036, 31.056511401585162 22.692740609100603, 42.90190775387404 17.049609332403506 C54.74730410616292 11.406478055706408, 70.12264847297492 6.790422554405517, 85.1125850588536 3.8910859734969137 C100.10252164473228 0.9917493925883107, 123.8280085361164 -0.0024893662623502455, 132.8415272691461 -0.3464101530481116 C141.85504600217578 -0.6903309398338731, 139.50259300386162 0.4702661188601951, 139.19369745703176 1.8275612527823455 M176.24162461603117 8.521423790911712 C190.2532396810256 11.681160082172623, 204.3017568037534 17.78274983535, 214.32857010937846 24.763946308584337 C224.35538341500353 31.745142781818675, 232.10399763021232 41.64062008133042, 236.40250444978165 50.40860263031773 C240.70101126935097 59.17658517930504, 241.46611288014483 68.62831789569736, 240.11961102679436 77.37184160250823 C238.77310917344388 86.11536530931909, 235.34312642540633 94.97618435085487, 228.32349332967885 102.86974487118292 C221.30386023395138 110.76330539151097, 210.0898567854825 119.09528036614844, 198.00181245242948 124.73320472447654 C185.91376811937644 130.37112908280463, 171.1104429462416 134.3917082361072, 155.7952273313606 136.69729102115144 C140.4800117164796 139.00287380619568, 121.58453787434293 139.4865181090631, 106.11051876314349 138.56670143474196 C90.63649965194405 137.64688476042082, 76.36586430134736 135.25385701445998, 62.951112664163944 131.1783909752246 C49.53636102698053 127.10292493598922, 35.19959704434627 121.11818258937657, 25.622008940043003 114.1139051993297 C16.044420835739736 107.10962780928281, 9.43311018925653 98.08869821692, 5.485584038344342 89.15272663494336 C1.538057887432155 80.21675505296673, 0.395655622546774 69.54988561708886, 1.9368520345698812 60.49807570746991 C3.4780484465929886 51.44626579785096, 7.096362336749522 42.36480528788272, 14.732762510482985 34.84186717722967 C22.369162684216448 27.31892906657663, 35.34061996806664 20.8768480883755, 47.75525307697066 15.360447043551645 C60.16988618587468 9.84404599872779, 74.30007854448476 4.411356941097793, 89.2205611639071 1.7434609082865364 C104.14104378332945 -0.9244351245247202, 122.49567107604565 -1.7995061891580864, 137.27814879350476 -0.6469291533158952 C152.06062651096386 0.505647882526296, 171.7268327848922 6.832171030073444, 177.91542746866176 8.658923123339683 C184.10402215243133 10.485675216605921, 175.33631598189922 9.169963497713443, 174.4097168961222 10.313583406281538" stroke="#000000" strokeWidth="1" fill="none"></path></g><g transform="translate(33.00000000000102 71.04748217181336) rotate(0 52 13)"><text x="0" y="18" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="start" style={{ whiteSpace: 'pre' }}>Excalidraw</text></g><g transform="translate(179.84693877550956 57.527074008548) rotate(0 22.5 25.5)"><text x="22.5" y="18.5" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }}>This</text><text x="22.5" y="44" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }}>post</text></g>
</svg>
)
}
================================================
FILE: packages/francoisbest.com/content/blog/2020/mobile-device-frames-for-excalidraw/index.mdx
================================================
---
title: Mobile Device Frames For Excalidraw
description: "Building mobile UI mockups in Excalidraw is a lot of fun, but eye-balling screen ratios is not great. Here are some free resources to help you get started, and a guide on how to make your own frames."
publicationDate: '2020-06-11'
tags: [excalidraw, ui-design]
---
import { FiFileText, FiBook } from 'react-icons/fi'
import MobileMockup from './mobile-mockup'
I love using [Excalidraw](https://excalidraw.com) to build UI mockups. As I was
working with a client on a responsive version of their webapp, I realised that
I was always eye-balling the screen dimensions, and I wanted a way to make the
screen ratio closer to the final deal.
<figure className="mb-8">
<MobileMockup
role="img"
className="dark:hue-rotate-180 dark:invert"
aria-label="A UI mockup for a product landing page on an iPhone 8"
/>
<figcaption>
Mobile UI mockup made with
[Excalidraw](https://excalidraw.com/#json=4882401423523840,3Hd00VsFV2umVwol0mbwdw)
</figcaption>
</figure>
Figma has frame size presets for various devices, which is a great feature when paired
with responsive layouts and other design tools. None of that is needed for
mockups. It would bring unnecessary complexity to a tool that needs to remain simple,
but at least having the option to set a rectangle to the dimensions of a given
device would be cool.
So I set out to build my own library of device frames, which are simply rectangles
with the right aspect ratio for each device.
## TL;DR
<Note status="success" icon={FiBook} title="Library files now available">
Those files are now part of the official{' '}
<a href="https://libraries.excalidraw.com/">Excalidraw Libraries</a>{' '}
repository.
</Note>
Here are the links to the editable Excalidraw sketches:
import { DiApple, DiAndroid } from 'react-icons/di'
<a
href="https://excalidraw.com/#json=5632680284651520,5Gocwea8NCsAOk6uVupJ6w"
rel="noopener noreferrer"
>
<DiApple className="-mt-1 mr-1 inline-block" /> Apple Device Frames
</a>
<p mb={2}>Mirrors:</p>
- [Apple Device Frames (sketch)](/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidraw)
- [Apple Device Frames (library)](/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidrawlib)
<Note title="License" icon={FiFileText}>
These resources are released under the {/* prettier-ignore */}
<a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons Attribution 4.0</a>{' '}
(CC BY 4.0) license. You can use them for any purpose, but I'd appreciate you
keep the attribution if you redistribute them.
</Note>
<Note status="info" title="Updates">
<ul>
<li>2020-12-29: Added iPhone 11 Pro Max</li>
<li>
Libraries are featured in the official{' '}
<a href="https://libraries.excalidraw.com/">Excalidraw Libraries</a>{' '}
repository
</li>
</ul>
</Note>
## Making Your Own Frames
We are going to use the following tools:
- [Figma](https://figma.com) _<small>(free)</small>_
- [CleanShot X](https://cleanshot.com/) _<small>(paid, but worth every penny)</small>_
From Figma, we create a frame for a given device, say an iPhone 8, with a vivid
background colour:

Copy the frame as a PNG image:
```txt
Right click > Copy/Paste > Copy as PNG
```
Then we're going to import the frame image to CleanShot:

Now we can use the overlay feature by pinning the frame image to the screen:

import { GoLightBulb } from 'react-icons/go'
<Note title="CleanShot Feature Request" icon={GoLightBulb}>
If anyone from CleanShot reads this post, a feature to use the clipboard image
as a source for the "Pin to the Screen" menu action would save an extra step
here.
</Note>
In Excalidraw, we can now position our overlay frame image and draw a rectangle
that follows the same ratio:
<video
autoPlay
loop
muted
playsInline
style={{ marginBottom: '4rem', borderRadius: '4px' }}
>
<source
src="/img/posts/2020/mobile-device-frames-for-excalidraw/make-frame.webm"
type="video/webm; codecs=vp9,vorbis"
/>
<source
src="/img/posts/2020/mobile-device-frames-for-excalidraw/make-frame.mp4"
type="video/mp4"
/>
<div>
<em>
Damn, there was supposed to be a video here, but your browser doesn't
support it, so here's what it shows:
</em>
<ul>
<li>Change the overlay opacity to 50%</li>
<li>Move it over the Excalidraw canvas</li>
<li>
Select the Rectangle tool in Excalidraw and draw a rectangle around the
frame overlay
</li>
<li>Position the overlay so that it fits a corner of the rectangle</li>
<li>Adjust the other corner of the rectangle to match the ratio</li>
<li>Voilà!</li>
</ul>
</div>
</video>
Follow me on [Mastodon](https://mamot.fr/@Franky47) for more UI design tips and Excalidraw goodies!
================================================
FILE: packages/francoisbest.com/content/blog/2020/mobile-device-frames-for-excalidraw/mobile-mockup.tsx
================================================
export default function MobileMockup(props: React.ComponentProps<'svg'>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 817.7056931643829 701.9035983642528" {...props}>
<g transform="translate(207.02585907745834 23.382911400942362) rotate(0 186.62604815023042 334.26034348165524)"><path d="M0.8425777314197225 0.5494960573143435 C95.90076772285924 1.7998403161098233, 188.2646788969781 1.3492034617023634, 373.16482450726954 -0.32420774650510226 M-0.25077429826089387 0.2496696342845022 C89.78337200902523 -0.15511909975088134, 178.76904479700167 -0.3420783798034587, 373.10881953855716 0.19727117894943097 M372.65163021821525 0.5017439462244511 C372.8119737571517 203.69060884446503, 371.72647716463 405.39193483279354, 372.97241094846277 668.6290606864895 M373.28038067620247 -0.284898554161191 C370.44541689078716 142.72130518750274, 370.8727429382077 285.7054031399821, 373.1000907520577 668.6608600181695 M374.4600426780556 669.0677582990893 C270.27433820349097 668.0601875399302, 169.23766175140267 667.4787586747271, 0.09907941210722972 669.1744851362606 M373.71664920885553 668.121306132955 C270.24138747710856 666.4259151769387, 168.08488573345747 666.879382567142, 0.5077339391677632 668.3296884860677 M0.7246292375028134 667.8986104616132 C-0.6278974127124027 510.0664297530539, -0.9089735340426639 351.8858214627075, 0.23523388132452966 -0.22531458362936974 M0.3327521730214358 668.400830881035 C-1.9179888097558457 414.75529270771506, -1.789320751837297 160.47551495614812, 0.1057070303708315 0.1840630304068327" stroke="#000000" strokeWidth="2" fill="none"></path></g><g transform="translate(206.64616417642992 23.427490844184035) rotate(0 186.75132183409914 14.457831325301186)"><path d="M0.5491206035918651 1.2007357880618914 C82.05157532133677 -2.705418257259187, 163.49488334700115 -1.659662072793137, 373.178657442855 -0.7332498829892373 M0.24949904271731232 0.21232210697223092 C149.02530058143898 0.563498686688773, 297.74827946774764 0.7928297851904621, 373.69978005782986 0.02148067086940671 M374.75700353375896 1.2050705011934042 C375.4221889578879 6.168878424620942, 372.4071449243605 14.655885429469379, 373.7735779761456 27.185641596677215 M372.79039728279486 -0.8452697871252894 C373.70758804469494 6.228403373015476, 374.34667563228993 11.630702212172356, 373.85307630534544 28.64949040266356 M374.0493412069917 27.979238758817196 C276.88690845997934 31.236884701078996, 179.21686216809027 31.26898883523732, 0.6533514528062743 28.618222148503612 M373.10353572245634 28.79226140824503 C246.9823092364494 31.256415434062443, 121.29195564375097 31.156981512664014, -0.19086797386923213 28.530101673458375 M-1.5551912542432547 30.278629074456603 C-0.20024468680192914 19.632748912279297, 0.23612348059843097 9.617367636210393, -0.5632864590734243 1.599334066733718 M-0.2996402056887746 28.983293847764344 C0.4046277746849932 18.527033433576193, -0.255812759668263 7.624069108560402, 0.46015757601708174 0.26635192055255175" stroke="#000000" strokeWidth="2" fill="none"></path></g><g transform="translate(371.0217611171256 28.104746629913393) rotate(0 18 10.5)"><text x="18" y="15" fontFamily="Virgil" fontSize="16px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">10:47</text></g><g transform="translate(534.8533650250401 30.407267092034203) rotate(0 16.964285714285325 7.738095238095411)"><path d="M0.2585847619920969 -1.8549538794904947 C12.03849820117471 -0.47629017184954264, 24.50398331088955 -1.5340162570355473, 35.902266552139736 0.23730975948274136 M0.18908641021698713 -0.6098924158141017 C8.669678365778479 -0.3523638556944211, 17.895726063222295 -0.08277481899131134, 33.58372393104128 -0.13355328422039747 M35.44381668979673 -1.1674239727047844 C33.20781046297368 2.940183237328127, 34.23528653741384 5.623775651966835, 32.49989153118814 16.668863882688026 M33.91472462958314 0.17259460169866714 C34.29902808414786 4.458232386913694, 34.08951093338532 9.29861485852792, 33.61859861267965 15.15934693898729 M34.70344888179443 15.5857349973885 C20.220828405282138 15.37820768282628, 7.9356204196145725 14.729115783431292, 0.7546248082071543 13.970307443734782 M33.31161701532892 16.46970884080656 C21.296168904252696 16.238361632612524, 8.101310812402545 14.776381379154502, 0.43868649285286665 15.311886976916867 M-0.19171450719503857 15.987118426599853 C0.26629351701454984 9.88658636453628, -0.3155342462760954 4.624431097015309, 1.1256880323136744 1.182245907395927 M0.48403194479068856 15.777435562230025 C0.019349658373921108 9.750392097658475, 0.4024472037507755 3.821713837761783, -0.7168904226827818 -0.25568855787805367" stroke="#000000" strokeWidth="2" fill="none"></path></g><g transform="translate(537.3414602631356 32.16917185393697) rotate(0 11.463793682796677 5.014700940860507)"><path d="M0.08377918042242527 0.10481817089021206 L22.605514407019115 1.2162166889756918 L22.923380911211467 10.979642439734318 L1.0619758609682322 8.666186679255345" stroke="none" strokeWidth="0" fill="#000"></path><path d="M-1.8549538794904947 -0.47871688567101955 C7.766138534745022 -0.5600904864118336, 14.617339210088938 1.944051499958253, 23.164897125075555 -0.8206000085920095 M-0.6098924158141017 -0.11680376250296831 C7.5804314695381585 0.27940885476043753, 15.664871756562782 -0.3882004494292445, 22.794034081372416 -0.7505826028063893 M22.171033987251775 -0.529994177756694 C22.63738344790236 3.773709977214703, 22.120464226671498 6.349827812095324, 23.70050373191721 10.100320724543723 M23.039437928928447 0.3115098439160706 C22.65224627000953 2.4702115282365718, 22.70933443944028 4.3880810635760295, 22.722255751644987 10.524944711083203 M23.03713188679049 9.852984614770987 C15.185315538261818 11.187932737974808, 6.6318447555290465 10.87285704246013, -1.5058830324560404 11.5495421961957 M23.921105730208552 10.154380248028913 C18.378211464506848 9.575450284703454, 11.53613526063376 10.32663561247178, -0.16430349927395582 10.935188428599515 M0.33110872828423843 10.123986179611055 C0.22655820141946567 7.517991908162918, 0.7336695148393516 6.301030811277698, 0.7661587873666502 0.8343263175619846 M0.19522298057967624 10.011889378223186 C0.12573928160325118 7.551857784421819, 0.12678451809869493 4.148219698969134, -0.16569990576568944 -0.05215054309907036" stroke="transparent" strokeWidth="2" fill="none"></path></g><g><g transform="translate(222.8995768269806 71.66927331797092) rotate(0 13.722324038689749 -0.4984451485316299)"><path d="M1.1842170741409064 0.1423858556896449 C5.753571055196759 0.3441358122974635, 22.627331057007297 0.4761048469692468, 27.09785576865397 0.4901911530643701 M0.34679230872541655 -0.8284655154123903 C4.742891960815129 -0.9264290833845735, 21.372341591942 -1.7903633201494813, 25.891321302320286 -1.3750332403555512" stroke="#000000" strokeWidth="1" fill="none"></path></g></g><g><g transform="translate(224.39639066632571 80.59038989215128) rotate(0 13.88717849730665 0.21885405840350813)"><path d="M0.817779854312539 -0.14865247942507276 C5.301019452624556 0.025728049501776568, 23.46550032254575 0.14472165368497367, 27.986381446126323 0.11316842101514335 M-0.21202445151284333 -1.2722989764623345 C4.05275076689252 -0.8020009216479957, 22.65054972188814 1.3642773100920023, 27.246322960465623 1.710007093269378" stroke="#000000" strokeWidth="1" fill="none"></path></g></g><g><g transform="translate(224.04179698327698 89.47960356680892) rotate(0 14.302358547590302 0.551006424247646)"><path d="M-0.7175245609134435 1.171920147165656 C3.693046198897597 1.148797395452857, 22.10761824008344 -0.1812808845192194, 26.947639607194287 -0.1941386673599481 M1.106636315267533 0.7415742790885269 C5.870591554958907 0.9106798309274017, 24.849779546133117 1.4771234393306076, 29.322241656094267 1.2413637834973632" stroke="#000000" strokeWidth="1" fill="none"></path></g></g><g transform="translate(412.2831133974423 71.75780464987258) rotate(0 34.5 10)"><text x="34.5" y="14" fontFamily="Virgil" fontSize="16px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Features</text></g><g transform="translate(509.4693397520359 72.82182631116837) rotate(0 24.5 10)"><text x="24.5" y="14" fontFamily="Virgil" fontSize="16px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Pricing</text></g><g transform="translate(638.7056931643829 10) rotate(0 84.5 12.5)"><text x="84.5" y="18" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Critical Nav links</text></g><g><g transform="translate(712.2114741057676 42.82554654926412) rotate(0 -61.85451598237614 19.150575094008616)"><path d="M-1.0143752183765173 -0.10616625286638737 C-5.3744391421875966 4.081419314291179, -6.980498933548915 17.571636301575637, -27.260545854882366 23.990550083867312 C-47.54059277621582 30.409463866158987, -106.71788908327028 35.62858065824848, -122.69465674637722 38.40731644088366" stroke="#000000" strokeWidth="1.5" fill="none" strokeDasharray="12 8"></path></g><g transform="translate(712.2114741057676 42.82554654926412) rotate(0 -61.85451598237614 19.150575094008616)"><path d="M-96.25524212863229 23.314467365515675 C-100.41142892666622 26.6248858354058, -109.20108796834455 28.797386441083606, -120.83769588909017 39.76485854404011" stroke="#000000" strokeWidth="1.5" fill="none"></path></g><g transform="translate(712.2114741057676 42.82554654926412) rotate(0 -61.85451598237614 19.150575094008616)"><path d="M-93.58555233160155 43.66127937067927 C-98.10544768307216 42.71048665740926, -107.45125598890417 40.644343306350784, -120.83769588909017 39.76485854404011" stroke="#000000" strokeWidth="1.5" fill="none"></path></g></g><g transform="translate(10 12.528897849461146) rotate(0 77 12.5)"><text x="77" y="18" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Non-critical Nav</text></g><g><g transform="translate(76.26653362957677 37.41610777375388) rotate(0 57.586277732488725 22.545488607161246)"><path d="M-1.0075390074402093 1.0845718536525963 C5.629669215226931 7.676314918201023, 21.217755349814464 32.75527872052678, 40.74902759645738 39.792868788172925 C60.280299843100295 46.83045885581907, 103.44681341743947 42.8189617567331, 116.1800944724173 43.310112259529454" stroke="#000000" strokeWidth="1.5" fill="none" strokeDasharray="12 8"></path></g><g transform="translate(76.26653362957677 37.41610777375388) rotate(0 57.586277732488725 22.545488607161246)"><path d="M90.00147746552851 53.72940074368441 C97.72984093770097 49.0350996271499, 110.56835714521709 46.57398607783381, 116.51593863713151 42.84378717950724" stroke="#000000" strokeWidth="1.5" fill="none"></path></g><g transform="translate(76.26653362957677 37.41610777375388) rotate(0 57.586277732488725 22.545488607161246)"><path d="M89.60302292293657 33.2120608487729 C97.52310960890391 36.259182791253586, 110.51190222530687 41.536146813499755, 116.51593863713151 42.84378717950724" stroke="#000000" strokeWidth="1.5" fill="none"></path></g></g><g transform="translate(271.0134211250315 157.66937606580302) rotate(0 126.00000000000045 45)"><text x="126" y="32" fontFamily="Virgil" fontSize="36px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Lorem Ipsum,</text><text x="126" y="77" fontFamily="Virgil" fontSize="36px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">dolor sit amet</text></g><g><g transform="translate(97.76415649546561 188.06693169555672) rotate(0 73.29352606510702 10.847377893748046)"><path d="M-0.6904414270073175 0.5018769297748804 C8.678813580671248 3.7075761062436148, 30.425101959533574 17.190287559931363, 55.086424456905114 20.17830041313639 C79.74774695427665 23.16631326634142, 131.9851811282017 18.82417410706719, 147.2774935572219 18.42995404900505" stroke="#000000" strokeWidth="1.5" fill="none" strokeDasharray="12 8"></path></g><g transform="translate(97.76415649546561 188.06693169555672) rotate(0 73.29352606510702 10.847377893748046)"><path d="M120.47942493758826 30.94157727151745 C125.82451342753288 29.708343285789898, 132.76659731253127 26.57547227079466, 148.08436587106408 18.963415524749895" stroke="#000000" strokeWidth="1.5" fill="none"></path></g><g transform="translate(97.76415649546561 188.06693169555672) rotate(0 73.29352606510702 10.847377893748046)"><path d="M119.43938503058078 10.446740863834872 C125.15986995986853 13.43953899431133, 132.316106244306 14.52671604020728, 148.08436587106408 18.963415524749895" stroke="#000000" strokeWidth="1.5" fill="none"></path></g></g><g transform="translate(47.858928358371486 153.44600699659668) rotate(0 33 12.5)"><text x="33" y="18" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Tagline</text></g><g transform="translate(224.45032222498412 316.32074274667514) rotate(0 169.22325601855619 150.48922936893206)"><path d="M0 0 C0 0, 0 0, 0 0 M0 0 C0 0, 0 0, 0 0 M-1.0467401414783546 7.186768985589139 C1.8094388413609392 4.697866166607635, 3.2215414676670244 1.6861828965706156, 4.799014333220667 0.5602333340642266 M-0.5412153120757888 6.175809076850407 C1.2648906495107513 4.611508528372413, 2.360874654491064 3.886376415566583, 4.623208350645847 0.6296484649029785 M0.13082601661790472 12.08282675635773 C3.1728493465474603 9.641906753829254, 5.832942371212358 6.11690970086263, 9.435684880566729 -0.7565192659303783 M0.20626616786230678 12.054367474883376 C3.7159647409199796 7.163415282072913, 8.83389175592771 2.1044330519412027, 10.275203672748935 0.45509816138891 M-0.43645598837642474 19.993301962409788 C4.76132422671718 15.154880973623989, 6.99848846108248 10.86632549715953, 17.502594323366363 -1.2314514455455372 M-0.0845427066600184 19.199323695060485 C4.701518030143545 14.096670844981535, 7.708724446270381 9.731292597122712, 15.625166247136374 -0.562119143370075 M0.2628957649878476 24.201828915341853 C8.898293834926562 16.563473809289796, 15.252636134773816 9.592512973175548, 22.560977772155667 -1.2447384787559983 M1.2103442356175993 24.48695468408531 C7.484168578866855 14.683014349415549, 15.55478553727778 5.961448195397917, 21.096506797985995 -0.723132622970688 M0.6608733190735236 30.1564804723004 C7.190713140086309 19.737776403688052, 16.821316102146312 11.574030507616545, 26.06707149432263 1.5446786819037737 M0.7582402565096871 30.15591745334511 C6.661495351369244 22.8625156910213, 11.72973043716874 16.12830392153856, 27.072779034871967 0.05336853899180305 M-1.910133730722638 36.255318049156415 C8.800882830030428 25.29872988260381, 14.977269941443694 15.366763226010217, 29.89408973925451 -1.6983637529180164 M-1.1800898044463715 36.20568333082025 C11.188171743652216 22.31068941234164, 23.27296337876413 8.822720310129675, 31.512799013822175 -0.9829710972250219 M0.8764048359961265 43.05851243678086 C13.262985721547667 30.15947601963975, 25.321885158817253 12.896728537788789, 38.847651265376555 -0.19058187668207083 M-0.23157642021318736 41.695293956991065 C12.30816603216311 28.44233473766721, 26.241851468800316 14.157640098864238, 36.082201362402486 0.7771516620275101 M-1.5645172223449535 48.489972541713044 C14.40112742530413 33.56741532948913, 31.29232357223203 16.87704517910837, 43.97675157207601 1.3695792712598092 M-0.4102724632249064 49.03874002550624 C10.679275579692824 35.19342685172401, 23.233145985919595 23.6275716247501, 41.870661511640776 0.29627200200697246 M-0.07452940635861438 53.49187832460116 C14.744850546088333 38.440908721542115, 24.182385876368407 23.395680081107688, 49.387790667778134 1.777122347446923 M0.9438099975463601 53.75324422162335 C16.990666013210003 35.21550000468804, 33.655020989704 16.354477347580527, 47.61217346410292 0.9590837413620399 M1.0621432923803837 60.99985744198452 C14.394712596744895 44.798070278394654, 26.918860819768813 29.093571403243832, 51.55554530187017 0.22032106252747852 M-0.615022535333523 60.528187863991064 C17.48454409600552 40.75368191080176, 34.593733629744044 20.478524187971125, 53.4022720258168 -0.4339873096497051 M-1.226406405731204 65.59751818473408 C18.718515702766805 45.99866807643288, 42.488721352455386 20.670806352175646, 58.13719673277737 -0.7441031769592321 M0.42301821517282434 66.97339384308974 C17.30592235786224 46.04164638861534, 35.20201202327535 25.095342543974596, 58.91831057434828 0.40214329262040494 M1.2945548447033772 72.71807821872083 C20.79496598783293 45.03857659007528, 44.408574866347884 20.655088673566404, 63.28330295988698 1.7088420896349916 M1.0237752136027822 73.91450536189971 C14.360579427220165 58.57896523723478, 26.63008781596982 43.20852836459743, 63.136683290725244 0.23079198342994545 M0.26072392710936754 79.84599332548407 C13.429547093842217 63.188205247684145, 31.46895726274802 42.79632070750179, 70.45182649162426 1.6475970188494458 M0.10971902502221553 78.68836467656968 C16.84896369870364 59.389780900720254, 32.8744873814167 40.4157637434225, 68.55310292751356 0.36243383142758745 M-0.636325534125973 84.171490340672 C30.076888267609714 51.97107568178454, 58.80269519807564 20.463970910553968, 75.71910657737645 1.283872821332352 M0.6211110179590094 85.89129603485719 C28.08833635410014 53.03232303092674, 56.61394399732278 21.31575654024526, 74.89982304074111 -0.3517241031290723 M-0.8351154055774046 91.5657437958239 C19.57207662293325 66.28742844186552, 41.94995878511642 43.97985975599493, 78.59388697556881 1.947828403622978 M0.7362811324790286 90.44803231791094 C25.374093319132683 62.59580235031064, 51.62273196964635 30.870116886145922, 79.36591575797377 0.5491838734086087 M1.750650158151828 96.23540885681078 C30.655125016346958 62.19927281160465, 60.958392249372075 26.53167171242724, 85.32828818558858 0.9878937263695491 M-0.06720891688034669 98.35999797871128 C23.37736123833957 71.59901998695639, 45.461282235098956 44.04793926403771, 84.3144940102394 0.12925544629823094 M-1.1735923725415205 101.92425006450358 C30.14312698493083 72.26024565036514, 56.98107110565605 40.470456168534845, 91.731835215861 -1.254053553888653 M-0.5119927280817365 104.00068778273854 C25.785446213879332 75.1907277467055, 49.468216566759715 45.884604220075616, 89.50298930010388 0.12474693418845106 M1.8110570107677901 109.11477007544161 C25.537330673076262 81.08598064342611, 53.33650434185429 48.91722708809147, 94.48242981577337 -0.5428647265034101 M-0.020326298559275813 110.4110455486414 C33.750397735696 69.87476389375288, 68.38206404751642 30.007724928838257, 95.80037393359513 -0.062353352663137684 M0.49187505746701987 116.6827426002731 C30.863953643105596 79.3975079406247, 60.7668108282041 44.596505448273206, 101.94037824375087 -1.7749433849354332 M0.34707517771492036 115.04774708667746 C33.82515938423164 76.11082213292418, 68.52505091125892 35.20856604262062, 101.40743878827894 0.7046015293921286 M1.8385430259814086 120.87238904553732 C41.818545062532884 73.00569452407302, 83.856676119607 26.753032601321465, 105.4593634991808 -2.0784220685365185 M0.11131507830671694 121.85212684049537 C29.736421210578595 89.16582098469635, 58.01144841398834 55.62732811610897, 105.57401554518324 0.2904905156118289 M0.6580598867077114 128.95091145210523 C22.890646355145925 101.64957101624177, 49.24540385734028 71.85172296231252, 110.20047309048839 0.16201437923262318 M0.436478555253494 128.99846082557548 C36.91963786805303 84.61428035149157, 76.28078284313682 40.45855143716027, 110.8192544568739 -0.6426488253553728 M1.6890652350426905 134.41012123584784 C44.809462563732 80.71377261559338, 92.30275360650026 29.12912010015367, 117.78711223807039 -1.04033825075701 M-0.3697945519073613 133.18934536989815 C31.662460904238394 96.75858637358988, 62.23690166962078 60.99998161436538, 115.9927875503883 1.1126626469708967 M-0.3038377955884357 142.16806792830445 C45.28697241977601 85.84730930197202, 94.0401263239485 35.00207834674582, 120.39932678732356 -0.07257479263446953 M-0.35573680311000544 140.95107585461207 C25.643980024615665 110.23975904676084, 51.51694398969695 80.83814430178467, 121.7753015402638 -0.395275614429039 M0.12483066920161434 144.22357211512323 C26.59768243764108 116.33284956263772, 54.29644971119341 86.12456419426019, 128.86812612167313 1.479448035139562 M0.13571004517346807 145.38192965247478 C44.857512917612816 95.89811600701357, 89.61555142821143 43.64240523485023, 127.17911074156626 1.0180348965935124 M-0.07330828178492711 152.60722653064244 C39.563393823327125 105.39136864137603, 83.46906354691487 56.31561206781403, 133.24953785884324 -1.3633961673501303 M-0.2779953292358238 153.1359670284066 C31.77955268212509 115.15969859146136, 64.69746767106503 76.30725923959768, 131.70635150266972 0.3724369804782723 M0.11208199863441537 159.11461974027281 C43.80741842981667 108.40189649309252, 89.12773945486649 53.960079812876224, 136.2170566985314 -0.6294975433108577 M-0.14427747776310457 159.50339455558824 C54.2646127017453 98.32088626119399, 105.97876137485953 37.24558307407548, 138.02003941653328 0.7757151262358413 M-1.556350400212642 164.47210170027014 C41.4956840408137 116.87447925641169, 84.52361629229904 68.49488615230706, 144.49636911372602 0.33196077612709146 M-0.21018698699160737 165.20747409284002 C42.38154088060725 115.99243530832806, 83.17230532458339 66.45141153119226, 143.85131738967237 -0.6762470175030468 M1.4208883882717334 169.59176531710648 C54.55180088470929 106.1705408057482, 110.03772380805023 44.71196440066539, 149.06394105520477 -0.0750304648688695 M-0.5868734490179572 171.1396511064516 C55.08807263531639 106.6555180574411, 109.89847064384345 44.777271205558016, 148.1194721332131 -0.30257992360894326 M-0.5530702299150623 176.4421615279255 C30.089604807251835 138.2875047880621, 63.90524508679343 103.13707832587744, 154.89275056314463 -0.18227224145983456 M0.6813249108045336 175.84075680354033 C54.57653768888127 114.77835290366163, 108.40997349955327 53.9106477950738, 153.7427784500248 -0.9226374491246555 M-0.4072742600502241 181.26541076107426 C50.61458435971062 125.63352531456455, 98.74648566981055 67.73870462900332, 159.95407901644816 -0.34282198760334803 M0.3059009113167122 183.17474941445838 C54.342283881940524 121.35749968440653, 107.79751048128539 57.984034182344224, 159.34962201217095 0.28219494714180837 M0.9769099328660464 188.71201010992223 C44.90896774027978 136.87972988056563, 94.59396451031694 81.73777773194313, 164.11796493563298 -1.2784869583283016 M-0.1638346133158729 189.5357909624896 C52.318817499595575 128.2514601911068, 104.15011212947844 69.35463653236317, 164.2194389536412 0.6508987157854388 M0.053709381549388285 194.72890180913905 C69.08444741127788 119.64434388276356, 134.67339674376817 41.12065552734496, 168.8343547835799 0.8862241270111113 M-0.17235108530891485 194.58587249198652 C53.02904794788068 135.112333432632, 103.37210685476511 75.74173775914966, 169.9962242906382 0.7177265375847902 M-0.04162757631211018 201.3286047642953 C70.10829977130136 124.4584187466144, 138.06949610073985 48.32709639616287, 175.77498207103756 -0.26529774598247635 M-0.09931615500740369 200.913171363782 C51.894613713223876 139.6794868472521, 103.3497044830427 79.20545938756608, 175.63767410016126 -0.2454127151206851 M-1.0671498840485842 205.50742112326367 C48.00578081302807 152.71056299492096, 96.26227039388883 97.5778739883907, 180.26899523291837 -0.1186061575001005 M-0.27154806494465755 207.16303268891386 C52.65353240129256 148.0836891745206, 103.7868805639909 88.31699744575403, 180.7307382683463 0.928758584316682 M-0.5267211235807963 213.8045407514122 C54.87043217953401 148.45636957124955, 110.84229469588985 85.38193999743855, 186.0633144516036 -1.2087189458635326 M0.4664770814161331 213.4150333581873 C53.782447428178294 149.28683445577715, 110.3463739317174 84.63551333005468, 185.47965354996737 -0.9019386284754013 M0.38308387330682314 219.16755580055005 C43.39621693905169 167.23098465504705, 87.05969180166794 117.46040596184305, 192.14927853259292 1.3463569273839755 M-0.39685315785659436 219.05522691122158 C52.29664758722439 161.36455473506334, 104.01017171228614 101.39918448332205, 190.48803079249882 0.06691724620073211 M-0.23043685898881122 224.02290666429056 C44.867803645002866 172.19472634822637, 90.74041024145936 118.53336353402132, 195.28925728196975 -0.5833402446413786 M0.12466095105730496 225.66343940036867 C59.38487875828602 157.54852295307867, 118.68102980361734 89.51090720492468, 196.92976279331822 -0.8281587422718591 M-1.0454003460329382 230.83573304478446 C77.11647783164167 143.13832997685236, 153.74473318605553 55.64682216532651, 201.12927873100483 1.1052845677502783 M0.46278485960370075 231.88605673099278 C57.91623723413236 164.08318836120267, 115.71643215141583 97.69081628780492, 201.81983766451597 0.2656424836391057 M-0.4307994950849977 236.26250225844782 C72.50008648696296 155.69441462278317, 142.3987256438871 73.71247374124715, 205.79294200427464 -1.3007341664859358 M-0.35572770973136325 237.8473650637391 C40.45154352993103 190.69761744916255, 81.46802180609441 143.82604349859125, 207.27735810172263 -0.7031822557532232 M1.328696579227229 243.6557492714941 C61.31574995936746 171.49848428946814, 123.47031744084617 100.69820498126485, 212.62141959398738 0.1474540964023947 M0.2052164015291897 243.8553652094408 C66.3761128152006 170.5111434275597, 131.8709719196211 94.74355981731037, 212.60091204905981 -0.15262470244777282 M-0.21893258026579165 251.2613792714433 C43.72082450704078 199.23992327601258, 85.56821064824108 150.1086218912527, 217.47135402757735 0.6782135757723693 M0.2604556029229131 250.17225237341142 C57.44446512144728 180.29749264818048, 117.4546633867067 112.16940063843363, 217.80450489343357 -0.1251134779389953 M1.0116152677884682 255.3861258049466 C72.47561645435378 173.99261375585493, 142.46066454502855 91.05230109705987, 221.83685649492924 -0.26621764207958326 M-0.19751601135261704 256.41736478560387 C77.11839115771723 166.12927739172133, 154.30023288555532 76.593083377668, 222.87683500201416 0.018219454321946466 M-0.9430168525424265 262.6206971991695 C71.73846166182358 182.40105243374637, 142.6338584020515 101.73884621210317, 229.49607629469088 0.6647675111219018 M0.169768145476468 261.94571416923645 C66.39479123960847 182.51649701520972, 135.09108411431146 105.1611377224315, 228.49692475822815 0.23130851829627053 M-0.5239601241480053 268.99484823190954 C70.1313637787402 190.58349742639652, 138.32315053251992 111.81202758115329, 232.34645514471907 0.0350284593844368 M0.281501272935862 268.53318686726993 C85.91890625361074 166.1370894883697, 173.60461272771082 66.12961243037368, 232.56475971051282 -0.15215611091988618 M-0.5470619994813789 273.60892550149225 C48.83691640234464 215.94356510363912, 96.64789712320008 161.31725158695204, 238.5656762777183 1.4620523056036847 M-0.28650452139153004 273.9578095765888 C60.15208822069801 204.92412026740095, 119.03291484692726 136.02077003388013, 238.24158292210456 0.2065522747343879 M0.3594795116950703 281.3306953881103 C56.23055805153536 215.26334728816514, 111.85643344922133 152.2332948448403, 243.4251596043723 -0.9082303204664227 M0.18246564500446555 280.9725971659279 C96.71860633023142 171.58883751706486, 193.24198872865537 59.56784899716201, 243.32673470726454 -0.24890740977215856 M-0.4016892138185748 286.7305848259414 C89.41010581604003 183.77736537481536, 177.51263631599699 83.28054637429334, 247.92317793916504 0.642764499793063 M0.22605238462578936 286.2103009338466 C99.30308144063272 173.0801530550976, 198.41606619140538 59.713296643841744, 249.34772613081108 0.20828344508175917 M0.8503916783550359 292.0913684251787 C54.228197602662995 228.68966568956523, 107.83196944667932 165.96828439937156, 254.38888800297616 -0.8581576155940325 M0.3126989042286148 292.51730616869673 C91.06875593871911 186.64940575904293, 181.79046007446198 81.12269222332452, 254.07297700436692 -0.5388338829723937 M0.580034470911974 298.6485378467573 C63.61796014839213 224.7012462941931, 127.47976363946364 150.361210539562, 259.0831380657439 1.428357487566106 M0.3986209607260208 298.09502766060893 C52.741383449557496 237.2640121972483, 105.60058882150766 176.8290108597762, 259.2840677808651 -0.016019899221610012 M1.5699808139487788 301.67410532088945 C86.66107147711219 201.69235451223204, 172.52160717202963 104.08309208785684, 265.74415343198007 0.13890473011276447 M2.296916519910346 302.716975791698 C73.26646103468099 221.20355555318122, 144.60073689676463 139.80386891392448, 265.4640556015504 -0.14707754150517857 M6.4954244371682055 303.7846569855687 C89.89733183854338 209.52207890410548, 173.73114150236165 113.45811227019455, 271.0575295203973 1.267698355570872 M7.24310026067986 302.62751451662564 C66.49211944241998 235.06842234669816, 126.87161654541127 165.26721249985872, 269.95441091040465 0.03276980078699723 M12.21575556229077 301.73634543402676 C94.73544634145566 207.3465350310526, 176.53776476273882 113.16296610115481, 275.6944270236082 -0.9127780918329463 M13.135080942323459 303.0190993919275 C83.06188802708573 220.74229180750612, 154.2254047746947 138.36343605694307, 275.78280906306264 0.02528841239864346 M16.83325442860102 303.9362627356934 C69.02387429455543 241.2753465114702, 122.77739500281909 180.74977625100794, 281.3288724433975 0.922119794831244 M17.30587064260225 302.7658039891819 C75.51415882665387 233.76852669000672, 135.53234398515335 165.4823002538931, 280.4648769060355 0.5257341961581328 M23.295606753845625 302.3408222766074 C103.44964603674892 211.91707709745285, 183.57020259647655 119.72985751492637, 285.7589253600117 0.15331093910225874 M23.183501573989474 301.97596704032594 C116.79420719593837 194.23372283109853, 209.97764989972902 88.49959853955995, 286.0400020205465 0.2328364152527178 M28.533268702309194 302.7508558011989 C107.59893488126656 212.99436542066505, 183.97487605876887 122.55453775437144, 291.25224820023556 0.8636138229099755 M28.528006451746293 302.96299887943826 C92.2703158615864 226.99303392197768, 157.85643550175243 151.87460750827918, 291.2629434906068 -0.35565986498928603 M33.26282591673837 303.4850531705065 C115.11526421858471 208.79243272269258, 193.72740893460852 116.55031511736385, 297.4010492693156 -0.09913055520670515 M33.76944572584592 301.9655706009559 C98.64565058225772 228.91279159007794, 163.53472474109745 153.72721120203008, 297.1171231349396 0.12150898111945885 M37.903724886417784 303.07717140396016 C95.19081004519079 237.69051007165461, 152.21986525044835 173.0203435828774, 302.68828611903655 0.22462236168206645 M39.284043149049545 302.5472589014573 C127.94587246536604 199.76110203900566, 215.9560759339132 99.08104574391852, 302.3959194750561 -0.22459321859275982 M45.60226557909403 301.5473624375989 C128.49664280535654 206.02459735808563, 213.480388891727 108.72756851437218, 308.6415525796449 -0.2513899528848096 M44.69305780883032 302.7971576378798 C133.66774193877228 199.07384539520615, 223.14371903005977 96.95088650826037, 308.0860645671794 -0.6892332759386046 M49.10917760905177 303.5798045658524 C127.23939634633352 214.09977451114437, 203.8627216309072 124.97378007151865, 312.36012692792536 0.1678663330225167 M49.528602895449474 303.08005066930156 C111.53431908474087 231.70933528117155, 172.1368339053264 162.63420775027348, 312.94893797032074 0.17206179941021177 M54.14171206903759 301.71571979975965 C110.64409327564023 240.41807562896645, 167.29108667051278 176.1326584304683, 317.44822531912456 0.3207883679429453 M54.79230653388015 302.2761786151601 C146.31611187337947 198.05653956430956, 237.72396469061331 92.3222695783629, 317.90396357820333 0.21832435814102702 M59.4818512983361 302.12889330875055 C142.13466587815847 209.04871159347522, 223.41982953322264 117.01506045871434, 324.28226115502713 0.4966624588063262 M60.23144735188354 303.27162289703364 C129.98383509045098 219.27620499687836, 201.2962681827351 137.81125618760447, 322.7397979873383 -0.22152264691787127 M66.779667914694 303.29911317886376 C126.87304768632626 233.46793153519354, 186.24883749246473 164.4644832746188, 329.6451545158796 0.31730862915155966 M65.89761531193288 302.41870609685935 C129.84482258648228 227.31535042758952, 195.46190253950363 150.97401889156657, 329.3678329289489 -0.4140573025791459 M69.69068978239586 303.7093794545294 C165.28796432841662 192.33706651339426, 261.81871219400267 82.46723811668188, 332.8121131284667 0.7848176609085237 M70.55274167928933 302.9356367987158 C144.5634676604878 216.60069884394693, 220.16225767148146 128.91539102433075, 334.0138060370463 0.05460610648551928 M77.24038413658658 303.1196742531126 C153.5104037788913 211.87090837455278, 231.94346778803148 122.3572294269037, 339.0490436660689 0.6588614936848097 M76.61800450138233 302.1065833087612 C168.88433466582723 194.51859914894692, 263.4237984588006 85.73446575189912, 338.85441019800123 -0.03315559938680551 M80.32346055832015 303.49444779296385 C180.456057377786 190.20008409410104, 278.42889756757666 77.10036904703475, 338.2035246777036 6.018827600426059 M81.80786876204711 302.41823629713866 C158.35369643876254 211.79510215638115, 237.15595628529326 121.40954932827181, 338.18445948301843 6.680874149687203 M85.84893970178057 301.85248660961184 C171.1816417426753 204.4782693499972, 257.093596868218 108.3177549752579, 338.79319606403834 12.408090469953645 M86.78400715311241 302.92555784952987 C147.01334655643072 232.90197755456919, 206.6690486848417 164.94489460455065, 339.1907151862915 12.580296753183712 M93.23983975054364 303.7635617099765 C151.5986619300909 237.13102569830568, 207.88778093147013 169.47565314022378, 338.2794015408793 19.563844548154584 M92.35320834195262 302.0222976132585 C152.6471957578978 234.17187854968327, 212.48087600931072 165.25044601940172, 338.6028748552138 18.404238388634326 M96.61881599350092 302.3696391409589 C178.2876454597256 211.37646015414825, 258.6112975072017 118.87549387418348, 338.7282173256443 23.908016846681807 M97.17926497487522 303.369279860059 C192.69852646378865 193.868460204298, 287.9182218785945 84.80338684465265, 338.6472906157091 25.054737562618236 M103.00507670526251 303.67566504352203 C150.27290417420178 245.11057452405765, 201.76672046710564 187.4668725503963, 339.28242959355237 30.221854523695306 M102.66542243874272 302.73253386653425 C156.05290881137412 239.89503613127113, 209.27985529363082 179.82199988000224, 339.2567610475897 31.149460361050842 M107.55806951409973 303.9409076638051 C164.54083537456214 236.03777461170884, 222.9221772276811 171.21000886912717, 339.4763957135432 38.51432288746742 M107.36466141914607 302.5508505558488 C194.70026249113468 205.07718630238492, 281.4814370227708 105.54396807751372, 339.1223149627153 36.859571675203775 M112.36889388522098 302.65391211601246 C187.72577360234695 217.77792337346852, 263.31898959158974 132.6689254358072, 339.7708433250295 43.9601820478978 M113.83873702065476 303.0312642399004 C172.25177587547367 234.7270166585083, 230.2065188407808 166.66338390949548, 339.6193596526022 42.67110471315254 M119.03262551458204 301.80760504229755 C190.9244890069392 218.9940293137231, 262.79815899057087 135.11966874246534, 338.38922090228726 48.73578254694443 M117.80454471452057 302.8867868750482 C182.05233993462969 231.98061286079104, 244.52640793303632 160.4501975094989, 338.06388655402964 49.66533700166651 M124.52965257816317 301.81691162486015 C177.25236259570136 243.5244553874766, 228.7936665431746 184.59433191117554, 338.67307844961726 55.13426601661174 M124.26286004342603 303.1599561287917 C198.8324184979979 214.37844524880032, 274.31299469017154 127.8930538750826, 338.59172185020736 55.456847903655294 M129.2946622187951 302.74879700897833 C173.558068929231 251.57696989822526, 220.00321391611362 199.2497689963049, 339.9230569881704 62.758371601982674 M128.54889428018544 302.6245723809275 C183.42564377975933 241.19049139875432, 237.47427800228783 178.28073882490432, 339.25660062947713 60.913245119325715 M135.99225619958366 302.8989865124519 C199.41012144222663 226.99766923235637, 268.1552135097814 151.18595826545277, 339.03332289863823 69.0429332849505 M134.05437723348103 301.94530497251196 C178.98183147778 254.01256364559288, 221.17705430155462 205.20188873890274, 338.7462821154293 67.77666460821317 M138.43384227698306 302.72691349875936 C179.8860479242551 255.03918421771203, 222.36383816080505 205.99829807673717, 338.32359344799903 73.66185698593175 M139.86527369062793 302.9570159501711 C217.12611590399575 211.5912009382141, 295.5052026648626 121.60454767361742, 338.99341658776376 73.25529016814718 M145.5658587541366 303.8879098336383 C220.65448517049484 213.40720689479693, 299.36777804482665 126.77885228837815, 338.2311693656641 78.77281448212402 M144.69456091575645 301.71057738629275 C208.18226855851526 230.51864412820532, 271.6456775213218 157.19159096963466, 338.68951390967624 79.27580249287655 M150.27032306439014 304.2841269215527 C225.70333408354543 217.84045885243304, 298.6393727593914 131.3353980496579, 340.43478328358 85.94378250714124 M150.0189293578355 303.34404262196375 C189.37301162590236 256.22184460446397, 230.79197944678873 210.30195894169267, 339.61032666509567 85.2048996505967 M156.44846503481713 302.8984522613874 C225.36991556082762 219.35388497017703, 297.0662378147228 135.84412757580026, 339.4488577740031 90.84377760739356 M155.42868829503888 302.03142242441663 C209.7188797169862 239.38214412570858, 264.5530291504334 177.9451916413263, 339.4697404890405 92.40450717666332 M162.09220448619791 303.7844291027312 C221.21202148658782 232.1781014036336, 280.73114381710815 164.30121637473485, 337.7900925871929 98.2077081773499 M160.3711561635128 303.5145679694353 C198.769559181822 258.82450407580535, 236.2716491541435 216.52713717021913, 338.3032003842979 98.10471663432354 M165.87538889479913 303.03005234955674 C213.65053107714292 247.78835021740394, 261.96905761111947 193.0983796669409, 338.26797392214155 103.60089659430402 M166.8111161396632 303.1644283837831 C220.66039889615453 239.44906653885906, 274.28423890880924 177.36899115818923, 339.3044275367007 103.88825457912911 M171.49572295186456 301.57234259521346 C235.0005345951924 228.49898008517295, 302.1968022081982 151.0833764455861, 337.2149547757572 110.18312856156643 M171.92478807463684 302.7122839363071 C220.11390455597115 245.05767084942642, 268.4712749039543 188.35051543572, 339.4157075555013 111.08475958900912 M175.54432654975636 301.74148568950375 C235.52569810118254 234.8378428704949, 292.5402193768332 169.07175185299113, 338.6089727710659 114.79830041390034 M176.83750833671445 302.06761834254144 C219.30459858048025 252.72103105108081, 261.2370244600119 204.66425787212378, 339.69041325458574 115.29191080107964 M181.53157664378088 301.08262798671456 C243.01292208732482 232.27129128630924, 302.5260859277174 164.27715200806017, 337.60807661903806 122.86785216555376 M182.71744758251154 302.1376586533643 C234.5860303062263 242.91957766472038, 288.3174986371323 180.93219100631242, 338.89640052367514 123.07482890030097 M187.31231058357977 301.7384204736481 C240.25981529865481 240.4674367002858, 292.73073615650253 179.06347211722877, 338.25925968488536 127.38691396824174 M188.1533065996689 302.88991311268103 C219.79528203632768 264.1249223886308, 251.6134119543751 227.5252426845487, 339.3842103439263 128.6967565329617 M193.0059121463708 304.13302039984194 C237.7330504298635 250.22861681353018, 283.77846429624896 200.56414921175212, 340.0867888467717 133.59345729160685 M193.36173983984688 302.6844185831314 C231.03110595080747 258.793718441575, 270.6302041656737 213.28964453851015, 338.74832605637295 133.6899003533798 M197.93654902677432 303.11008281791067 C246.82932960844695 247.44466481576362, 290.7127280277362 194.7578778217983, 339.305735474847 142.00713220036442 M199.19934405655843 301.5333687269274 C247.1918963626968 244.5682519801306, 296.6167645900048 188.8106926213386, 339.2841392876447 140.9844741562826 M202.291374706558 301.723584494703 C236.49393988217196 264.92625054625006, 272.7483473470053 223.8395806352824, 338.70500445119455 145.2099633795206 M203.04297896810272 302.2029047193827 C247.9564325758602 251.8551637055862, 292.2239019969231 200.44906229870736, 339.28560128989864 146.90965726467627 M206.43263700009663 301.67835193724864 C256.5521887338236 248.07810823534302, 307.8214059471245 190.06377585338072, 339.19544130485593 152.3654487542242 M209.26718476658095 302.13358790186726 C238.09657741065874 268.7543575039728, 267.9591621454066 231.94492133881562, 339.5074710623929 152.35387482022793 M213.41063905545334 303.57482251817385 C251.62491411687358 260.31770919168787, 290.9665824355907 218.2730563406743, 338.86909558623654 158.51140356435656 M213.8581074936391 303.3238021825815 C242.7908344277058 269.6857626822154, 270.3501424076909 236.3314907960587, 339.14363345596564 158.72891325782268 M220.0856799974689 302.61548899218815 C255.0900279296782 261.12321106818627, 289.80265264443113 219.68642903461256, 338.32833503491327 165.25887094697612 M218.0553718188429 303.6093985364996 C260.5576251378498 256.4253559484429, 301.25743492315144 209.32761576152734, 339.09275623808696 164.63476934450375 M226.37980220053072 300.9476094449396 C265.579838842458 254.86047388802194, 308.8179265171747 203.81840374940984, 337.1307315343115 170.92043168362218 M224.7152547357204 301.57693558637897 C252.18412056237307 272.5599407514376, 277.9492042063506 244.09009359957525, 338.44137247382287 170.86922283641982 M227.95995254806388 304.5243896579675 C267.5551303168649 255.46846649841785, 307.00079627101223 210.11735487115578, 338.3897337088183 178.01443760994292 M229.49460180933733 303.5788805883683 C263.6844777686207 261.3626864005108, 299.26219272953523 221.94669310011525, 338.543806031701 177.23953548132843 M233.5489546644557 302.0048064669202 C257.1026944112144 277.52277101582314, 279.53679182981904 251.23503436025618, 337.5886946918591 182.24018825747763 M235.56804940822158 301.72718279022524 C256.41700538405587 276.8842955916253, 277.926281060507 251.6683707464477, 338.24244935178746 183.63137766037826 M241.73953668313146 301.72878440333676 C274.02986945443746 265.2172030857701, 306.6518209940313 228.03438246805578, 339.2357853288305 188.13246379209733 M240.84830247243912 302.96220007309836 C272.6564557344387 265.7896473643716, 308.0285168482483 226.40935358894546, 337.9289266486195 188.7571113265497 M246.30530347847838 302.61701905286554 C266.0865333757199 280.4247516886657, 287.34296489078804 257.2671030758396, 340.9583170901293 194.26315375467294 M246.70335674551774 303.0462723097453 C265.98227212358836 279.14118383354025, 288.04302625187415 253.77347840500636, 338.04466682423447 195.45116874532306 M249.01476443392173 303.1461536970458 C270.25447752117293 279.7991408066882, 289.0792774874186 257.4363577528521, 339.59706498418086 200.44288580174378 M251.17677654252336 303.6326480063147 C281.0499068738615 268.22098515572304, 312.49391061689334 232.73988858406324, 338.5814763811984 200.965431235347 M256.9423056738487 304.24860267152576 C286.67735824630716 269.0223336413914, 317.98200301589844 232.49984411946224, 337.86118076037224 205.37555864395807 M255.70491698198379 302.25905766056417 C286.26665740121405 268.4419796781452, 315.46935268902286 234.92439605575478, 340.14270262553896 206.71751210687916 M263.28221783168465 302.28772800784475 C288.0598389556906 270.34935749074117, 314.1810865772223 236.86218895162227, 339.1422206771289 213.63351868785512 M262.337201753965 303.4166404036424 C291.1625583121217 267.8277364504853, 321.61352905224936 234.99992594009228, 338.1635114348222 214.35968330356818 M268.6596682818167 303.95766279165093 C291.815628891457 276.54609681453155, 316.32202795104877 246.37418327646088, 338.1690587048473 221.31209297430587 M266.4533453519948 302.543517358307 C284.0459604317011 283.26112584795845, 300.6626179937855 264.6085165645327, 337.97205046108536 219.5271583408944 M273.94472890767014 303.2584878044835 C293.71042597803995 276.23747630107965, 313.84989612294885 253.3641276330273, 339.10220943892443 227.23121791918126 M271.983630841139 303.0937182807064 C292.93684412004166 280.92310184168883, 311.0402164079294 257.6630185867027, 338.29002204386586 226.13347104683814 M277.56334833363235 300.39605488199084 C299.7466587490955 278.0605201868261, 322.04854893716254 253.0569221737147, 339.62815101278557 234.1370680540207 M278.1261320899257 302.5307549185985 C298.36653677011725 278.78469695028286, 318.832855265994 254.32038257001994, 339.7332561232297 231.66585430254815 M284.3113796931284 302.75716355571217 C302.94218827230003 279.7681032665282, 327.8344495863363 253.6152196503312, 339.61423830081776 236.3565889644723 M282.56699153842055 303.39654765313185 C294.63430175279996 288.3872201792513, 306.54114131222957 274.4715714439479, 338.87287344270453 237.933522980782 M286.66555286054563 303.8093844112956 C308.37130046866366 279.4755594003989, 326.26539684167244 261.89317371188145, 337.92273068644647 244.77267393207535 M288.1976424301515 303.3049988570639 C303.96222058137965 285.0926346092563, 317.37927307591485 267.57002977506653, 339.45556331735645 244.13660058768824 M293.59750221930926 304.0762357302806 C307.0791760555293 289.6800185462092, 319.6632652840583 272.36655678426087, 337.06570526506823 250.4786968727009 M294.33508658018445 302.0109195365435 C306.1283480633688 288.2120745817006, 319.8218293538079 272.8816588737831, 338.4647468367226 251.61440220124737 M299.8847388938066 302.03700684831523 C309.6549979641689 290.25150734584855, 319.7954576534963 279.72477668098475, 339.29937975708003 257.9801410772199 M299.4391166070473 301.4036026893281 C312.74465383978094 286.0532433336638, 327.2887503498256 268.94407494254585, 338.6211497778406 257.1408132119397 M302.9246785174628 303.10193479693885 C313.3521979188848 289.5593763009271, 326.6378621739513 276.7090548635649, 340.71843542090687 264.39622795217156 M304.3325937008058 302.5757499465665 C314.9764690452301 288.4967578816776, 326.95630388914833 276.15019860156127, 337.8833755182761 263.2196680634138 M308.04561346466517 302.5120276617997 C320.43244657738757 287.9207219038742, 331.569309795257 273.4847085309555, 337.91093025593614 269.11444995820426 M308.9186698549879 301.8170449310162 C319.8413158250914 290.9465502080798, 330.59618382943796 278.31793762749686, 338.5090682390571 267.83303319092744 M313.320740618839 302.314241224437 C322.8881866635877 296.0046173406626, 330.4461814540617 287.33962955380696, 338.77232677059317 272.76574725185714 M315.4010656346596 301.68307989882584 C324.3050430324041 290.9565920941532, 333.66338301187903 281.33378513283543, 339.27519069274956 274.5101336631291 M318.22307457538096 303.01601190072176 C328.2814029116732 294.07809075554525, 332.58934332663785 287.6020814973494, 337.1604051852745 282.8790406240012 M320.34523337935804 303.7436386779758 C327.1348365916692 295.3753226683492, 333.10594700904585 286.9936924934796, 338.5391874063906 282.07563612049927 M323.71977055468784 300.786885481525 C328.2237871499186 296.9002636234379, 334.05602659271835 293.33807391273825, 338.98958634537706 285.0828602405075 M325.17893011612074 301.87807540894653 C329.8210704798034 297.9865508534576, 334.46926899638913 292.28372570094143, 339.73955759451786 286.94502751601823 M330.3212050120407 302.7689308003481 C332.7114917450221 301.4138288640576, 335.0448282345552 297.60528853804743, 338.4794983513892 292.6054407415285 M329.8833280643043 302.71078661814846 C333.385500798135 299.9367235268374, 335.43844830938446 296.3657775028906, 338.2007610860054 292.67890339892614 M336.15071861064155 302.9791053183568 C336.8962546055425 301.4183982378072, 337.8741945322226 299.89950739644206, 338.88008267441825 299.21133798000136 M335.6855659914678 302.59702518225697 C337.02991394124655 301.54674801824234, 338.10530172693433 300.0878010734625, 339.2885939947801 298.9612831677097" stroke="#ced4da" strokeWidth="0.5" fill="none"></path><path d="M1.025000303077548 -1.0210200086860572 C126.4593460380801 -2.623258486183465, 252.82690366675521 -1.1903732071732434, 338.99323134445416 -0.3312612150753279 M338.1721258170044 1.1316216946948554 C338.36463237530916 115.18113698394362, 339.570159578534 233.03264806216058, 338.09310316323496 301.0356634589555 M339.64307271528855 301.8654367316296 C269.1770781808705 301.3424798661518, 202.02252009281725 300.8744205962091, -0.6541915114751535 301.24536844070303 M-1.2879338111639067 302.0028160424876 C-0.3475345164388284 203.99503535089872, -1.3291802275160087 106.4360773792282, 1.0358268576151337 -1.4583501011319009" stroke="#000000" strokeWidth="1.5" fill="none" strokeDasharray="12 8"></path></g><g><g transform="translate(383.57277423868345 655.6533428680344) rotate(0 7.73767115987539 9.116186729986197)"><path d="M-0.19015785120427608 0.8903645183891056 C2.7125150053596676 3.68892221711576, 14.105995769250482 13.907269993051887, 17.22447177217673 16.93950574658811 M-1.7491294524259866 0.3122019452042877 C0.9760299615024206 3.209370183963329, 12.731047661557136 15.087163527626544, 16.058901567825632 17.920171514768153" stroke="#495057" strokeWidth="2" fill="none"></path></g></g><g><g transform="translate(399.95217351052906 672.0006767575975) rotate(0 7.898041413165629 -7.544459703192132)"><path d="M0.7692286636680363 1.036823919788003 C3.2328757788985962 -1.7929252233356239, 11.55320261977613 -13.454582973942163, 13.93329146169126 -16.125743326172234 M-0.28606501724571 0.535552532337606 C2.609962849356234 -2.262947162725031, 13.649951649643482 -12.812162247039378, 16.08214784357697 -15.17933332119137" stroke="#495057" strokeWidth="2" fill="none"></path></g></g><g transform="translate(634.7849188062419 286.95155461378) rotate(0 41.5 20)"><text x="41.5" y="14" fontFamily="Virgil" fontSize="16px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Product</text><text x="41.5" y="34" fontFamily="Virgil" fontSize="16px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">screenshot</text></g><g><g transform="translate(669.3294416556828 338.9088798825975) rotate(0 -71.26604767116078 70.45597479527277)"><path d="M0.7478593502193689 -0.8813055608421564 C-1.5501979921680078 16.603909571069604, 10.07321646493292 81.78597181809128, -14.062084938613406 105.5650652701296 C-38.19738634215973 129.34415872216792, -122.35689467903188 135.91385814925817, -144.0639490710586 141.79325515138774" stroke="#000000" strokeWidth="1.5" fill="none" strokeDasharray="12 8"></path></g><g transform="translate(669.3294416556828 338.9088798825975) rotate(0 -71.26604767116078 70.45597479527277)"><path d="M-119.59118393890255 128.22442891314677 C-125.25244645758644 129.9035899960338, -132.74272260136337 133.67938529968947, -145.9713487608373 141.7028298444566" stroke="#000000" strokeWidth="1.5" fill="none"></path></g><g transform="translate(669.3294416556828 338.9088798825975) rotate(0 -71.26604767116078 70.45597479527277)"><path d="M-115.97509170954999 148.42452493704366 C-122.74874510038585 144.52699262832866, -131.24135955592405 142.70355847050757, -145.9713487608373 141.7028298444566" stroke="#000000" strokeWidth="1.5" fill="none"></path></g></g><g transform="translate(52.60195509654477 461.1494712804469) rotate(0 46.5 25)"><text x="46.5" y="18" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">Invitation</text><text x="46.5" y="43" fontFamily="Virgil" fontSize="20px" fill="#000000" textAnchor="middle" style={{ whiteSpace: 'pre' }} direction="ltr">to scroll</text></g><g><g transform="translate(108.46855455891637 522.6204054202319) rotate(0 127.36507475970211 71.03200849219758)"><path d="M-1.128410968557 0.8741367850452659 C6.506430864463528 20.522147188454493, 3.281872612772773 94.49079552860964, 46.11303452219236 117.87675276432708 C88.94419643161194 141.2627100000445, 221.06046196393342 137.60448791482838, 255.8585604879605 141.1898801993499" stroke="#000000" strokeWidth="1.5" fill="none" strokeDasharray="12 8"></path></g><g transform="translate(108.46855455891637 522.6204054202319) rotate(0 127.36507475970211 71.03200849219758)"><path d="M228.72075139326648 152.02543160797546 C237.94025194430418 148.51061051969876, 247.51876627187784 143.96355938324527, 254.50578319128326 141.27699229269422" stroke="#000000" strokeWidth="1.5" fill="none"></path></g><g transform="translate(108.46855455891637 522.6204054202319) rotate(0 127.36507475970211 71.03200849219758)"><path d="M229.57779370339966 131.5221274629241 C238.51653862192674 134.66962737272516, 247.81622969465124 136.79295636721, 254.50578319128326 141.27699229269422" stroke="#000000" strokeWidth="1.5" fill="none"></path></g></g>
</svg>
)
}
================================================
FILE: packages/francoisbest.com/content/blog/2020/password-reset-for-e2ee-apps/index.mdx
================================================
---
title: Password Reset for End-to-End Encrypted Applications
description: "We forget passwords. Usually it's OK, because most websites implement a password reset feature. But how to do this in end-to-end encrypted applications that don't have access to the password in the first place ?"
publicationDate: '2020-02-15'
tags: [e2ee, security, cryptography]
---
We all forget our passwords. And that's OK, most of the time.
Some people have built entire businesses around that fact: Password Managers
such as
[Bitwarden](https://bitwarden.com),
[1Password](https://1password.com),
[LastPass](https://lastpass.com),
[Dashlane](https://dashlane.com),
[KeePass](https://keepass.info) etc..
They all offer you a similar promise:
> You have only one password to rembember.
Which brings us back to our opening statement: we, humans, forget passwords.
And while most web services have a password reset feature to alleviate that,
End-to-end encrypted (E2EE) apps such as password managers don't allow you to lose
your main password (the one that unlocks your account).
This is because those **password managers don't have access to your main password**.
If they did, it would be a terrible security design flaw on their part, and you
should probably look for a replacement [^1].
[^1]:
It's not always easy to know what an app does when you can't read its source code.
I use [Bitwarden](https://bitwarden.com) to manage my passwords
especially because it is [open-source](https://github.com/bitwarden).
Traditional password resets can vary a lot in security and complexity.
[Troy Hunt](https://www.troyhunt.com) has an excellent article on
[how to do this the right way](https://www.troyhunt.com/everything-you-ever-wanted-to-know/).
The gist of it is:
1. A user asks for a password reset
2. An email is sent to them containing a link that bypasses the traditional authentication mechanism
3. The user enters a new password
4. The password entry is updated in the database
The key here is that the password is sent in clear text to the server, which
will ([hopefully](https://www.youtube.com/watch?v=8ZtInClXe1Q))
salt it & hash it using a slow algorithm like Bcrypt/Scrypt/Argon2 before saving
it in a database.
Because there is no password storage of any kind in the backend of an E2EE app,
there is nothing to update with this kind of system.
Moreover, the issue is that some (if not all) of the data in the database is
stored as received: encrypted with a key that the server does not have. If that
key is lost, because it depends on the lost main password, there is no way to
decrypt the existing data.
The only thing those apps can do is to reset your account, wipe the slate clean
by deleting all the existing unreadable data and let you use the same username
or email address for a fresh start.
This is not ideal. There is a way to allow a user to recover their data if they
lose access to their main password though:
Using [horcruxes](https://en.wikipedia.org/wiki/Magical_objects_in_Harry_Potter#Horcruxes).
## Shamir Secret Sharing
In 1979, [Adi Shamir](https://en.wikipedia.org/wiki/Adi_Shamir)
wrote ["How to share a secret"](https://dl.acm.org/doi/10.1145/359168.359176),
explaining a technique now named after him,
[Shamir Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing).
It allows splitting a secret into a number of parts, each individually useless
to the owner. Even if someone were to gather all but one of the parts, the
secret would still be safe. All the parts are required to reconstruct the secret.
To apply this to a password reset system, the password can be split in two shards.
One shard would be sent to the server, and the other presented to the user,
asking them to save it somewhere safe.
import { FiShield } from 'react-icons/fi'
<Note title="Security Note" icon={FiShield}>
Because passwords usually have low entropy, it might be more secure to split a
key derived from the password instead.
</Note>
Here is an example output in TypeScript using [`@stablelib/tss`](https://npmjs.com/package/@stablelib/tss) :
```ts
import crypto from 'crypto'
import { split, IDENTIFIER_LENGTH } from '@stablelib/tss'
import { utf8, hex } from '@47ng/codec'
const shards = split(
utf8.encode('supersecretpassword'),
2, // Require 2 shards to recompose the secret
2, // Generate 2 shards in total
crypto.randomBytes(IDENTIFIER_LENGTH) // Random identifier
).map(shard => hex.encode(shard))
// Server shard:
// 7db2d515c461711e28a1a099aabc7cf5
// 0202003401eceaeffaedecfafcedfaeb
// effeecece8f0edfbc55ecd2967222724
// 8d0a0ad74add54bce3d5ec9ffb201724
// 2742f1bfd68d2532
//
// User shard:
// 7db2d515c461711e28a1a099aabc7cf5
// 02020034025650554057564046574051
// 55445656524a57417fe47793dd989d9e
// 37b0b06df067ee06596f5625419aad9e
// 9df84b056c379f88
```
## Password Reset Flow
Now that the user has saved their shard, and the server shard has been saved in
the database, a password reset flow can be initiated.
Everything happens on the client side in an E2EE app, so the app cannot ask
the user to send their shard to the server, as it would give it access to the
keys to unlock all the data.
Instead, the server sends an email with a link containing a temporary token.
The reason the server shard is not sent directly in the email link is to
allow that link to expire.
When the user visits that link, the token is used to retrieve the server shard,
and the user is asked to enter their shard.
Recomposition happens on the client side, to regenerate whatever secret is used
to authenticate / decrypt the data:
```ts
import { combine } from '@stablelib/tss'
import { utf8, hex } from '@47ng/codec'
const shards = [
'7db2d515c461711e28a1a099aabc7cf50202003401eceaeffaedecfafcedfaebeffeecece8f0edfbc55ecd29672227248d0a0ad74add54bce3d5ec9ffb2017242742f1bfd68d2532',
'7db2d515c461711e28a1a099aabc7cf50202003402565055405756404657405155445656524a57417fe47793dd989d9e37b0b06df067ee06596f5625419aad9e9df84b056c379f88'
].map(shard => hex.decode(shard))
const secret = utf8.decode(combine(shards))
// Secret:
// supersecretpassword
```
## Caveats
While this system can be convenient, it poses a security risk, even though it
requires compromising both wherever the user stored their shard and their email
account, it can happen.
Brute-force attacks on this system should be dimensioned to be similar to
attacking the encrypted data, 256 bits of entropy in the secret to split gives
a good trade-off between shard size and computational complexity for an attack.
If the user loses their shard, there is nothing that can be done to recover
the account. So one could say it's the same problem as a password, but the size
of the shard plays in favour of storing it somewhere safe, as it cannot be
remembered.
> ... for neither can live, while the other survives.
>
> <figcaption>JK Rowling, Harry Potter And The Order Of The Phoenix</figcaption>
================================================
FILE: packages/francoisbest.com/content/blog/2020/the-security-of-github-actions/index.mdx
================================================
---
title: The Security of GitHub Actions
description: "GitHub Actions are a great way to build powerful customised CI/CD workflows using the power of community-driven resources, but they can be tricky to get right in terms of security."
publicationDate: '2020-02-24'
tags: [security, tooling, github-actions, docker]
---
[GitHub Actions](https://github.com/features/actions)
are a great way to build powerful customised CI/CD workflows using the power of
community-driven resources, but they can be tricky to get right in terms of
security.
## Remote Code Execution as a Service
What GitHub gave us with Actions is basically the opportunity to run
([almost](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features#actions))
any code on their servers. This makes for a large attack surface and lengthy
discussions, so let me define some boundaries.
This article is not about the kind of security regarding attacks against GitHub,
but rather against yourself, when implementing a workflow.
It will also not consider GitHub itself as an adversary, and instead focus on
threats coming from compromised third party actions and their impact on our
workflows.
## Attack Vectors
There are a few bad things that can happen to your workflow:
#### 1. Data Theft
A malicious action leaks/steals your API tokens or other secrets required by
legitimate actions.
#### 2. Data Integrity Breaches
A malicious action modifies one of your built artefacts, injecting it with
malicious code or corrupting it before it is processed or deployed by a
legitimate action.
#### 3. Availability
A malicious action crashes on purpose to prevent your workflow from executing
successfully.
## The Blessings & Curse of the Community
Having people work on their own actions and contributing them back to the
community is definitely a blessing, as can be seen with the flourishing of the
JavaScript ecosystem through NPM in the last decade.
But it comes with its woes, as we have seen in the past. Some famous examples
being the
[`leftPad` incident](https://blog.npmjs.org/post/141577284765/kik-left-pad-and-npm)
(an availability _"attack"_), the
[attacks on ESLint](https://eslint.org/blog/2018/07/postmortem-for-malicious-package-publishes)
that leaked credentials (data theft) or the
[`event-stream` attack](https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident)
that targeted Copay's build process (data integrity).
I guess every popular system will gather the interest of attackers, and in the
end the benefits will probably outweigh the risks, as long as some protections
are in place. Some are in the hands of GitHub (scanning and removing malicious
actions), but some are in the hands of the users.
So what can you do to protect yourself ?
There has been some research by
[Julien Renaux](https://julienrenaux.fr/2019/12/20/github-actions-security-risk/)
on this topic, where he recommends pinning action versions not by Git tags,
but by Git SHA-1, which is immutable.
This article builds on top of this research, looking specifically at actions
using Docker and environment variables.
## Docker-based Actions
Actions can run in a Docker container, created from an image pulled from
Docker hub or GitHub's Image Registry. You can specify a tag to use for the
image, but just like Git tags,
[Docker tags are not immutable](https://renovate.whitesourcesoftware.com/blog/overcoming-dockers-mutable-image-tags/).
As an example, I have created a small Node.js image:
```dockerfile title="Dockerfile"
FROM mhart/alpine-node:slim-12
CMD node -e 'console.log("hello")'
```
```shell
$ docker build -t franky47/test:foo .
$ docker push franky47/test:foo
foo: digest: sha256:0916addef9806b26b46f685028e8d95d4c37e7ed8e6274b822797e90ae6fd88f size: 740
$ docker run --rm -it franky47/test:foo
hello
```
Later on, I modify the image, rebuild and upload it using the same tag:
```dockerfile /evil/ title="Dockerfile"
FROM mhart/alpine-node:slim-12
CMD node -e 'console.log("evil")'
```
```shell /evil/
$ docker build -t franky47/test:foo .
$ docker push franky47/test:foo
foo: digest: sha256:85fe141a80820b9db0631252ca4e06cc3ced6f662c540b9c25da645168ae5be7 size: 740
$ docker run --rm -it franky47/test:foo
evil
```
You can see how the tag transparently allows the evil version to run.
The only defence against that is, just like Git, to use the SHA-256 digest hash
to pin the image:
```shell
$ docker run --rm -it franky47/test@sha256:0916addef9806b26b46f685028e8d95d4c37e7ed8e6274b822797e90ae6fd88f
hello
$ docker run --rm -it franky47/test@sha256:85fe141a80820b9db0631252ca4e06cc3ced6f662c540b9c25da645168ae5be7
evil
```
## Docker for Action Authors
Action authors can use Docker too. They add their Dockerfile to the action
repository, and tell GitHub where to find it in the `action.yml` metadata file.
Most of the time, the job runner will build the Docker image from the sources
before running it onto the workflow.
Because those images are built out-of-band before the workflow runs, it's less
likely that the Docker build context gets injected with malicious files or
environment variables to compromise the built image. However, because the
Dockerfile and the rest of the action repository come from Git, SHA-1 pinning
is still recommended to be sure of what is being built.
#### Performance vs Security
It seems wasteful to rebuild images for every workflow run that depends
on a Docker-based action. The image may take a long time to build, and that time
is taken from the usage limits of everyone who depends on your action,
it slows their workflows down, and it generally wastes energy.
Once your action is stable, you can build and publish the Docker image, then
pin it to your `action.yml` file by digest hash:
```yaml title="action.yml"
name: Some action
runs:
using: docker
image: docker://franky47/test@sha256:0916addef9806b26b46f685028e8d95d4c37e7ed8e6274b822797e90ae6fd88f
```
This way, the users of your action will pull the image from the Docker registry
instead of building it.
The threat model for this kind of delivery method now shifts from your action's
users to your own workflow (the one you use to build & deploy the Docker image).
But it has a few advantages:
- You can review and pin any action you may need to build the image
- Your image cannot be compromised by being built outside of a boundary you control.
<Note title="Note">
The threat model of the Docker registry being compromised or untrusted is out
of the scope of this article.
</Note>
## Keeping Up With The ~~Kardashians~~ Security Updates
So what about security updates ? If versions are pinned forever, we miss out on
critical vulnerabilities being patched up in the actions we use, their
dependencies and all the dependency graph.
Unfortunately for now, while security and maintenance updates of dependencies
can be automated for action authors, action users have to manually check and
update their actions, and remember to pin the SHA-1 hash every time.
Services like [Dependabot](https://dependabot.com/github-actions/)
will eventually become able to analyse the dependency tree of a workflow file,
make sure with [CodeQL](https://securitylab.github.com/tools/codeql)
that it is free of known vulnerabilities or malicious code, and suggest updates
back to the workflow file, hopefully in the form of SHA-1 pinnings.
<Note status="success" title="2020-12-25 Update">
Dependabot now supports SHA-1 pinning updates, when using the built-in version
in GitHub.
</Note>
---
Regardless of how you provide your action, there is another threat both action
authors and consumers need to be aware of:
## Environment Variables
<Note status="warning" title="2020-09-02 Update">
What is stated in this section is no longer correct, as GitHub seems to have
fixed the issues aforementionned.
I'm leaving the original article for reference. For a proof of fix, see the
following [GitHub issue on Docker's
action](https://github.com/docker/build-push-action/issues/10).
</Note>
GitHub Actions can communicate between one another through environment variables,
in tandem with the I/O system that GitHub provides
gitextract_puz3o34r/ ├── .beans/ │ ├── fbst-0ilj--step-6-dark-mode-next-themes.md │ ├── fbst-2evh--step-1-tooling-foundation-turbo-v2-oxfmt-oxlint-vi.md │ ├── fbst-5495--step-3-dead-code-small-dependency-cleanup.md │ ├── fbst-9pj9--modernise-francoisbestcom-codebase.md │ ├── fbst-9w5o--step-7-mdx-pipeline-fumadocs-mdx.md │ ├── fbst-abdk--step-8-non-blog-pages-tsx.md │ ├── fbst-lzix--step-10-knip-final-cleanup.md │ ├── fbst-m7lu--fix-broken-links-in-blog-posts-and-sitemap-page.md │ ├── fbst-nv8l--restore-hireme-cta-and-edit-links-on-static-pages.md │ ├── fbst-p7fj--step-9-dayjs-temporal.md │ ├── fbst-qj1o--expand-sitemap-to-include-all-public-pages.md │ ├── fbst-qp1g--step-4-nextjs-16-upgrade.md │ ├── fbst-tzpj--fix-qa-regressions-in-modernisation-pr.md │ ├── fbst-u6tb--fix-reading-time-computation-and-display.md │ ├── fbst-ugwm--step-5-built-in-sitemap-robots.md │ └── fbst-xhgq--step-2-typescript-60-upgrade.md ├── .beans.yml ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .node-version ├── .oxfmtrc.json ├── .vscode/ │ └── launch.json ├── LICENSE.txt ├── README.md ├── docs/ │ ├── authoring.md │ └── blog-engine.md ├── package.json ├── packages/ │ └── francoisbest.com/ │ ├── .npmrc │ ├── .oxlintrc.json │ ├── README.md │ ├── content/ │ │ └── blog/ │ │ ├── 2019/ │ │ │ ├── how-to-store-e2ee-keys-in-the-browser/ │ │ │ │ └── index.mdx │ │ │ └── strava-auth-cli-in-rust/ │ │ │ └── index.mdx │ │ ├── 2020/ │ │ │ ├── dark-mode-for-excalidraw/ │ │ │ │ ├── index.mdx │ │ │ │ ├── status-text.tsx │ │ │ │ └── venn.tsx │ │ │ ├── mobile-device-frames-for-excalidraw/ │ │ │ │ ├── index.mdx │ │ │ │ └── mobile-mockup.tsx │ │ │ ├── password-reset-for-e2ee-apps/ │ │ │ │ └── index.mdx │ │ │ └── the-security-of-github-actions/ │ │ │ └── index.mdx │ │ ├── 2021/ │ │ │ ├── cargo-docker-mtime/ │ │ │ │ └── index.mdx │ │ │ └── hashvatars/ │ │ │ └── index.mdx │ │ └── 2023/ │ │ ├── displaying-local-times-in-nextjs/ │ │ │ └── index.mdx │ │ ├── displaying-the-right-vercel-deployment-urls-in-nextjs/ │ │ │ └── index.mdx │ │ ├── dotenv-is-dead/ │ │ │ └── index.mdx │ │ ├── npm-download-stats-are-down/ │ │ │ └── index.mdx │ │ ├── publish-a-json-schema/ │ │ │ └── index.mdx │ │ ├── reading-files-on-vercel-during-nextjs-isr/ │ │ │ └── index.mdx │ │ ├── storing-react-state-in-the-url-with-nextjs/ │ │ │ ├── demo.tsx │ │ │ ├── greetings.tsx │ │ │ ├── index.mdx │ │ │ ├── query-spy.tsx │ │ │ └── update-queue.tsx │ │ └── testing-against-every-nextjs-canary-release/ │ │ ├── index.mdx │ │ └── windowing.tsx │ ├── knip.json │ ├── next.config.ts │ ├── package.json │ ├── public/ │ │ ├── .well-known/ │ │ │ ├── atproto-did │ │ │ ├── keybase.txt │ │ │ └── security.txt │ │ ├── favicons/ │ │ │ └── browserconfig.xml │ │ ├── img/ │ │ │ └── posts/ │ │ │ └── 2020/ │ │ │ └── mobile-device-frames-for-excalidraw/ │ │ │ ├── apple-device-frames.excalidraw │ │ │ ├── apple-device-frames.excalidrawlib │ │ │ └── make-frame.webm │ │ └── manifest.webmanifest │ ├── scripts/ │ │ └── isr.mjs │ ├── source.config.ts │ ├── src/ │ │ ├── app/ │ │ │ ├── (dashboards)/ │ │ │ │ ├── layout.tsx │ │ │ │ └── sandbox/ │ │ │ │ └── .gitignore │ │ │ ├── (not-prose)/ │ │ │ │ ├── hashvatar/ │ │ │ │ │ ├── demo.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── horcrux/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── recompose.tsx │ │ │ │ │ ├── split.tsx │ │ │ │ │ └── tss.ts │ │ │ │ ├── layout.tsx │ │ │ │ └── woodworking/ │ │ │ │ └── dovetail-designer/ │ │ │ │ ├── components/ │ │ │ │ │ ├── dovetail-svg.tsx │ │ │ │ │ └── dovetails.ts │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── (pages)/ │ │ │ │ ├── _landing-sections/ │ │ │ │ │ ├── about-me.tsx │ │ │ │ │ ├── career/ │ │ │ │ │ │ ├── career.tsx │ │ │ │ │ │ ├── experience.tsx │ │ │ │ │ │ └── icons/ │ │ │ │ │ │ ├── arturia.tsx │ │ │ │ │ │ ├── gael.tsx │ │ │ │ │ │ ├── heron.tsx │ │ │ │ │ │ ├── lacquereur.tsx │ │ │ │ │ │ ├── marianne.tsx │ │ │ │ │ │ ├── pulsar.tsx │ │ │ │ │ │ └── slate-digital.tsx │ │ │ │ │ ├── featured-posts.tsx │ │ │ │ │ └── music.tsx │ │ │ │ ├── business-card/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── qrcode.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── links/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── music/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── open-source/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── posts/ │ │ │ │ │ ├── [...slug]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── [year]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── blog-post-preview.tsx │ │ │ │ │ │ └── blog-roll-header.tsx │ │ │ │ │ ├── feed/ │ │ │ │ │ │ └── [format]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── og/ │ │ │ │ │ │ └── [...slug]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── tags/ │ │ │ │ │ ├── [tag]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── public-keys/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── safari-speedrun/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── runner.tsx │ │ │ │ ├── sitemap/ │ │ │ │ │ └── page.tsx │ │ │ │ └── uses/ │ │ │ │ └── page.tsx │ │ │ ├── .well-known/ │ │ │ │ └── webfinger/ │ │ │ │ └── route.ts │ │ │ ├── api/ │ │ │ │ └── isr/ │ │ │ │ └── route.ts │ │ │ ├── global.css │ │ │ ├── layout.tsx │ │ │ ├── not-found.tsx │ │ │ ├── robots.ts │ │ │ ├── sitemap.ts │ │ │ └── vcard/ │ │ │ ├── route.ts │ │ │ └── vcard.ts │ │ ├── css.d.ts │ │ ├── lib/ │ │ │ ├── blog/ │ │ │ │ ├── defs.ts │ │ │ │ ├── engine.ts │ │ │ │ ├── index.ts │ │ │ │ ├── reading-time.test.ts │ │ │ │ └── reading-time.ts │ │ │ ├── env.ts │ │ │ ├── mdx-components.tsx │ │ │ ├── paths.test.ts │ │ │ ├── paths.ts │ │ │ ├── seo.json │ │ │ ├── services/ │ │ │ │ ├── chiffre.ts │ │ │ │ ├── github.ts │ │ │ │ ├── hacker-news.ts │ │ │ │ ├── html-sanitizer.ts │ │ │ │ └── npm.ts │ │ │ └── source.ts │ │ └── ui/ │ │ ├── components/ │ │ │ ├── browser-window-frame.tsx │ │ │ ├── buttons/ │ │ │ │ ├── button-spinner.tsx │ │ │ │ ├── button.tsx │ │ │ │ └── icon-button.tsx │ │ │ ├── forms/ │ │ │ │ ├── inputs.tsx │ │ │ │ ├── radio.tsx │ │ │ │ ├── slider.tsx │ │ │ │ └── structure.tsx │ │ │ ├── graphs/ │ │ │ │ └── svg-curve-graph.tsx │ │ │ ├── hashvatar.client.tsx │ │ │ ├── hashvatar.server.tsx │ │ │ ├── hire-me.tsx │ │ │ ├── local-time.tsx │ │ │ ├── logo.tsx │ │ │ ├── note.tsx │ │ │ ├── stat.tsx │ │ │ ├── tag.tsx │ │ │ └── theme-controls.tsx │ │ ├── embeds/ │ │ │ ├── blog-post-embed.tsx │ │ │ ├── embed-frame.tsx │ │ │ ├── github-repo.tsx │ │ │ ├── hacker-news.tsx │ │ │ ├── npm-package.tsx │ │ │ ├── spotify-album.tsx │ │ │ ├── spotify-artist.tsx │ │ │ └── spotify-loader.tsx │ │ ├── format.ts │ │ ├── head/ │ │ │ └── favicons.tsx │ │ ├── hooks/ │ │ │ ├── useClipboard.ts │ │ │ └── useHydration.ts │ │ ├── layouts/ │ │ │ ├── footer.tsx │ │ │ ├── nav-header.tsx │ │ │ ├── nav-link.tsx │ │ │ └── wide-container.tsx │ │ └── theme/ │ │ └── moonlight-ii.json │ └── tsconfig.json ├── pnpm-workspace.yaml └── turbo.json
SYMBOL INDEX (198 symbols across 96 files)
FILE: packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/status-text.tsx
function StatusText (line 1) | function StatusText(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/venn.tsx
function VennDiagram (line 1) | function VennDiagram(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/content/blog/2020/mobile-device-frames-for-excalidraw/mobile-mockup.tsx
function MobileMockup (line 1) | function MobileMockup(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/greetings.tsx
constant DEFAULT (line 7) | const DEFAULT = 'anonymous reader'
FILE: packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/update-queue.tsx
function UpdateQueue (line 1) | function UpdateQueue(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/content/blog/2023/testing-against-every-nextjs-canary-release/windowing.tsx
function Windowing (line 1) | function Windowing(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/next.config.ts
method redirects (line 30) | async redirects() {
FILE: packages/francoisbest.com/source.config.ts
method onVisitTitle (line 22) | onVisitTitle(element) {
method onVisitCaption (line 54) | onVisitCaption(element) {
FILE: packages/francoisbest.com/src/app/(dashboards)/layout.tsx
function DashboardLayout (line 3) | function DashboardLayout({
FILE: packages/francoisbest.com/src/app/(not-prose)/hashvatar/demo.tsx
function HashvatarDemoPage (line 8) | function HashvatarDemoPage() {
type VariantButtonProps (line 52) | type VariantButtonProps = {
FILE: packages/francoisbest.com/src/app/(not-prose)/hashvatar/page.tsx
function HashvatarPage (line 9) | function HashvatarPage() {
FILE: packages/francoisbest.com/src/app/(not-prose)/horcrux/page.tsx
function HorcruxPage (line 11) | function HorcruxPage() {
FILE: packages/francoisbest.com/src/app/(not-prose)/horcrux/recompose.tsx
type ShardStateMap (line 10) | type ShardStateMap = {
type ShardState (line 16) | type ShardState = {
function useShards (line 34) | function useShards() {
function useSecret (line 40) | function useSecret(shards: string[]) {
FILE: packages/francoisbest.com/src/app/(not-prose)/horcrux/split.tsx
type HorcruxSplitProps (line 19) | type HorcruxSplitProps = {
type ReadOnlyCodeBlock (line 131) | type ReadOnlyCodeBlock = {
FILE: packages/francoisbest.com/src/app/(not-prose)/horcrux/tss.ts
function generateRandomBytes (line 4) | function generateRandomBytes(length: number) {
function splitSecret (line 17) | function splitSecret(
function assembleSecret (line 35) | function assembleSecret(shards: string[]) {
function cleanupShard (line 49) | function cleanupShard(shard: string) {
FILE: packages/francoisbest.com/src/app/(not-prose)/layout.tsx
function PageLayout (line 4) | function PageLayout({
FILE: packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/components/dovetail-svg.tsx
type DovetailSVGProps (line 4) | type DovetailSVGProps = React.ComponentProps<'section'> & {
FILE: packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/components/dovetails.ts
type DovetailParameters (line 1) | type DovetailParameters = {
function getDovetailData (line 10) | function getDovetailData({
type DovetailData (line 43) | type DovetailData = ReturnType<typeof getDovetailData>
function getDovetailPath (line 47) | function getDovetailPath({
function getDovetailMeasurements (line 77) | function getDovetailMeasurements({ a, b, c, c_, b_ }: DovetailData) {
FILE: packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/page.client.tsx
type DovetailDesignerProps (line 22) | interface DovetailDesignerProps {}
FILE: packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/page.tsx
function DovtailDesignerPage (line 13) | function DovtailDesignerPage() {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/about-me.tsx
function AboutMe (line 8) | function AboutMe() {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/career.tsx
type CareerProps (line 12) | type CareerProps = React.ComponentProps<'div'> & {}
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/experience.tsx
type ExperienceProps (line 4) | type ExperienceProps = {
type ClientProps (line 40) | type ClientProps = React.ComponentProps<'li'> & {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/arturia.tsx
function ArturiaLogo (line 1) | function ArturiaLogo(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/gael.tsx
function GaelLogo (line 1) | function GaelLogo(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/heron.tsx
function HeronLogo (line 1) | function HeronLogo(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/lacquereur.tsx
function AcquereurLogo (line 1) | function AcquereurLogo(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/marianne.tsx
function MarianneLogo (line 1) | function MarianneLogo(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/pulsar.tsx
function PulsarLogo (line 1) | function PulsarLogo(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/slate-digital.tsx
function SlateLogo (line 1) | function SlateLogo(props: React.ComponentProps<'svg'>) {
FILE: packages/francoisbest.com/src/app/(pages)/business-card/page.tsx
type PageProps (line 13) | type PageProps = {
function BusinessCardPage (line 17) | async function BusinessCardPage({ searchParams }: PageProps) {
function loadKey (line 46) | async function loadKey(form: FormData) {
function LoadKey (line 57) | function LoadKey() {
FILE: packages/francoisbest.com/src/app/(pages)/business-card/qrcode.tsx
type QRCodeProps (line 4) | type QRCodeProps = React.ComponentProps<'figure'> & {
FILE: packages/francoisbest.com/src/app/(pages)/layout.tsx
function PageLayout (line 4) | function PageLayout({
FILE: packages/francoisbest.com/src/app/(pages)/links/page.tsx
function Page (line 3) | function Page() {
FILE: packages/francoisbest.com/src/app/(pages)/music/page.tsx
function MusicPage (line 12) | function MusicPage() {
FILE: packages/francoisbest.com/src/app/(pages)/open-source/page.tsx
function OpenSourcePage (line 28) | async function OpenSourcePage() {
FILE: packages/francoisbest.com/src/app/(pages)/page.tsx
function HomePage (line 10) | function HomePage() {
FILE: packages/francoisbest.com/src/app/(pages)/posts/[...slug]/page.tsx
type PageProps (line 14) | type PageProps = {
function generateStaticParams (line 18) | async function generateStaticParams() {
function generateMetadata (line 23) | async function generateMetadata({ params }: PageProps): Promise<Metadata> {
function BlogPost (line 42) | async function BlogPost({ params }: PageProps) {
FILE: packages/francoisbest.com/src/app/(pages)/posts/[year]/page.tsx
type PageProps (line 8) | type PageProps = {
function generateStaticParams (line 16) | async function generateStaticParams() {
function generateMetadata (line 28) | async function generateMetadata({ params }: PageProps) {
function YearIndex (line 52) | async function YearIndex({ params }: PageProps) {
FILE: packages/francoisbest.com/src/app/(pages)/posts/components/blog-post-preview.tsx
type BlogPostPreviewProps (line 6) | type BlogPostPreviewProps = Post & {
FILE: packages/francoisbest.com/src/app/(pages)/posts/components/blog-roll-header.tsx
type BlogRollHeaderProps (line 3) | type BlogRollHeaderProps = {
FILE: packages/francoisbest.com/src/app/(pages)/posts/feed/[format]/route.ts
function generateStaticParams (line 6) | async function generateStaticParams() {
function GET (line 14) | async function GET(
FILE: packages/francoisbest.com/src/app/(pages)/posts/og/[...slug]/route.ts
constant CONTENT_DIR (line 5) | const CONTENT_DIR = path.join(process.cwd(), 'content/blog')
function generateStaticParams (line 7) | async function generateStaticParams() {
function GET (line 14) | async function GET(
FILE: packages/francoisbest.com/src/app/(pages)/posts/page.tsx
function BlogIndex (line 10) | async function BlogIndex() {
FILE: packages/francoisbest.com/src/app/(pages)/posts/tags/[tag]/page.tsx
type PageProps (line 9) | type PageProps = {
function generateMetadata (line 15) | async function generateMetadata({ params }: PageProps) {
function TagPage (line 23) | async function TagPage({ params }: PageProps) {
function generateStaticParams (line 54) | async function generateStaticParams() {
FILE: packages/francoisbest.com/src/app/(pages)/posts/tags/page.tsx
function TagsIndex (line 10) | async function TagsIndex() {
FILE: packages/francoisbest.com/src/app/(pages)/public-keys/page.tsx
function PublicKeysPage (line 11) | function PublicKeysPage() {
FILE: packages/francoisbest.com/src/app/(pages)/safari-speedrun/page.tsx
function SafariSpeedrunPage (line 10) | function SafariSpeedrunPage() {
FILE: packages/francoisbest.com/src/app/(pages)/sitemap/page.tsx
function SitemapPage (line 12) | function SitemapPage() {
FILE: packages/francoisbest.com/src/app/(pages)/uses/page.tsx
function UsesPage (line 8) | function UsesPage() {
FILE: packages/francoisbest.com/src/app/.well-known/webfinger/route.ts
function GET (line 3) | async function GET() {
FILE: packages/francoisbest.com/src/app/api/isr/route.ts
constant ACCEPTED_TAGS (line 4) | const ACCEPTED_TAGS = ['npm', 'github']
function GET (line 6) | async function GET(req: NextRequest) {
FILE: packages/francoisbest.com/src/app/layout.tsx
function RootLayout (line 15) | function RootLayout({
FILE: packages/francoisbest.com/src/app/not-found.tsx
function NotFound (line 4) | function NotFound() {
FILE: packages/francoisbest.com/src/app/robots.ts
function robots (line 6) | function robots(): MetadataRoute.Robots {
FILE: packages/francoisbest.com/src/app/sitemap.ts
function sitemap (line 5) | async function sitemap(): Promise<MetadataRoute.Sitemap> {
FILE: packages/francoisbest.com/src/app/vcard/route.ts
function GET (line 6) | function GET(req: NextRequest) {
FILE: packages/francoisbest.com/src/app/vcard/vcard.ts
function decryptPhoneNumber (line 18) | function decryptPhoneNumber(key: string) {
FILE: packages/francoisbest.com/src/lib/blog/defs.ts
type PostMetadata (line 1) | type PostMetadata = {
FILE: packages/francoisbest.com/src/lib/blog/engine.ts
constant CONTENT_DIR (line 8) | const CONTENT_DIR = path.join(process.cwd(), 'content/blog')
type OgImageExtension (line 10) | type OgImageExtension = 'jpg' | 'png'
type Post (line 12) | type Post = {
function getAllPosts (line 20) | async function getAllPosts(): Promise<Post[]> {
function getPost (line 33) | async function getPost(slug: string[]): Promise<Post | undefined> {
type FumadocsPage (line 39) | type FumadocsPage = ReturnType<typeof blogSource.getPages>[number]
function pageToPost (line 41) | async function pageToPost(page: FumadocsPage): Promise<Post> {
function detectOgImage (line 60) | async function detectOgImage(
FILE: packages/francoisbest.com/src/lib/blog/reading-time.ts
constant CONTENT_DIR (line 5) | const CONTENT_DIR = path.join(process.cwd(), 'content/blog')
function computeReadingTime (line 7) | async function computeReadingTime(slug: string[]): Promise<string> {
FILE: packages/francoisbest.com/src/lib/mdx-components.tsx
function getMdxComponents (line 10) | function getMdxComponents(): MDXComponents {
FILE: packages/francoisbest.com/src/lib/paths.ts
function resolve (line 9) | function resolve(importMetaUrl: string, ...paths: string[]) {
function url (line 20) | function url(routePath: string) {
function gitHubUrl (line 28) | function gitHubUrl(
FILE: packages/francoisbest.com/src/lib/services/chiffre.ts
type ChiffreConfig (line 1) | type ChiffreConfig =
FILE: packages/francoisbest.com/src/lib/services/github.ts
type GitHubRepositoryData (line 4) | type GitHubRepositoryData = {
function fetchRepository (line 46) | async function fetchRepository(
FILE: packages/francoisbest.com/src/lib/services/hacker-news.ts
type HackerNewsItem (line 26) | type HackerNewsItem = z.infer<typeof hackerNewsItemSchema>
function getHackerNewsItem (line 30) | async function getHackerNewsItem(url: string) {
FILE: packages/francoisbest.com/src/lib/services/html-sanitizer.ts
function sanitizeHTML (line 7) | function sanitizeHTML(unsafeHTML: string) {
FILE: packages/francoisbest.com/src/lib/services/npm.ts
constant NPM_API_URL (line 4) | const NPM_API_URL = process.env.NPM_API_URL || 'https://api.npmjs.org'
type NpmPackageStatsData (line 6) | type NpmPackageStatsData = {
type RangeResponse (line 16) | type RangeResponse = {
function getLastNDays (line 23) | async function getLastNDays(
type PointResponse (line 38) | type PointResponse = {
function getAllTime (line 42) | async function getAllTime(pkg: string): Promise<number> {
function getVersions (line 58) | async function getVersions(pkg: string): Promise<Record<string, number>> {
function fetchNpmPackage (line 69) | async function fetchNpmPackage(
type BulkPointResponse (line 86) | type BulkPointResponse = Record<string, PointResponse>
type BulkRangeResponse (line 87) | type BulkRangeResponse = Record<string, RangeResponse>
function getAllTimeBulk (line 89) | async function getAllTimeBulk(
function getLastNDaysBulk (line 114) | async function getLastNDaysBulk(
function fetchAllNpmPackages (line 135) | async function fetchAllNpmPackages(
function get (line 200) | async function get<T = unknown>(
FILE: packages/francoisbest.com/src/ui/components/browser-window-frame.tsx
type BrowserWindowFrameProps (line 4) | type BrowserWindowFrameProps = React.ComponentProps<'figure'> & {
FILE: packages/francoisbest.com/src/ui/components/buttons/button.tsx
type ButtonSize (line 10) | type ButtonSize = 'xs' | 'sm' | 'md' | 'lg'
type ButtonVariant (line 11) | type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link'
type ButtonColor (line 12) | type ButtonColor = 'gray' | 'green' | 'blue' | 'red'
type ButtonProps (line 13) | type ButtonProps = React.ComponentProps<'button'> & {
function Button (line 25) | function Button({
type ButtonStyle (line 104) | type ButtonStyle = `${ButtonVariant}_${ButtonColor}`
FILE: packages/francoisbest.com/src/ui/components/buttons/icon-button.tsx
type IconButtonProps (line 5) | type IconButtonProps = Omit<
FILE: packages/francoisbest.com/src/ui/components/forms/inputs.tsx
type InputProps (line 5) | type InputProps = React.ComponentProps<'input'> & {}
type NumberInputProps (line 26) | type NumberInputProps = InputProps & {}
type TextareaProps (line 46) | type TextareaProps = React.ComponentProps<'textarea'> & {}
FILE: packages/francoisbest.com/src/ui/components/forms/radio.tsx
type RadioStateContext (line 5) | type RadioStateContext = {
type RadioGroupProps (line 10) | type RadioGroupProps = React.ComponentProps<'fieldset'> &
type RadioProps (line 38) | type RadioProps = React.ComponentProps<'input'> & {
FILE: packages/francoisbest.com/src/ui/components/forms/slider.tsx
type SliderProps (line 4) | type SliderProps = Omit<React.ComponentProps<'input'>, 'onChange'> & {
FILE: packages/francoisbest.com/src/ui/components/forms/structure.tsx
type FormControlContext (line 4) | type FormControlContext = {
type FormControlProps (line 8) | type FormControlProps = React.ComponentProps<'div'> &
function useFormControlContext (line 15) | function useFormControlContext() {
FILE: packages/francoisbest.com/src/ui/components/graphs/svg-curve-graph.tsx
type SvgCurveGraphProps (line 9) | type SvgCurveGraphProps = React.ComponentProps<'svg'> & {
type Point (line 18) | type Point = [number, number]
type CommandFn (line 20) | type CommandFn = (point: Point, index: number, array: Point[]) => string
constant FLOAT_DECIMALS (line 22) | const FLOAT_DECIMALS = 4
function formatGraphData (line 26) | function formatGraphData(
FILE: packages/francoisbest.com/src/ui/components/hashvatar.client.tsx
function useHash (line 8) | function useHash(
FILE: packages/francoisbest.com/src/ui/components/hashvatar.server.tsx
function sha256 (line 4) | async function sha256(message: string) {
type Variants (line 16) | type Variants = 'normal' | 'stagger' | 'spider' | 'flower' | 'gem'
type ColorMapper (line 18) | type ColorMapper = (args: {
type SHA256AvatarProps (line 25) | type SHA256AvatarProps = {
type Point (line 36) | interface Point {
function polarPoint (line 41) | function polarPoint(radius: number, angle: number): Point {
function moveTo (line 51) | function moveTo({ x, y }: Point) {
function lineTo (line 55) | function lineTo({ x, y }: Point) {
function arcTo (line 59) | function arcTo({ x, y }: Point, radius: number, invert = false) {
type GenerateSectionArgs (line 63) | interface GenerateSectionArgs {
function generateSection (line 82) | function generateSection({
function getHashSoul (line 133) | function getHashSoul(bytes: string[]) {
FILE: packages/francoisbest.com/src/ui/components/hire-me.tsx
constant AVAILABLE (line 4) | const AVAILABLE = undefined // eg: 'January 2026'
type HireMeProps (line 6) | type HireMeProps = Omit<NoteProps, 'status' | 'title' | 'children'>
FILE: packages/francoisbest.com/src/ui/components/local-time.tsx
type Props (line 7) | type Props = React.ComponentProps<'time'> & {
function LocalDate (line 12) | function LocalDate({ date, hydratedSuffix = null, ...props }: Props) {
function LocalTime (line 25) | function LocalTime({ date, hydratedSuffix = null, ...props }: Props) {
function LocalDateTime (line 38) | function LocalDateTime({
FILE: packages/francoisbest.com/src/ui/components/logo.tsx
type LogoProps (line 10) | type LogoProps = React.ComponentProps<'svg'> & {
FILE: packages/francoisbest.com/src/ui/components/note.tsx
type NoteStatus (line 10) | type NoteStatus = 'default' | 'info' | 'success' | 'warning' | 'error'
type NoteConfig (line 11) | type NoteConfig = {
type NoteProps (line 52) | type NoteProps = React.ComponentProps<'aside'> & {
FILE: packages/francoisbest.com/src/ui/components/tag.tsx
type StaticTagProps (line 7) | type StaticTagProps = React.ComponentProps<'span'> & {
type LinkedTagProps (line 10) | type LinkedTagProps = LinkProps & {
type TagsNavProps (line 27) | type TagsNavProps = React.ComponentProps<'nav'> & {
FILE: packages/francoisbest.com/src/ui/components/theme-controls.tsx
type ThemeControlsProps (line 11) | type ThemeControlsProps = Omit<IconButtonProps, 'aria-label' | 'icon'>
FILE: packages/francoisbest.com/src/ui/embeds/blog-post-embed.tsx
type BlogPostEmbedProps (line 6) | type BlogPostEmbedProps = Omit<EmbedFrameProps, 'Icon' | 'children'> & {
FILE: packages/francoisbest.com/src/ui/embeds/embed-frame.tsx
type EmbedFrameProps (line 3) | type EmbedFrameProps = React.ComponentProps<'section'> & {
FILE: packages/francoisbest.com/src/ui/embeds/github-repo.tsx
type GitHubRepoProps (line 13) | type GitHubRepoProps = React.ComponentProps<'section'> & {
type MetaListItemProps (line 88) | type MetaListItemProps = {
FILE: packages/francoisbest.com/src/ui/embeds/hacker-news.tsx
type HackerNewsCommentProps (line 5) | type HackerNewsCommentProps = {
function HackerNewsComment (line 9) | async function HackerNewsComment({ url }: HackerNewsCommentProps) {
FILE: packages/francoisbest.com/src/ui/embeds/npm-package.tsx
type NpmPackageProps (line 19) | type NpmPackageProps = Omit<EmbedFrameProps, 'Icon' | 'children'> & {
type VersionRolloutProps (line 204) | type VersionRolloutProps = {
FILE: packages/francoisbest.com/src/ui/embeds/spotify-album.tsx
type SpotifyAlbumProps (line 20) | type SpotifyAlbumProps = Partial<SpotifyData> & {
function SpotifyAlbum (line 24) | function SpotifyAlbum({ url, ...props }: SpotifyAlbumProps) {
type EmptyAlbumViewProps (line 37) | type EmptyAlbumViewProps = {
FILE: packages/francoisbest.com/src/ui/embeds/spotify-artist.tsx
type SpotifyArtistProps (line 17) | type SpotifyArtistProps = Partial<SpotifyData> & {
function SpotifyArtist (line 21) | function SpotifyArtist({ url, ...props }: SpotifyArtistProps) {
FILE: packages/francoisbest.com/src/ui/embeds/spotify-loader.tsx
type SpotifyData (line 18) | type SpotifyData = z.infer<typeof spotifyDataSchema>
type SpotifyLoaderProps (line 20) | type SpotifyLoaderProps<OtherProps> = OtherProps & {
function SpotifyLoader (line 27) | async function SpotifyLoader<OtherProps>({
function loadSpotifyData (line 46) | function loadSpotifyData(url: string): Promise<SpotifyData> {
FILE: packages/francoisbest.com/src/ui/format.ts
constant LOCALE (line 1) | const LOCALE = 'en-GB'
function formatDate (line 6) | function formatDate(
function formatTime (line 23) | function formatTime(date: Date | string | number) {
function formatNumber (line 33) | function formatNumber(value: number) {
function formatStatNumber (line 37) | function formatStatNumber(
FILE: packages/francoisbest.com/src/ui/hooks/useClipboard.ts
function useClipboard (line 3) | function useClipboard(value: string, timeout = 1500) {
FILE: packages/francoisbest.com/src/ui/hooks/useHydration.ts
function useHydration (line 5) | function useHydration() {
FILE: packages/francoisbest.com/src/ui/layouts/nav-link.tsx
type NavLinkProps (line 7) | type NavLinkProps = LinkProps & {
Condensed preview — 177 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (748K chars).
[
{
"path": ".beans/fbst-0ilj--step-6-dark-mode-next-themes.md",
"chars": 1655,
"preview": "---\n# fbst-0ilj\ntitle: 'Step 6: Dark mode → next-themes'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-"
},
{
"path": ".beans/fbst-2evh--step-1-tooling-foundation-turbo-v2-oxfmt-oxlint-vi.md",
"chars": 1856,
"preview": "---\n# fbst-2evh\ntitle: 'Step 1: Tooling foundation (Turbo v2, oxfmt, oxlint, Vitest 4)'\nstatus: completed\ntype: task\npri"
},
{
"path": ".beans/fbst-5495--step-3-dead-code-small-dependency-cleanup.md",
"chars": 1292,
"preview": "---\n# fbst-5495\ntitle: 'Step 3: Dead code & small dependency cleanup'\nstatus: completed\ntype: task\npriority: normal\ncrea"
},
{
"path": ".beans/fbst-9pj9--modernise-francoisbestcom-codebase.md",
"chars": 10050,
"preview": "---\n# fbst-9pj9\ntitle: Modernise francoisbest.com codebase\nstatus: completed\ntype: feature\npriority: high\ncreated_at: 20"
},
{
"path": ".beans/fbst-9w5o--step-7-mdx-pipeline-fumadocs-mdx.md",
"chars": 3820,
"preview": "---\n# fbst-9w5o\ntitle: 'Step 7: MDX pipeline → fumadocs-mdx'\nstatus: completed\ntype: task\npriority: high\ncreated_at: 202"
},
{
"path": ".beans/fbst-abdk--step-8-non-blog-pages-tsx.md",
"chars": 1228,
"preview": "---\n# fbst-abdk\ntitle: 'Step 8: Non-blog pages → TSX'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-"
},
{
"path": ".beans/fbst-lzix--step-10-knip-final-cleanup.md",
"chars": 1236,
"preview": "---\n# fbst-lzix\ntitle: 'Step 10: knip + final cleanup'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04"
},
{
"path": ".beans/fbst-m7lu--fix-broken-links-in-blog-posts-and-sitemap-page.md",
"chars": 2387,
"preview": "---\n# fbst-m7lu\ntitle: Fix broken links in blog posts and sitemap page\nstatus: completed\ntype: bug\npriority: normal\ncrea"
},
{
"path": ".beans/fbst-nv8l--restore-hireme-cta-and-edit-links-on-static-pages.md",
"chars": 1736,
"preview": "---\n# fbst-nv8l\ntitle: Restore HireMe CTA and edit links on static pages\nstatus: completed\ntype: bug\npriority: high\ncrea"
},
{
"path": ".beans/fbst-p7fj--step-9-dayjs-temporal.md",
"chars": 1781,
"preview": "---\n# fbst-p7fj\ntitle: 'Step 9: dayjs → Temporal'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T1"
},
{
"path": ".beans/fbst-qj1o--expand-sitemap-to-include-all-public-pages.md",
"chars": 1730,
"preview": "---\n# fbst-qj1o\ntitle: Expand sitemap to include all public pages\nstatus: completed\ntype: bug\npriority: high\ncreated_at:"
},
{
"path": ".beans/fbst-qp1g--step-4-nextjs-16-upgrade.md",
"chars": 1933,
"preview": "---\n# fbst-qp1g\ntitle: 'Step 4: Next.js 16 upgrade'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01"
},
{
"path": ".beans/fbst-tzpj--fix-qa-regressions-in-modernisation-pr.md",
"chars": 8590,
"preview": "---\n# fbst-tzpj\ntitle: Fix QA regressions in modernisation PR\nstatus: completed\ntype: feature\npriority: normal\ncreated_a"
},
{
"path": ".beans/fbst-u6tb--fix-reading-time-computation-and-display.md",
"chars": 1724,
"preview": "---\n# fbst-u6tb\ntitle: Fix reading time computation and display\nstatus: completed\ntype: bug\npriority: high\ncreated_at: 2"
},
{
"path": ".beans/fbst-ugwm--step-5-built-in-sitemap-robots.md",
"chars": 1391,
"preview": "---\n# fbst-ugwm\ntitle: 'Step 5: Built-in sitemap & robots'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 202"
},
{
"path": ".beans/fbst-xhgq--step-2-typescript-60-upgrade.md",
"chars": 1447,
"preview": "---\n# fbst-xhgq\ntitle: 'Step 2: TypeScript 6.0 upgrade'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-0"
},
{
"path": ".beans.yml",
"chars": 97,
"preview": "beans:\n path: .beans\n prefix: fbst-\n id_length: 4\n default_status: todo\n default_type: task\n"
},
{
"path": ".github/FUNDING.yml",
"chars": 147,
"preview": "# These are supported funding model platforms\ngithub: [franky47]\nliberapay: francoisbest\ncustom: ['https://paypal.me/fra"
},
{
"path": ".gitignore",
"chars": 401,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules/\n\n# t"
},
{
"path": ".node-version",
"chars": 4,
"preview": "v18\n"
},
{
"path": ".oxfmtrc.json",
"chars": 216,
"preview": "{\n \"$schema\": \"./node_modules/oxfmt/configuration_schema.json\",\n \"printWidth\": 80,\n \"semi\": false,\n \"singleQuote\": t"
},
{
"path": ".vscode/launch.json",
"chars": 469,
"preview": "{\n \"version\": \"0.2.0\",\n \"resolveSourceMapLocations\": [\"${workspaceFolder}/**\", \"!**/node_modules/**\"],\n \"configuratio"
},
{
"path": "LICENSE.txt",
"chars": 258,
"preview": "1. You are free to use this code as inspiration.\n2. Please do not copy it directly.\n3. Crediting the author is appreciat"
},
{
"path": "README.md",
"chars": 662,
"preview": "This is the source code for my personal website and blog, hosted at <https://francoisbest.com>\n\nBuilt with:\n\n- [Next.js]"
},
{
"path": "docs/authoring.md",
"chars": 873,
"preview": "Objective: reducing the amount of cognitive load to author blog posts.\n\nThings that bring friction:\n\n- Having to think a"
},
{
"path": "docs/blog-engine.md",
"chars": 1337,
"preview": "# Blog engine\n\nBlog posts are local static MDX pages using the app router page convention name\nof `post-url-slug/page.md"
},
{
"path": "package.json",
"chars": 763,
"preview": "{\n \"name\": \"francoisbest.com\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"description\": \"My personal website\",\n \"auth"
},
{
"path": "packages/francoisbest.com/.npmrc",
"chars": 29,
"preview": "enable-pre-post-scripts=true\n"
},
{
"path": "packages/francoisbest.com/.oxlintrc.json",
"chars": 215,
"preview": "{\n \"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n \"plugins\": [\"typescript\", \"unicorn\", \"oxc\"],\n \"cate"
},
{
"path": "packages/francoisbest.com/README.md",
"chars": 1370,
"preview": "This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js"
},
{
"path": "packages/francoisbest.com/content/blog/2019/how-to-store-e2ee-keys-in-the-browser/index.mdx",
"chars": 5394,
"preview": "---\ntitle: How To Store End-to-End Encryption Keys In The Browser\ndescription: \"End-to-end encrypted applications use cr"
},
{
"path": "packages/francoisbest.com/content/blog/2019/strava-auth-cli-in-rust/index.mdx",
"chars": 19440,
"preview": "---\ntitle: Building A Strava Authentication CLI In Rust\ndescription: A first look at how to implement an OAuth authentic"
},
{
"path": "packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/index.mdx",
"chars": 4091,
"preview": "---\ntitle: Dark Mode For Excalidraw\ndescription: How to give a dark twist to Excalidraw diagrams with CSS filters.\npubli"
},
{
"path": "packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/status-text.tsx",
"chars": 1402,
"preview": "export default function StatusText(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/20"
},
{
"path": "packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/venn.tsx",
"chars": 40263,
"preview": "export default function VennDiagram(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/2"
},
{
"path": "packages/francoisbest.com/content/blog/2020/mobile-device-frames-for-excalidraw/index.mdx",
"chars": 5108,
"preview": "---\ntitle: Mobile Device Frames For Excalidraw\ndescription: \"Building mobile UI mockups in Excalidraw is a lot of fun, b"
},
{
"path": "packages/francoisbest.com/content/blog/2020/mobile-device-frames-for-excalidraw/mobile-mockup.tsx",
"chars": 52392,
"preview": "export default function MobileMockup(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/"
},
{
"path": "packages/francoisbest.com/content/blog/2020/password-reset-for-e2ee-apps/index.mdx",
"chars": 6937,
"preview": "---\ntitle: Password Reset for End-to-End Encrypted Applications\ndescription: \"We forget passwords. Usually it's OK, beca"
},
{
"path": "packages/francoisbest.com/content/blog/2020/the-security-of-github-actions/index.mdx",
"chars": 11831,
"preview": "---\ntitle: The Security of GitHub Actions\ndescription: \"GitHub Actions are a great way to build powerful customised CI/C"
},
{
"path": "packages/francoisbest.com/content/blog/2021/cargo-docker-mtime/index.mdx",
"chars": 5088,
"preview": "---\ntitle: \"Cargo, Docker and mtime\"\ndescription: The perils of premature optimisation in detecting modified source file"
},
{
"path": "packages/francoisbest.com/content/blog/2021/hashvatars/index.mdx",
"chars": 16639,
"preview": "---\ntitle: \"Representing SHA-256 Hashes As Avatars\"\ndescription: \"How to turn 256 bits of entropy into a beautiful, avat"
},
{
"path": "packages/francoisbest.com/content/blog/2023/displaying-local-times-in-nextjs/index.mdx",
"chars": 5931,
"preview": "---\ntitle: Displaying Local Times in Next.js\ndescription: Making time accessible by dealing with React SSR hydration mis"
},
{
"path": "packages/francoisbest.com/content/blog/2023/displaying-the-right-vercel-deployment-urls-in-nextjs/index.mdx",
"chars": 5924,
"preview": "---\ntitle: Displaying the right Vercel deployment URLs in Next.js\ndescription: \"A TIL about caching, Git branch manageme"
},
{
"path": "packages/francoisbest.com/content/blog/2023/dotenv-is-dead/index.mdx",
"chars": 3858,
"preview": "---\ntitle: Dotenv is dead\ndescription: Long live type-safe environment variable management in Node.js\npublicationDate: '"
},
{
"path": "packages/francoisbest.com/content/blog/2023/npm-download-stats-are-down/index.mdx",
"chars": 3494,
"preview": "---\ntitle: NPM download stats are down\ndescription: \"And people are drawing the funniest conclusions.\"\npublicationDate: "
},
{
"path": "packages/francoisbest.com/content/blog/2023/publish-a-json-schema/index.mdx",
"chars": 3592,
"preview": "---\ntitle: Publish a JSON Schema\ndescription: \"Improving DX in editors is only half the story.\"\npublicationDate: '2023-1"
},
{
"path": "packages/francoisbest.com/content/blog/2023/reading-files-on-vercel-during-nextjs-isr/index.mdx",
"chars": 3212,
"preview": "---\ntitle: Reading files on Vercel during Next.js ISR\ndescription: \"A little hack to let the Next.js file tracer pick up"
},
{
"path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/demo.tsx",
"chars": 1123,
"preview": "import { gitHubUrl, resolve } from 'lib/paths'\nimport { Suspense } from 'react'\nimport { PiHandTap } from 'react-icons/p"
},
{
"path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/greetings.tsx",
"chars": 683,
"preview": "'use client'\n\nimport { useQueryState } from 'nuqs'\nimport { Input } from 'ui/components/forms/inputs'\nimport { FormContr"
},
{
"path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/index.mdx",
"chars": 10284,
"preview": "---\ntitle: Storing React state in the URL with Next.js\ndescription: \"A peek under the hood of the next-usequerystate 1.8"
},
{
"path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/query-spy.tsx",
"chars": 1524,
"preview": "'use client'\n\nimport Link from 'next/link'\nimport { useSearchParams } from 'next/navigation'\nimport React from 'react'\ni"
},
{
"path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/update-queue.tsx",
"chars": 12900,
"preview": "export default function UpdateQueue(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/2"
},
{
"path": "packages/francoisbest.com/content/blog/2023/testing-against-every-nextjs-canary-release/index.mdx",
"chars": 8986,
"preview": "---\ntitle: Testing against every Next.js canary release\ndescription: How to run a GitHub Actions workflow when a new pre"
},
{
"path": "packages/francoisbest.com/content/blog/2023/testing-against-every-nextjs-canary-release/windowing.tsx",
"chars": 31842,
"preview": "export default function Windowing(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/200"
},
{
"path": "packages/francoisbest.com/knip.json",
"chars": 221,
"preview": "{\n \"$schema\": \"https://unpkg.com/knip@6/schema.json\",\n \"ignore\": [\"content/**\"],\n \"ignoreDependencies\": [\"sharp\", \"@t"
},
{
"path": "packages/francoisbest.com/next.config.ts",
"chars": 2399,
"preview": "import type { NextConfig } from 'next'\nimport configureBundleAnalyzer from 'next-bundle-analyzer'\nimport { createMDX } f"
},
{
"path": "packages/francoisbest.com/package.json",
"chars": 1896,
"preview": "{\n \"name\": \"francoisbest.com\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \""
},
{
"path": "packages/francoisbest.com/public/.well-known/atproto-did",
"chars": 33,
"preview": "did:plc:rfoxp4hc5fgthjfaaigyw3c2\n"
},
{
"path": "packages/francoisbest.com/public/.well-known/keybase.txt",
"chars": 1995,
"preview": "https://keybase.io/franky47\n--------------------------------------------------------------------\n\nI hereby claim:\n\n * I"
},
{
"path": "packages/francoisbest.com/public/.well-known/security.txt",
"chars": 220,
"preview": "Contact: https://keybase.io/franky47\nEncryption: https://keybase.io/franky47/pgp_keys.asc\nPreferred-Languages: en, fr\nCa"
},
{
"path": "packages/francoisbest.com/public/favicons/browserconfig.xml",
"chars": 285,
"preview": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig><msapplication><tile><square70x70logo src=\"./ms-icon-70x70.png\"/><"
},
{
"path": "packages/francoisbest.com/public/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidraw",
"chars": 35440,
"preview": "{\n \"type\": \"excalidraw\",\n \"version\": 2,\n \"source\": \"https://excalidraw.com\",\n \"elements\": [\n {\n \"type\": \"rec"
},
{
"path": "packages/francoisbest.com/public/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidrawlib",
"chars": 34249,
"preview": "{\n \"type\": \"excalidrawlib\",\n \"version\": 1,\n \"library\": [\n [\n {\n \"type\": \"rectangle\",\n \"version\""
},
{
"path": "packages/francoisbest.com/public/manifest.webmanifest",
"chars": 1053,
"preview": "{\n \"name\": \"François Best\",\n \"short_name\": \"François Best\",\n \"description\": \"Freelance developer\",\n \"start_url\": \"/\""
},
{
"path": "packages/francoisbest.com/scripts/isr.mjs",
"chars": 157,
"preview": "const url = tag =>\n `http://localhost:3000/api/isr?token=${process.env.ISR_TOKEN}&tag=${tag}`\n\nawait Promise.all([fetch"
},
{
"path": "packages/francoisbest.com/source.config.ts",
"chars": 2459,
"preview": "import {\n defineCollections,\n defineConfig,\n applyMdxPreset,\n frontmatterSchema\n} from 'fumadocs-mdx/config'\nimport "
},
{
"path": "packages/francoisbest.com/src/app/(dashboards)/layout.tsx",
"chars": 201,
"preview": "import { Footer } from 'ui/layouts/footer'\n\nexport default function DashboardLayout({\n children\n}: {\n children: React."
},
{
"path": "packages/francoisbest.com/src/app/(dashboards)/sandbox/.gitignore",
"chars": 14,
"preview": "*\n!.gitignore\n"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/hashvatar/demo.tsx",
"chars": 2156,
"preview": "'use client'\n\nimport React from 'react'\nimport { Input } from 'ui/components/forms/inputs'\nimport { useHash } from 'ui/c"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/hashvatar/page.tsx",
"chars": 361,
"preview": "import { BlogPostEmbed } from 'ui/embeds/blog-post-embed'\nimport HashvatarDemoPage from './demo'\n\nexport const metadata "
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/page.tsx",
"chars": 963,
"preview": "import { gitHubUrl, resolve } from 'lib/paths'\nimport { Note } from 'ui/components/note'\nimport { HorcruxRecompose } fro"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/recompose.tsx",
"chars": 3673,
"preview": "'use client'\n\nimport React from 'react'\nimport { FiPlusCircle, FiTrash2 } from 'react-icons/fi'\nimport { Button } from '"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/split.tsx",
"chars": 5129,
"preview": "'use client'\n\nimport type { Encoding } from '@47ng/codec'\nimport React from 'react'\nimport { FiCheck, FiCopy } from 'rea"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/tss.ts",
"chars": 1440,
"preview": "import { Encoding, decoders, detectEncoding, encoders, utf8 } from '@47ng/codec'\nimport { combine, split } from '@stable"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/layout.tsx",
"chars": 314,
"preview": "import { Footer } from 'ui/layouts/footer'\nimport { NavHeader } from 'ui/layouts/nav-header'\n\nexport default function Pa"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/components/dovetail-svg.tsx",
"chars": 15822,
"preview": "import React from 'react'\nimport { DovetailData, getDovetailPath } from './dovetails'\n\ntype DovetailSVGProps = React.Com"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/components/dovetails.ts",
"chars": 1684,
"preview": "export type DovetailParameters = {\n jointWidth: number\n numTails: number\n pinsBoardThickness: number\n angleRatio: nu"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/page.client.tsx",
"chars": 14497,
"preview": "'use client'\n\nimport { parseAsFloat, parseAsInteger, useQueryState } from 'nuqs'\nimport { ChangeEvent } from 'react'\nimp"
},
{
"path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/page.tsx",
"chars": 632,
"preview": "import type { Metadata } from 'next'\nimport { Suspense } from 'react'\nimport DovetailDesigner from './page.client'\n\nexpo"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/about-me.tsx",
"chars": 1788,
"preview": "import Image from 'next/image'\nimport { BrowserWindowFrame } from 'ui/components/browser-window-frame'\nimport { WideCont"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/career.tsx",
"chars": 6103,
"preview": "import { Logo } from 'ui/components/logo'\nimport { Note } from 'ui/components/note'\nimport { Client, Experience } from '"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/experience.tsx",
"chars": 2184,
"preview": "import React from 'react'\nimport { StaticTag } from 'ui/components/tag'\n\ntype ExperienceProps = {\n title: string\n url:"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/arturia.tsx",
"chars": 1578,
"preview": "export default function ArturiaLogo(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/2"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/gael.tsx",
"chars": 8065,
"preview": "export default function GaelLogo(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/2000"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/heron.tsx",
"chars": 1081,
"preview": "export default function HeronLogo(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/200"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/lacquereur.tsx",
"chars": 978,
"preview": "export default function AcquereurLogo(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/marianne.tsx",
"chars": 51649,
"preview": "export default function MarianneLogo(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/pulsar.tsx",
"chars": 2721,
"preview": "export default function PulsarLogo(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/20"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/slate-digital.tsx",
"chars": 1554,
"preview": "export default function SlateLogo(props: React.ComponentProps<'svg'>) {\n return (\n <svg xmlns=\"http://www.w3.org/200"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/featured-posts.tsx",
"chars": 555,
"preview": "import Link from 'next/link'\nimport { BlogPostEmbed } from '../../../ui/embeds/blog-post-embed'\n\nconst featuredPosts = ["
},
{
"path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/music.tsx",
"chars": 2096,
"preview": "import { SpotifyAlbum, SpotifyAlbumGrid } from 'ui/embeds/spotify-album'\nimport { SpotifyArtist, SpotifyArtistGrid } fro"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/business-card/page.tsx",
"chars": 1614,
"preview": "import { decryptPhoneNumber, vcard } from 'app/vcard/vcard'\nimport { cookies } from 'next/headers'\nimport { SearchParams"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/business-card/qrcode.tsx",
"chars": 730,
"preview": "import qr from 'qrcode'\nimport { twMerge } from 'tailwind-merge'\n\nexport type QRCodeProps = React.ComponentProps<'figure"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/layout.tsx",
"chars": 410,
"preview": "import { Footer } from 'ui/layouts/footer'\nimport { NavHeader } from 'ui/layouts/nav-header'\n\nexport default function Pa"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/links/page.tsx",
"chars": 991,
"preview": "import { HireMe } from 'ui/components/hire-me'\n\nexport default function Page() {\n return (\n <>\n <h1>Links</h1>\n"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/music/page.tsx",
"chars": 6036,
"preview": "import { Metadata } from 'next'\nimport { FiVolume2 } from 'react-icons/fi'\nimport { HireMe } from 'ui/components/hire-me"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/open-source/page.tsx",
"chars": 5676,
"preview": "import { fetchAllNpmPackages, type NpmPackageStatsData } from 'lib/services/npm'\nimport { Metadata } from 'next'\nimport "
},
{
"path": "packages/francoisbest.com/src/app/(pages)/page.tsx",
"chars": 2388,
"preview": "import Link from 'next/link'\nimport { FiMap } from 'react-icons/fi'\nimport { Note } from 'ui/components/note'\nimport { H"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/[...slug]/page.tsx",
"chars": 3818,
"preview": "import { getAllPosts, getPost } from 'lib/blog'\nimport { computeReadingTime } from 'lib/blog/reading-time'\nimport { getM"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/[year]/page.tsx",
"chars": 2182,
"preview": "import { getAllPosts } from 'lib/blog'\nimport Link from 'next/link'\nimport { FiX } from 'react-icons/fi'\nimport { Button"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/components/blog-post-preview.tsx",
"chars": 1245,
"preview": "import { Post } from 'lib/blog'\nimport Link from 'next/link'\nimport { formatDate } from 'ui/format'\nimport { TagsNav } f"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/components/blog-roll-header.tsx",
"chars": 781,
"preview": "import { FiRss } from 'react-icons/fi'\n\ntype BlogRollHeaderProps = {\n title: React.ReactNode\n description?: React.Reac"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/feed/[format]/route.ts",
"chars": 3038,
"preview": "import { Feed } from 'feed'\nimport { getAllPosts } from 'lib/blog'\nimport { url } from 'lib/paths'\nimport { NextResponse"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/og/[...slug]/route.ts",
"chars": 1073,
"preview": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport { getAllPosts, type OgImageExtension } from 'lib/b"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/page.tsx",
"chars": 645,
"preview": "import { getAllPosts } from 'lib/blog'\nimport { BlogPostPreview } from './components/blog-post-preview'\nimport { BlogRol"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/tags/[tag]/page.tsx",
"chars": 1732,
"preview": "import { getAllPosts } from 'lib/blog'\nimport Link from 'next/link'\nimport { FiX } from 'react-icons/fi'\nimport { Button"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/posts/tags/page.tsx",
"chars": 1082,
"preview": "import { getAllPosts } from 'lib/blog'\nimport { LinkedTag } from 'ui/components/tag'\nimport { BlogRollHeader } from '../"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/public-keys/page.tsx",
"chars": 3987,
"preview": "import { Metadata } from 'next'\nimport { HireMe } from 'ui/components/hire-me'\nimport { Note } from 'ui/components/note'"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/safari-speedrun/page.tsx",
"chars": 449,
"preview": "import { Metadata } from 'next'\nimport { Suspense } from 'react'\nimport { Runner } from './runner'\n\nexport const metadat"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/safari-speedrun/runner.tsx",
"chars": 1787,
"preview": "'use client'\n\nimport { parseAsInteger, useQueryState } from 'nuqs'\nimport React from 'react'\n\nimport { Button } from 'ui"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/sitemap/page.tsx",
"chars": 2071,
"preview": "import { Metadata } from 'next'\nimport Link from 'next/link'\nimport { HireMe } from 'ui/components/hire-me'\nimport { Not"
},
{
"path": "packages/francoisbest.com/src/app/(pages)/uses/page.tsx",
"chars": 2250,
"preview": "import { Metadata } from 'next'\nimport { HireMe } from 'ui/components/hire-me'\n\nexport const metadata: Metadata = {\n ti"
},
{
"path": "packages/francoisbest.com/src/app/.well-known/webfinger/route.ts",
"chars": 892,
"preview": "import { NextResponse } from 'next/server'\n\nexport async function GET() {\n if (process.env.VERCEL_ENV !== 'production')"
},
{
"path": "packages/francoisbest.com/src/app/api/isr/route.ts",
"chars": 669,
"preview": "import { revalidateTag } from 'next/cache'\nimport { NextRequest, NextResponse } from 'next/server'\n\nconst ACCEPTED_TAGS "
},
{
"path": "packages/francoisbest.com/src/app/global.css",
"chars": 6264,
"preview": "@import 'tailwindcss';\n@plugin \"@tailwindcss/typography\";\n\n@custom-variant dark (&:where(.dark, .dark *));\n\n@theme {\n /"
},
{
"path": "packages/francoisbest.com/src/app/layout.tsx",
"chars": 2253,
"preview": "import { url } from 'lib/paths'\nimport seo from 'lib/seo.json'\nimport { chiffreConfig } from 'lib/services/chiffre'\nimpo"
},
{
"path": "packages/francoisbest.com/src/app/not-found.tsx",
"chars": 777,
"preview": "import Link from 'next/link'\nimport PageLayout from './(not-prose)/layout'\n\nexport default function NotFound() {\n retur"
},
{
"path": "packages/francoisbest.com/src/app/robots.ts",
"chars": 610,
"preview": "import type { MetadataRoute } from 'next'\nimport { url } from 'lib/paths'\n\nconst isPreviewDeployment = process.env.VERCE"
},
{
"path": "packages/francoisbest.com/src/app/sitemap.ts",
"chars": 1727,
"preview": "import type { MetadataRoute } from 'next'\nimport { getAllPosts } from 'lib/blog'\nimport { url } from 'lib/paths'\n\nexport"
},
{
"path": "packages/francoisbest.com/src/app/vcard/route.ts",
"chars": 691,
"preview": "import type { NextRequest } from 'next/server'\nimport { decryptPhoneNumber, vcard } from './vcard'\n\nexport const dynamic"
},
{
"path": "packages/francoisbest.com/src/app/vcard/vcard.ts",
"chars": 1508,
"preview": "import { hex, utf8 } from '@47ng/codec'\nimport nacl from 'tweetnacl'\n\n// export function encryptPhoneNumber() {\n// con"
},
{
"path": "packages/francoisbest.com/src/css.d.ts",
"chars": 23,
"preview": "declare module '*.css'\n"
},
{
"path": "packages/francoisbest.com/src/lib/blog/defs.ts",
"chars": 112,
"preview": "export type PostMetadata = {\n title: string\n description: string\n publicationDate?: Date\n tags?: string[]\n}\n"
},
{
"path": "packages/francoisbest.com/src/lib/blog/engine.ts",
"chars": 1947,
"preview": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport 'server-only'\nimport { blogSource } from 'lib/sour"
},
{
"path": "packages/francoisbest.com/src/lib/blog/index.ts",
"chars": 48,
"preview": "export * from './defs'\nexport * from './engine'\n"
},
{
"path": "packages/francoisbest.com/src/lib/blog/reading-time.test.ts",
"chars": 1814,
"preview": "import { describe, expect, test } from 'vitest'\nimport { computeReadingTime } from './reading-time'\n\ndescribe('reading t"
},
{
"path": "packages/francoisbest.com/src/lib/blog/reading-time.ts",
"chars": 509,
"preview": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport readingTime from 'reading-time'\n\nconst CONTENT_DIR"
},
{
"path": "packages/francoisbest.com/src/lib/env.ts",
"chars": 175,
"preview": "import { z } from 'zod'\n\nconst envSchema = z.object({\n SPOTIFY_CLIENT_ID: z.string(),\n SPOTIFY_CLIENT_SECRET: z.string"
},
{
"path": "packages/francoisbest.com/src/lib/mdx-components.tsx",
"chars": 776,
"preview": "import type { MDXComponents } from 'mdx/types'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { Note"
},
{
"path": "packages/francoisbest.com/src/lib/paths.test.ts",
"chars": 396,
"preview": "import { describe, expect, test } from 'vitest'\nimport { url } from './paths'\n\ndescribe('paths', () => {\n test('url gen"
},
{
"path": "packages/francoisbest.com/src/lib/paths.ts",
"chars": 1081,
"preview": "import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst __dirname = path.dirname(fileURLToPath(impo"
},
{
"path": "packages/francoisbest.com/src/lib/seo.json",
"chars": 649,
"preview": "{\n \"title\": {\n \"default\": \"François Best\",\n \"template\": \"%s | François Best\"\n },\n \"description\": \"Freelance web"
},
{
"path": "packages/francoisbest.com/src/lib/services/chiffre.ts",
"chars": 433,
"preview": "type ChiffreConfig =\n | {\n enabled: true\n projectId: string\n publicKey: string\n }\n | {\n enabled"
},
{
"path": "packages/francoisbest.com/src/lib/services/github.ts",
"chars": 2436,
"preview": "import 'server-only'\nimport { z } from 'zod'\n\nexport type GitHubRepositoryData = {\n url: string\n avatarUrl: string\n t"
},
{
"path": "packages/francoisbest.com/src/lib/services/hacker-news.ts",
"chars": 1204,
"preview": "import { z } from 'zod'\nimport { sanitizeHTML } from './html-sanitizer'\n\nconst hackerNewsId = z.number().int().positive("
},
{
"path": "packages/francoisbest.com/src/lib/services/html-sanitizer.ts",
"chars": 225,
"preview": "import DOMPurify from 'dompurify'\nimport { JSDOM } from 'jsdom'\n\nconst window = new JSDOM('').window\nconst purify = DOMP"
},
{
"path": "packages/francoisbest.com/src/lib/services/npm.ts",
"chars": 7322,
"preview": "import { Temporal } from '@js-temporal/polyfill'\nimport 'server-only'\n\nconst NPM_API_URL = process.env.NPM_API_URL || 'h"
},
{
"path": "packages/francoisbest.com/src/lib/source.ts",
"chars": 248,
"preview": "import { blog } from 'collections/server'\nimport { loader } from 'fumadocs-core/source'\nimport { toFumadocsSource } from"
},
{
"path": "packages/francoisbest.com/src/ui/components/browser-window-frame.tsx",
"chars": 1264,
"preview": "import { twMerge } from 'tailwind-merge'\nimport { ThemeControls } from './theme-controls'\n\nexport type BrowserWindowFram"
},
{
"path": "packages/francoisbest.com/src/ui/components/buttons/button-spinner.tsx",
"chars": 572,
"preview": "// Source:\n// https://tailwind-elements.com/docs/standard/components/spinners/\n\nimport React from 'react'\nimport { twMer"
},
{
"path": "packages/francoisbest.com/src/ui/components/buttons/button.tsx",
"chars": 6101,
"preview": "// Inspired from Chakra-UI's buttons.\n// Source:\n// https://github.com/chakra-ui/chakra-ui/blob/main/packages/components"
},
{
"path": "packages/francoisbest.com/src/ui/components/buttons/icon-button.tsx",
"chars": 628,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\nimport { Button, ButtonProps } from './button'\n\nexpor"
},
{
"path": "packages/francoisbest.com/src/ui/components/forms/inputs.tsx",
"chars": 1428,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\nimport { useFormControlContext } from './structure'\n\n"
},
{
"path": "packages/francoisbest.com/src/ui/components/forms/radio.tsx",
"chars": 1561,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\nimport { useFormControlContext } from './structure'\n\n"
},
{
"path": "packages/francoisbest.com/src/ui/components/forms/slider.tsx",
"chars": 545,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport type SliderProps = Omit<React.ComponentProps<"
},
{
"path": "packages/francoisbest.com/src/ui/components/forms/structure.tsx",
"chars": 1111,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\ntype FormControlContext = {\n name?: string\n}\n\nexpor"
},
{
"path": "packages/francoisbest.com/src/ui/components/graphs/svg-curve-graph.tsx",
"chars": 7972,
"preview": "import { Temporal } from '@js-temporal/polyfill'\nimport React from 'react'\nimport { twJoin, twMerge } from 'tailwind-mer"
},
{
"path": "packages/francoisbest.com/src/ui/components/hashvatar.client.tsx",
"chars": 2166,
"preview": "'use client'\n\nimport React from 'react'\nimport { Input } from './forms/inputs'\nimport { Slider } from './forms/slider'\ni"
},
{
"path": "packages/francoisbest.com/src/ui/components/hashvatar.server.tsx",
"chars": 6405,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport async function sha256(message: string) {\n //"
},
{
"path": "packages/francoisbest.com/src/ui/components/hire-me.tsx",
"chars": 1414,
"preview": "import { FiMail } from 'react-icons/fi'\nimport { Note, NoteProps } from 'ui/components/note'\n\nconst AVAILABLE = undefine"
},
{
"path": "packages/francoisbest.com/src/ui/components/local-time.tsx",
"chars": 1498,
"preview": "'use client'\n\nimport { Suspense } from 'react'\nimport { formatDate, formatTime } from 'ui/format'\nimport { useHydration "
},
{
"path": "packages/francoisbest.com/src/ui/components/logo.tsx",
"chars": 3138,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nconst sizes = {\n 6: 'w-6 h-6',\n 8: 'w-8 h-8',\n 16"
},
{
"path": "packages/francoisbest.com/src/ui/components/note.tsx",
"chars": 2430,
"preview": "import {\n FiAlertCircle,\n FiAlertTriangle,\n FiCheckCircle,\n FiInfo,\n FiPaperclip\n} from 'react-icons/fi'\nimport { t"
},
{
"path": "packages/francoisbest.com/src/ui/components/stat.tsx",
"chars": 748,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport const Stat: React.FC<React.ComponentProps<'dl"
},
{
"path": "packages/francoisbest.com/src/ui/components/tag.tsx",
"chars": 1100,
"preview": "import Link, { LinkProps } from 'next/link'\nimport { twMerge } from 'tailwind-merge'\n\nconst tagClassName =\n 'flex h-5 i"
},
{
"path": "packages/francoisbest.com/src/ui/components/theme-controls.tsx",
"chars": 1239,
"preview": "'use client'\n\nimport { useTheme } from 'next-themes'\nimport React from 'react'\nimport { FiMoon, FiSun } from 'react-icon"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/blog-post-embed.tsx",
"chars": 634,
"preview": "import { getPost } from 'lib/blog'\nimport { FiBookmark } from 'react-icons/fi'\nimport { EmbedFrame, EmbedFrameProps } fr"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/embed-frame.tsx",
"chars": 1198,
"preview": "import { twMerge } from 'tailwind-merge'\n\nexport type EmbedFrameProps = React.ComponentProps<'section'> & {\n Icon: Reac"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/github-repo.tsx",
"chars": 2653,
"preview": "import { fetchRepository } from 'lib/services/github'\nimport {\n FiAlertCircle,\n FiFileText,\n FiGitPullRequest,\n FiGi"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/hacker-news.tsx",
"chars": 1645,
"preview": "import { SiYcombinator } from '@icons-pack/react-simple-icons'\nimport { getHackerNewsItem } from 'lib/services/hacker-ne"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/npm-package.tsx",
"chars": 8293,
"preview": "import { fetchRepository } from 'lib/services/github'\nimport { fetchNpmPackage, type NpmPackageStatsData } from 'lib/ser"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/spotify-album.tsx",
"chars": 2419,
"preview": "import Image from 'next/image'\nimport React, { Suspense } from 'react'\nimport ReactDOM from 'react-dom'\nimport { WideCon"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/spotify-artist.tsx",
"chars": 1757,
"preview": "import Image from 'next/image'\nimport { Suspense } from 'react'\nimport ReactDOM from 'react-dom'\nimport { SpotifyData, S"
},
{
"path": "packages/francoisbest.com/src/ui/embeds/spotify-loader.tsx",
"chars": 1766,
"preview": "import { SpotifyApi } from '@spotify/web-api-ts-sdk'\nimport { env } from 'lib/env'\nimport { parse as parseSpotifyUri } f"
},
{
"path": "packages/francoisbest.com/src/ui/format.ts",
"chars": 1025,
"preview": "const LOCALE = 'en-GB'\n\n/**\n * Format a date-ish object to a locale-friendly string\n */\nexport function formatDate(\n da"
},
{
"path": "packages/francoisbest.com/src/ui/head/favicons.tsx",
"chars": 1805,
"preview": "export const Favicons = () => (\n <>\n <link\n rel=\"apple-touch-icon\"\n sizes=\"57x57\"\n href=\"/favicons/ap"
},
{
"path": "packages/francoisbest.com/src/ui/hooks/useClipboard.ts",
"chars": 692,
"preview": "import { useCallback, useEffect, useState } from 'react'\n\nexport function useClipboard(value: string, timeout = 1500) {\n"
},
{
"path": "packages/francoisbest.com/src/ui/hooks/useHydration.ts",
"chars": 207,
"preview": "'use client'\n\nimport React from 'react'\n\nexport function useHydration() {\n const [hydrated, setHydrated] = React.useSta"
},
{
"path": "packages/francoisbest.com/src/ui/layouts/footer.tsx",
"chars": 3536,
"preview": "import { chiffreConfig } from 'lib/services/chiffre'\nimport Link from 'next/link'\nimport React from 'react'\nimport { BsD"
},
{
"path": "packages/francoisbest.com/src/ui/layouts/nav-header.tsx",
"chars": 2761,
"preview": "import Link from 'next/link'\nimport React from 'react'\nimport { BsMastodon } from 'react-icons/bs'\nimport { FiTwitter } "
},
{
"path": "packages/francoisbest.com/src/ui/layouts/nav-link.tsx",
"chars": 594,
"preview": "'use client'\n\nimport Link, { LinkProps } from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport React fro"
},
{
"path": "packages/francoisbest.com/src/ui/layouts/wide-container.tsx",
"chars": 350,
"preview": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport const WideContainer: React.FC<React.Component"
},
{
"path": "packages/francoisbest.com/src/ui/theme/moonlight-ii.json",
"chars": 29404,
"preview": "{\n \"name\": \"Moonlight II\",\n \"type\": \"dark\",\n \"colors\": {\n \"foreground\": \"#c8d3f5\",\n \"focusBorder\": \"#82aaff\",\n "
},
{
"path": "packages/francoisbest.com/tsconfig.json",
"chars": 904,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"lib\": [\n \"dom\",\n \"dom.iterable\",\n \"esnext\"\n ],\n "
},
{
"path": "pnpm-workspace.yaml",
"chars": 27,
"preview": "packages:\n - 'packages/*'\n"
},
{
"path": "turbo.json",
"chars": 535,
"preview": "{\n \"$schema\": \"https://turbo.build/schema.json\",\n \"tasks\": {\n \"dev\": {\n \"cache\": false,\n \"persistent\": tr"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the franky47/francoisbest.com GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 177 files (686.1 KB), approximately 250.0k tokens, and a symbol index with 198 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.