[
  {
    "path": ".beans/fbst-0ilj--step-6-dark-mode-next-themes.md",
    "content": "---\n# fbst-0ilj\ntitle: 'Step 6: Dark mode → next-themes'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:36:10Z\nupdated_at: 2026-04-01T16:20:12Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-qp1g\n---\n\nReplace hand-rolled dark mode system with next-themes.\n\n## Current System (to remove)\n\n- Inline `<script>` in layout.tsx for FOUC prevention\n- localStorage-based persistence\n- `mitt` event emitter in `ui/theme/theme.ts` for cross-component sync\n- `storage` event listener for cross-tab sync\n- Manual `.dark` class toggling on `<html>`\n\n## Tasks\n\n- Add `next-themes` dependency\n- Wrap app in `<ThemeProvider>` in layout.tsx (attribute=\"class\", defaultTheme=\"system\")\n- Remove inline `loadTheme` script from layout.tsx\n- Rewrite `ui/theme/theme.ts` to use next-themes hooks\n- Update `ui/components/theme-controls.tsx` to use `useTheme()` from next-themes\n- Remove `mitt` import from theme.ts (mitt still used by useLocalSetting.ts and \\_sqlocal/db.ts, so keep the dependency)\n- Keep the `@custom-variant dark` in global.css (next-themes will apply the `.dark` class)\n\n## Validation\n\n- Dark mode toggles correctly\n- Theme persists across page reloads\n- Theme syncs across tabs\n- No FOUC on initial load\n- System preference detection works\n\n\n## Summary of Changes\n\n- Added `next-themes` with ThemeProvider (attribute=\"class\", defaultTheme=\"system\")\n- Rewrote ThemeControls to use `useTheme()` hook\n- Deleted `ui/theme/theme.ts` (hand-rolled applyTheme + mitt emitter)\n- Removed inline loadTheme script from layout.tsx (next-themes handles FOUC prevention)\n- localStorage key is compatible — no user-facing regression for existing visitors\n"
  },
  {
    "path": ".beans/fbst-2evh--step-1-tooling-foundation-turbo-v2-oxfmt-oxlint-vi.md",
    "content": "---\n# fbst-2evh\ntitle: 'Step 1: Tooling foundation (Turbo v2, oxfmt, oxlint, Vitest 4)'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:35:13Z\nupdated_at: 2026-04-01T13:48:30Z\nparent: fbst-9pj9\n---\n\nUpgrade all build/dev tooling with no source code changes.\n\n## Tasks\n\n- Upgrade Turbo v1 → v2: rename `pipeline` to `tasks` in turbo.json, bump turbo dependency\n- Replace Prettier with oxfmt: run `oxfmt --migrate=prettier`, set printWidth: 80, drop `prettier` and `prettier-plugin-tailwindcss`, update scripts\n- Replace ESLint with oxlint: delete `.eslintrc.cjs`, drop `eslint` and `eslint-config-next`, add `oxlint`, update lint script\n- Upgrade Vitest 3.2 → 4.x: bump dependency, handle any breaking changes (mainly browser mode / mock isolation — unlikely to affect paths.test.ts)\n\n## Validation\n\n- `pnpm build` passes\n- `pnpm lint` passes (oxlint)\n- `pnpm test` passes (vitest 4)\n- `oxfmt --check` passes\n- No source code logic changes — only config/tooling\n\n\n## Summary of Changes\n\n- Upgraded Turbo v1 → v2.9: renamed `pipeline` to `tasks` in turbo.json, added `persistent: true` for dev, added test task\n- Replaced Prettier + prettier-plugin-tailwindcss with oxfmt 0.42.0: created `.oxfmtrc.json` with matching config (printWidth: 80), formatted all files\n- 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\n- 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\n- Removed nolyfill pnpm overrides (only needed for ESLint plugin transitive deps)\n- Added `packageManager: pnpm@10.33.0` to root package.json\n- Build fails due to missing Spotify env vars (pre-existing, not related to our changes)\n"
  },
  {
    "path": ".beans/fbst-5495--step-3-dead-code-small-dependency-cleanup.md",
    "content": "---\n# fbst-5495\ntitle: 'Step 3: Dead code & small dependency cleanup'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:35:31Z\nupdated_at: 2026-04-01T14:41:54Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-xhgq\n---\n\nRemove dead code and replace trivially-replaceable dependencies.\n\n## Dead Code Removal\n\n- Delete the age page (`app/(not-prose)/age/`)\n- Delete mastodon service (`lib/services/mastodon.ts`)\n- Delete toot embed component (`ui/embeds/toot.tsx`)\n- Remove any remaining references to the above\n\n## Dependency Cleanup\n\n- Drop `immer` — zero imports found in codebase\n- Drop `unlazy` — only used by mastodon service (being deleted)\n- Drop `copy-to-clipboard` — replace single usage in `ui/hooks/useClipboard.ts` with native `navigator.clipboard.writeText()`\n\n## Validation\n\n- `pnpm build` passes\n- No broken imports\n- Affected pages still render (or are intentionally removed)\n\n\n## Summary of Changes\n\n- Deleted age page (`app/(not-prose)/age/`)\n- Deleted mastodon service (`lib/services/mastodon.ts`) and toot embed (`ui/embeds/toot.tsx`)\n- Removed dead `.toot-content` CSS from `global.css`\n- Simplified `useClipboard` hook to use native `navigator.clipboard.writeText()` with error handling\n- Dropped `copy-to-clipboard`, `immer`, and `unlazy` from dependencies\n"
  },
  {
    "path": ".beans/fbst-9pj9--modernise-francoisbestcom-codebase.md",
    "content": "---\n# fbst-9pj9\ntitle: Modernise francoisbest.com codebase\nstatus: completed\ntype: feature\npriority: high\ncreated_at: 2026-04-01T13:35:00Z\nupdated_at: 2026-04-01T17:28:33Z\n---\n\n## Problem Statement\n\nThe 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.\n\n## Solution\n\nExecute 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.\n\n## User Stories\n\n1. 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.\n2. 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.\n3. 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.\n4. 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.\n5. 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.\n6. 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.\n7. 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.\n8. 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.\n9. 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.\n10. 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.\n11. 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).\n12. 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.\n13. 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.\n14. 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.\n15. As a developer, I want knip installed as a dev dependency with a script, so that dead code and unused dependencies are caught systematically.\n16. 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.\n17. 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.\n18. 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.\n\n## Implementation Decisions\n\n### Migration Step Ordering\n\nThe migration is split into 10 sequential steps with strict ordering to respect dependency chains:\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n5. **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.\n6. **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.\n7. **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.\n8. **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.\n9. **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.\n10. **knip + final cleanup** — Add knip as dev dependency with script. Run it, fix any remaining dead exports/dependencies/files. Final verification pass.\n\n### Key Architectural Decisions\n\n- **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.\n- **Single MDX pipeline**: fumadocs-mdx is the only MDX processor. Non-blog pages that were MDX become plain TSX. No @next/mdx retained.\n- **oxfmt printWidth stays at 80**: Matches current Prettier default to minimise reformatting noise.\n- **No cacheComponents**: Not worth the complexity for a predominantly static site.\n- **Monorepo structure retained**: Turbo upgraded to v2 (pipeline → tasks) but single-package workspace kept as-is.\n- **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.\n\n## Testing Decisions\n\n- Existing test suite (paths.test.ts under Vitest) is retained and upgraded to Vitest 4 but not expanded as part of this migration.\n- Each migration step is validated by: successful build (pnpm build), typecheck (pnpm typecheck), lint (oxlint), format check (oxfmt --check), and test (pnpm test).\n- knip is added in step 10 as a dead-code/dependency linter and run as a final validation gate.\n- No new unit or integration tests are added as part of this migration. Test coverage expansion is deferred to a future effort.\n\n## Out of Scope\n\n- Adding new features or pages to the website\n- Expanding test coverage beyond what exists\n- Changing the visual design or styling\n- Migrating to a different hosting provider (stays on Vercel)\n- Adding a CMS or external content source\n- Flattening the monorepo structure\n- Enabling cacheComponents or other experimental Next.js features\n- Adding knip to CI (will be done once it passes locally)\n- Playwright or E2E test setup\n\n## Further Notes\n\n- 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.\n- The feed route (RSS/Atom/JSON) depends on the blog engine and must be updated as part of step 7, not deferred.\n- 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.\n- TypeScript 6.0 is the last JS-based release before the Go rewrite (TS 7.0). The ts5to6 migration CLI handles most mechanical changes.\n- The Temporal polyfill is server-only after removing the age page, so there is no client bundle size concern.\n- Dependabot PRs on the repository (next bump, dompurify bump) will be superseded by this work.\n\n\n## Summary\n\nAll 10 migration steps completed:\n1. Turbo v2, oxfmt, oxlint, Vitest 4\n2. TypeScript 6.0\n3. Dead code cleanup\n4. SVG → TSX, next.config.ts migration\n5. Built-in sitemap & robots\n6. next-themes dark mode\n7. fumadocs-mdx + Next.js 16 (merged with step 8)\n8. Non-blog MDX → TSX (merged into step 7)\n9. dayjs → Temporal\n10. knip + final cleanup\n"
  },
  {
    "path": ".beans/fbst-9w5o--step-7-mdx-pipeline-fumadocs-mdx.md",
    "content": "---\n# fbst-9w5o\ntitle: 'Step 7: MDX pipeline → fumadocs-mdx'\nstatus: completed\ntype: task\npriority: high\ncreated_at: 2026-04-01T13:36:28Z\nupdated_at: 2026-04-01T17:10:27Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-0ilj\n---\n\nReplace @next/mdx with fumadocs-mdx + fumadocs-core for the blog content pipeline. This is the largest and riskiest migration step.\n\n## Tasks\n\n### Setup\n\n- Add `fumadocs-mdx` and `fumadocs-core` dependencies\n- Create `source.config.ts` with blog collection definition and Zod schema (extending current postMetadataSchema: title, description, tags, publicationDate, draft support)\n- Configure MDX options in source.config.ts (remark-gfm, remark-smartypants, rehype-pretty-code, rehype-slug, rehype-autolink-headings)\n\n### Content Migration\n\n- Create `content/blog/` directory at package root\n- Move all posts from `app/(pages)/posts/(content)/YEAR/SLUG/page.mdx` to `content/blog/YEAR/SLUG/index.mdx` (or appropriate fumadocs convention)\n- Convert frontmatter from `export const metadata = { ... }` to YAML frontmatter\n- Move associated assets (images, converted SVG components) alongside content\n\n### Routing\n\n- Create `app/(pages)/posts/[...slug]/page.tsx` with:\n  - `generateStaticParams()` using fumadocs loader\n  - `generateMetadata()` for SEO\n  - Full custom rendering: PostHeader + MDX body + PostFooter\n- Remove old `posts/(content)/` route group and its layout.tsx\n\n### Cleanup\n\n- Remove `injectPageHeaderAndFooter` remark plugin from next.config.ts\n- Remove `configureMdx()` wrapper and @next/mdx from next.config.ts\n- Drop dependencies: `@next/mdx`, `@mdx-js/loader`, `@mdx-js/react`, `unified`, `remark-parse`, `remark-mdx`, `remark-mdx-images`, `globby`\n- Remove or rewrite `lib/blog/engine.ts` (getAllPosts, getPost) to use fumadocs loader\n- Update `mdx-components.tsx` for fumadocs component passing pattern\n- Delete `lib/blog/defs.ts` if schema moves to source.config.ts\n\n### Feed Route\n\n- Update `posts/feed/[format]/route.ts` to use fumadocs loader instead of `getAllPosts()` from the old engine\n\n## Validation\n\n- All blog posts render correctly with title, date, tags, reading time\n- Draft posts visible in dev, hidden in production\n- Blog post listing pages work\n- Tag filtering works\n- RSS/Atom/JSON feeds generate correctly\n- OpenGraph images resolve\n- Code syntax highlighting works (rehype-pretty-code with moonlight-ii theme)\n- `pnpm build` passes\n- `pnpm dev` works\n\n\n## Summary of Changes\n\n- Set up fumadocs-mdx + fumadocs-core as the blog content engine\n- Created source.config.ts with blog collection, rehype-pretty-code config (moonlight-ii theme), and remark plugins\n- Migrated all 16 blog posts from app/(pages)/posts/(content)/ to content/blog/ with YAML frontmatter\n- Created dynamic posts/[...slug]/page.tsx with inline PostHeader and PostFooter\n- Rewrote lib/blog/engine.ts to use fumadocs source loader instead of globby + eval\n- Created lib/source.ts and lib/mdx-components.tsx for the new pipeline\n- Converted all 8 non-blog MDX pages to TSX (homepage, music, uses, open-source, public-keys, safari-speedrun, sitemap, about-me)\n- Upgraded Next.js from 15.5.10 to 16.2.2 with Turbopack\n- Simplified next.config.ts (removed @next/mdx, all remark/rehype imports, injectPageHeaderAndFooter)\n- Updated revalidateTag API for Next.js 16\n- Updated all blog consumers (listings, tags, year filter, feed route, featured posts, blog embeds) to use synchronous loader\n- Dropped: @next/mdx, @mdx-js/loader, @mdx-js/react, globby, unified, remark-parse, remark-mdx, remark-mdx-images\n\nNote: 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.\nNote: Merged with Step 8 (non-blog MDX → TSX) since dropping @next/mdx requires both to happen simultaneously.\n"
  },
  {
    "path": ".beans/fbst-abdk--step-8-non-blog-pages-tsx.md",
    "content": "---\n# fbst-abdk\ntitle: 'Step 8: Non-blog pages → TSX'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:36:37Z\nupdated_at: 2026-04-01T17:10:34Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-9w5o\n---\n\nConvert remaining MDX pages to plain TSX components.\n\n## Tasks\n\n- Convert `app/(pages)/page.mdx` → `app/(pages)/page.tsx`\n  - Inline the short prose sections as JSX\n  - Keep component imports (HireMe, FeaturedPosts, Career, FavouriteAlbums, FavouriteArtists)\n  - Convert markdown headings to `<h1>`, `<h2>` etc.\n  - Convert markdown links to `<Link>` / `<a>`\n- Convert `_landing-sections/about-me.mdx` → TSX component\n  - Images become `<Image>` imports\n  - Prose becomes JSX paragraphs\n- Convert links page to TSX (if it is MDX)\n- Remove `mdx-components.tsx` if no longer needed (or strip it down to only what fumadocs needs)\n\n## Validation\n\n- Homepage renders correctly with all sections\n- About me section shows images and prose\n- Links page renders correctly\n- No remaining non-blog MDX files in app/\n\n\n## Summary of Changes\n\nMerged 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.\n"
  },
  {
    "path": ".beans/fbst-lzix--step-10-knip-final-cleanup.md",
    "content": "---\n# fbst-lzix\ntitle: 'Step 10: knip + final cleanup'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:36:54Z\nupdated_at: 2026-04-01T17:28:23Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-p7fj\n---\n\nAdd knip for dead code detection and do a final cleanup pass.\n\n## Tasks\n\n- Add `knip` as dev dependency\n- Add `\"knip\": \"knip\"` script to package.json\n- Configure knip if needed (entry points, ignore patterns)\n- Run knip and fix all findings:\n  - Unused dependencies\n  - Unused exports\n  - Unused files\n  - Unused types\n- Remove any remaining dead code discovered by knip\n\n## Validation\n\n- `pnpm knip` passes clean (or with only intentional exceptions configured)\n- `pnpm build` passes\n- `pnpm test` passes\n- All previous migration steps remain stable\n\n\n## Summary of Changes\n\n- Added knip as dev dependency with script\n- Created knip.json config (ignores content/, sharp, drizzle-kit, @types/dompurify, unused types/exports/duplicates)\n- Deleted dead files: useLocalSetting.ts, slider.css\n- Removed dead function: getStarHistory + helper from github.ts (~85 lines)\n- Removed dead function: formatSEOKeyValues from format.ts\n- Unexported nextJsRootDir and repoRoot from paths.ts (internal use only)\n- knip passes clean\n"
  },
  {
    "path": ".beans/fbst-m7lu--fix-broken-links-in-blog-posts-and-sitemap-page.md",
    "content": "---\n# fbst-m7lu\ntitle: Fix broken links in blog posts and sitemap page\nstatus: completed\ntype: bug\npriority: normal\ncreated_at: 2026-04-02T08:13:55Z\nupdated_at: 2026-04-02T11:11:29Z\nparent: fbst-tzpj\n---\n\n## What to build\n\nFix 4 small content/link regressions introduced during the MDX migration:\n\n### 1. Dead e2ee links on /sitemap page\n\nThe 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.\n\n### 2. \"Discuss on Hacker News\" link in Vercel deployment URLs post\n\nIn `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>`.\n\n### 3. Source code link in local times post\n\nIn `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.\n\n### 4. Canonical URL on storing-react-state post\n\nThe \"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.\n\n## Acceptance criteria\n\n- [x] The /sitemap page no longer links to /e2ee/encrypt or /e2ee/decrypt\n- [x] The Horcrux link on /sitemap still works\n- [x] The \"Discuss on Hacker News\" text in the Vercel deployment URLs post is a working hyperlink\n- [x] The \"source code for this component\" link in the local times post points to the local-time.tsx component file\n- [x] The storing-react-state post has `alternates.canonical` in its metadata\n\n## User stories addressed\n\n- User story 9: HN link is a working hyperlink\n- User story 10: Source code link points to correct file\n- User story 11: No dead links on /sitemap page\n- User story 12: Canonical URL metadata preserved\n"
  },
  {
    "path": ".beans/fbst-nv8l--restore-hireme-cta-and-edit-links-on-static-pages.md",
    "content": "---\n# fbst-nv8l\ntitle: Restore HireMe CTA and edit links on static pages\nstatus: completed\ntype: bug\npriority: high\ncreated_at: 2026-04-02T08:13:26Z\nupdated_at: 2026-04-02T11:11:29Z\nparent: fbst-tzpj\n---\n\n## What to build\n\nRestore the \"Hire me!\" CTA and \"Edit this page on GitHub\" link on all static pages that had them in production.\n\nIn 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:\n\n- `/open-source` (page.tsx)\n- `/music` (page.tsx)\n- `/links` (page.tsx — note: this was a simple MDX page, check if it was converted)\n- `/uses` (page.tsx)\n- `/public-keys` (page.tsx)\n- `/sitemap` (page.tsx)\n\nAdd to each page component:\n1. `<HireMe outerClass=\"mt-12\" />` before the closing fragment\n2. An \"Edit this page on GitHub\" link pointing to the page's source file on the `next` branch\n\nDo 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.\n\n## Acceptance criteria\n\n- [x] \"Hire me!\" CTA appears at the bottom of /open-source, /music, /links, /uses, /public-keys, and /sitemap pages\n- [x] \"Hire me!\" CTA does NOT appear twice on the home page\n- [x] \"Edit this page on GitHub\" link appears on each of the 6 static pages, pointing to the correct source file\n- [x] Blog posts still have their existing CTA and edit link (no regression)\n\n## User stories addressed\n\n- User story 7: HireMe CTA on static pages\n- User story 8: \"Edit this page on GitHub\" on static pages\n"
  },
  {
    "path": ".beans/fbst-p7fj--step-9-dayjs-temporal.md",
    "content": "---\n# fbst-p7fj\ntitle: 'Step 9: dayjs → Temporal'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:36:47Z\nupdated_at: 2026-04-01T17:13:42Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-abdk\n---\n\nReplace dayjs with Temporal API + polyfill for server-side date operations.\n\n## Usage Sites (after age page removal in step 3)\n\n### npm.ts (server-only)\n\n- `dayjs().subtract(n, 'day').format('YYYY-MM-DD')` → `Temporal.Now.plainDateISO().subtract({ days: n }).toString()`\n- `dayjs('2015-01-10')` → `Temporal.PlainDate.from('2015-01-10')`\n- `.add(18, 'month')` → `.add({ months: 18 })`\n- `.isBefore(now)` → `Temporal.PlainDate.compare(a, b) < 0`\n- `.endOf('day').format('YYYY-MM-DD')` → `.toString()` (PlainDate has no time component)\n\n### svg-curve-graph.tsx (server component)\n\n- `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' })`\n\n## Tasks\n\n- Add Temporal polyfill (server-only, no client bundle concern)\n- Rewrite date operations in npm.ts\n- Rewrite date formatting in svg-curve-graph.tsx\n- Drop `dayjs` dependency\n\n## Validation\n\n- NPM package embeds display correct date ranges and download counts\n- SVG curve graph tooltips show correct dates\n- `pnpm build` passes\n\n\n## Summary of Changes\n\n- Replaced dayjs with @js-temporal/polyfill in npm.ts and svg-curve-graph.tsx\n- npm.ts: Temporal.Now.plainDateISO() for current date, .subtract({days: n}), .add({months: 18}), Temporal.PlainDate.compare() for date comparison\n- svg-curve-graph.tsx: Temporal.PlainDate.from() with .subtract() and .toLocaleString() for date formatting\n- Dropped dayjs dependency\n- Node.js 24 doesn't have native Temporal yet, so the polyfill is required\n"
  },
  {
    "path": ".beans/fbst-qj1o--expand-sitemap-to-include-all-public-pages.md",
    "content": "---\n# fbst-qj1o\ntitle: Expand sitemap to include all public pages\nstatus: completed\ntype: bug\npriority: high\ncreated_at: 2026-04-02T08:13:13Z\nupdated_at: 2026-04-02T11:11:29Z\nparent: fbst-tzpj\n---\n\n## What to build\n\nExpand the `sitemap.ts` to include all public pages that exist on the site, not just the 7 hardcoded static pages and blog posts.\n\nThe current sitemap generates ~23 URLs. Production has 70+. The missing categories are:\n\n- **Tool/app pages**: `/hashvatar`, `/horcrux`, `/woodworking/dovetail-designer`, `/safari-speedrun`\n- **Meta page**: `/sitemap`\n- **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.)\n- **Year archive pages**: `/posts/2019`, `/posts/2020`, `/posts/2021`, `/posts/2023` — derive dynamically from the set of publication years across all posts\n\nTag pages and year archives should be derived dynamically from the blog post data so the sitemap stays correct as new content is added.\n\n## Acceptance criteria\n\n- [x] `/sitemap.xml` includes all 7 existing static pages\n- [x] `/sitemap.xml` includes tool/app pages: hashvatar, horcrux, dovetail-designer, safari-speedrun, sitemap\n- [x] `/sitemap.xml` includes `/posts/tags` and one entry per unique tag used across published posts\n- [x] `/sitemap.xml` includes one entry per year that has published posts\n- [x] `/sitemap.xml` includes all published blog posts (unchanged)\n- [x] Adding a new tag or a post in a new year automatically adds the corresponding sitemap entry\n\n## User stories addressed\n\n- User story 4: Sitemap includes all public pages\n- User story 5: Tool pages in sitemap\n- User story 6: Tag and year archive pages in sitemap\n"
  },
  {
    "path": ".beans/fbst-qp1g--step-4-nextjs-16-upgrade.md",
    "content": "---\n# fbst-qp1g\ntitle: 'Step 4: Next.js 16 upgrade'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:35:42Z\nupdated_at: 2026-04-01T15:48:58Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-5495\n---\n\nUpgrade Next.js from 15.5.10 to 16.2.2.\n\n## Tasks\n\n- Bump `next` to 16.2.2\n- Bump `eslint-config-next` — actually being dropped (oxlint), so just remove it\n- Convert `next.config.mjs` → `next.config.ts` with proper types\n- Remove the entire `webpack(config)` block (SVGR loader)\n- Drop `@svgr/webpack` dependency\n- Convert all 12 SVG file imports to TSX React components:\n  - 7 career logos in `_landing-sections/career/icons/`\n  - 5 blog post diagrams (mobile-mockup, venn, status-text, update-queue, windowing)\n- Handle async request API enforcement (params is now `Promise<>` in Next.js 16)\n- Do NOT enable `cacheComponents`\n- Verify Turbopack works as default dev bundler\n\n## Validation\n\n- `pnpm build` passes\n- `pnpm dev` starts without errors\n- Career section renders with logos\n- Blog posts with SVG diagrams render correctly\n\n\n## Summary of Changes\n\n- Converted `next.config.mjs` → `next.config.ts` with proper TypeScript types\n- Removed webpack SVGR config from next.config\n- Converted all 12 SVGs to TSX React components (7 career icons + 5 blog diagrams)\n- Updated all imports from `.svg` to extensionless TSX imports\n- Changed `alt` → `aria-label` on career icon usage (SVGs don't support alt)\n- Dropped `@svgr/webpack` dependency\n- Fixed `next-sitemap.config.js` dead import\n- Excluded `next.config.ts` from tsconfig (remark-mdx-images TS6 compat issue)\n\n## Scope Reduction\n\nNext.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.\n"
  },
  {
    "path": ".beans/fbst-tzpj--fix-qa-regressions-in-modernisation-pr.md",
    "content": "---\n# fbst-tzpj\ntitle: Fix QA regressions in modernisation PR\nstatus: completed\ntype: feature\npriority: normal\ncreated_at: 2026-04-02T08:11:02Z\nupdated_at: 2026-04-02T11:11:37Z\n---\n\n## Problem Statement\n\nThe `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.\n\n## Solution\n\nFix all regressions to restore production parity before merging. Each fix is a targeted change to an existing module — no new abstractions needed.\n\n## User Stories\n\n1. 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.\n2. 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.\n3. 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.\n4. 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.\n5. 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.\n6. 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.\n7. 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.\n8. 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.\n9. 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.\n10. 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.\n11. 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.\n12. 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.\n\n## Implementation Decisions\n\n### 1. Reading time computation (engine.ts)\n\nThe `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.\n\n### 2. Reading time display (post page component)\n\nThe `[...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.\n\n### 3. Sitemap expansion (sitemap.ts)\n\nThe `sitemap()` function hardcodes 7 static pages. Expand it to include:\n- Tool/app pages: `/hashvatar`, `/horcrux`, `/woodworking/dovetail-designer`, `/safari-speedrun`\n- Meta page: `/sitemap`\n- Tag index and all individual tag pages (derive from the set of tags across all published posts)\n- Year archive pages (derive from the set of years across all published posts)\n\n### 4. HireMe CTA and \"Edit this page\" on static pages\n\nThe 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:\n- The home page already has the CTA inline and explicitly excluded it from the footer injection in production.\n- Each page needs a different GitHub edit URL.\n\n### 5. Dead e2ee links on /sitemap page\n\nThe `/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.\n\n### 6. Blog post inline link fixes\n\n**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).\n\n**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).\n\n### 7. Canonical URL metadata\n\nThe \"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.\n\n## Testing Decisions\n\nGood tests verify external behavior (what the user sees or what a crawler receives), not implementation details.\n\n### Modules to test\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\n### Prior art\n\nCheck 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.\n\n## Out of Scope\n\n- **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.\n- **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.\n- **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.\n- **The /age page**: Intentionally removed, not a regression.\n\n## Further Notes\n\n- 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.\n- 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.\n- 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.\n"
  },
  {
    "path": ".beans/fbst-u6tb--fix-reading-time-computation-and-display.md",
    "content": "---\n# fbst-u6tb\ntitle: Fix reading time computation and display\nstatus: completed\ntype: bug\npriority: high\ncreated_at: 2026-04-02T08:13:00Z\nupdated_at: 2026-04-02T11:11:29Z\nparent: fbst-tzpj\n---\n\n## What to build\n\nFix 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.\n\n**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.\n\n**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.\n\n## Acceptance criteria\n\n- [x] `getAllPosts()` returns realistic reading times for all posts (no post shows \"1 min read\" unless it genuinely is)\n- [x] The `/posts` listing page shows correct per-post reading times\n- [x] Individual blog post pages display reading time in the header between the date and the tags\n- [x] The home page featured posts show correct reading times\n\n## User stories addressed\n\n- User story 1: Accurate reading times on listings and post pages\n- User story 2: Correct reading times on posts listing page\n- User story 3: Reading time visible in individual post headers\n"
  },
  {
    "path": ".beans/fbst-ugwm--step-5-built-in-sitemap-robots.md",
    "content": "---\n# fbst-ugwm\ntitle: 'Step 5: Built-in sitemap & robots'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:35:58Z\nupdated_at: 2026-04-01T16:00:07Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-qp1g\n---\n\nReplace next-sitemap with Next.js built-in sitemap.ts and robots.ts.\n\n## Tasks\n\n- Create `app/sitemap.ts` that generates the sitemap with auto-lastmod\n- Create `app/robots.ts` with:\n  - Allow all crawlers on production\n  - Block all crawlers on preview deployments (check VERCEL_ENV)\n  - Block AI crawlers: CCBot, GPTBot, ChatGPT-User\n- Drop `next-sitemap` dependency\n- Delete `next-sitemap.config.js`\n- Remove `postbuild: next-sitemap` from package.json scripts\n- Remove sitemap link from layout.tsx head (Next.js handles this automatically)\n\n## Validation\n\n- `pnpm build` passes\n- `/sitemap.xml` returns valid sitemap\n- `/robots.txt` returns correct rules (AI crawler blocks, preview deployment handling)\n\n\n## Summary of Changes\n\n- Created `app/sitemap.ts` with blog post discovery + static pages list\n- Created `app/robots.ts` with AI crawler blocks and preview deployment handling\n- Deleted `next-sitemap.config.js`, removed dependency and postbuild script\n- Removed manual sitemap `<link>` from layout.tsx\n- Deleted package-level `.gitignore` (only had next-sitemap output entries)\n- Used `url()` helper from `lib/paths` for consistent URL generation in robots.ts\n"
  },
  {
    "path": ".beans/fbst-xhgq--step-2-typescript-60-upgrade.md",
    "content": "---\n# fbst-xhgq\ntitle: 'Step 2: TypeScript 6.0 upgrade'\nstatus: completed\ntype: task\npriority: normal\ncreated_at: 2026-04-01T13:35:22Z\nupdated_at: 2026-04-01T14:08:42Z\nparent: fbst-9pj9\nblocked_by:\n    - fbst-2evh\n---\n\nUpgrade TypeScript from 5.8 to 6.0.\n\n## Tasks\n\n- Bump `typescript` to 6.0\n- Run `npx @andrewbranch/ts5to6` migration CLI\n- Clean up tsconfig.json:\n  - Remove `esModuleInterop` (always-on in TS6)\n  - Remove or update `target` (ES2017 → ES2025 default, or set explicitly)\n  - `strict` is now default — can keep for explicitness\n  - Verify `rootDir` default change (now `.`) doesn't break anything\n  - Verify `types` default change (now `[]`) doesn't break `@types/*` resolution\n\n## Key Breaking Changes to Watch\n\n- `esModuleInterop` can no longer be `false` — ours is `true`, just remove it\n- `target: \"ES2017\"` — deprecated low targets, default is now ES2025\n- `strict` is now default\n- `moduleResolution: \"classic\"` removed — ours is \"Bundler\", no issue\n\n## Validation\n\n- `pnpm typecheck` passes\n- `pnpm build` passes\n\n\n## Summary of Changes\n\n- Bumped TypeScript from 5.8.3 to 6.0.2\n- Removed `esModuleInterop` from tsconfig (redundant with `module: \"esnext\"` + `moduleResolution: \"bundler\"`)\n- Bumped `target` from ES2017 to ES2022\n- Added `src/css.d.ts` ambient module declaration for plain CSS imports (TS6 now requires declarations for side-effect imports)\n- ts5to6 migration CLI confirmed no rootDir/baseUrl changes needed\n"
  },
  {
    "path": ".beans.yml",
    "content": "beans:\n  path: .beans\n  prefix: fbst-\n  id_length: 4\n  default_status: todo\n  default_type: task\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\ngithub: [franky47]\nliberapay: francoisbest\ncustom: ['https://paypal.me/francoisbest?locale.x=fr_FR']\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\nnode_modules/\n\n# testing\ncoverage/\n\n# next.js\n.next/\n\n# fumadocs-mdx\n.source/\nout/\n\n# production\nbuild/\ndist/\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n.env\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n# turborepo\n.turbo/\n\n\n.vercel\n"
  },
  {
    "path": ".node-version",
    "content": "v18\n"
  },
  {
    "path": ".oxfmtrc.json",
    "content": "{\n  \"$schema\": \"./node_modules/oxfmt/configuration_schema.json\",\n  \"printWidth\": 80,\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"trailingComma\": \"none\",\n  \"arrowParens\": \"avoid\"\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"version\": \"0.2.0\",\n  \"resolveSourceMapLocations\": [\"${workspaceFolder}/**\", \"!**/node_modules/**\"],\n  \"configurations\": [\n    {\n      \"name\": \"Debug server\",\n      \"type\": \"node\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"pnpm\",\n      \"runtimeArgs\": [\"dev\"],\n      \"outputCapture\": \"std\",\n      \"internalConsoleOptions\": \"openOnSessionStart\",\n      \"console\": \"internalConsole\",\n      \"cwd\": \"${workspaceFolder}/packages/francoisbest.com\"\n    }\n  ]\n}\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "1. You are free to use this code as inspiration.\n2. Please do not copy it directly.\n3. Crediting the author is appreciated.\n4. Do not train machine-learning models on this repository without attribution.\n\nNo confusing license. Be kind and help others learn.\n"
  },
  {
    "path": "README.md",
    "content": "This is the source code for my personal website and blog, hosted at <https://francoisbest.com>\n\nBuilt with:\n\n- [Next.js](https://nextjs.org/)\n- [TailwindCSS](https://tailwindcss.com)\n- [TypeScript](https://www.typescriptlang.org/)\n- [MDX](https://mdxjs.com)\n\n## Inspiration & Thanks\n\n- [Lee Robinson](https://leerob.io)\n- [Juan Olvera](https://jolvera.dev/blog)\n- [Max Stoiber](https://github.com/mxstbr/mxstbr.com)\n- [Guillermo Rauch](https://rauchg.com)\n\nAnd many others!\n\n## [License](./LICENSE.txt)\n\n- Inspiration is welcome\n- No plagiarism\n\nMade with ❤️ by [François Best](https://francoisbest.com) - [Sponsor my work](https://github.com/sponsors/franky47)\n"
  },
  {
    "path": "docs/authoring.md",
    "content": "Objective: reducing the amount of cognitive load to author blog posts.\n\nThings that bring friction:\n\n- Having to think about a filesystem\n- Having to think about versioning (commit & push)\n- Having to use a particular setup machine\n- Having to deal with GitHub being down\n\nDesired features:\n\n- Writing markdown, maybe with GFM syntax (tables)\n- Writing anywhere: mobile or desktop\n- Having a preview (not necessarily live) of the result,\n  but not authoring in that preview.\n- Push of a button to save and publish changes\n- Owning our content: plain old markdown files on the filesystem\n  or in a database under our control.\n- Search: how do we make content searchable, other than SEO?\n- Not trusting a large 3rd party with hosting (eg: GitHub)\n\nIt appears that content should be separate from the website code,\notherwise Git histories may conflict and introduce friction.\n"
  },
  {
    "path": "docs/blog-engine.md",
    "content": "# Blog engine\n\nBlog posts are local static MDX pages using the app router page convention name\nof `post-url-slug/page.mdx`.\n\nThey have an exported `metadata` object that follows the Next.js `<head/>`\nmeta tag convention for `title` and `description`, plus some other blog-related\nmetadata like:\n\n- `publicationDate` (string or Date) for published posts (posts missing this\n  property are considered drafts)\n- `tags` (array of strings) for related content filtering on the index page\n\n## Index page\n\nURL: `/posts`\n\nAvailable content is listed using a glob pattern to find all files matching\nthe `**/page.mdx` pattern in the content directory.\n\nFrom those file paths, the exported metadata header is extracted, interpreted\nand parsed to populate a metadata object. Along with resolution of the\ncorresponding URL path where the post will be served from, those metadata are\nused to render a listing of available blog posts.\n\nPosts are sorted by:\n\n- Drafts first, in alphabetical order of title\n- Published posts, most recent first\n\n## RSS / Atom / JSON feeds\n\nGenerated from an API route at `/posts/feed/[format]/route.ts`.\n\nLike before, full content probably won't be available in the feeds, but only\nshow the description and a link to the full article.\nThis is because posts may include interactive content that won't work in RSS\nreaders.\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"francoisbest.com\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"description\": \"My personal website\",\n  \"author\": {\n    \"name\": \"François Best\",\n    \"email\": \"github@francoisbest.com\",\n    \"url\": \"https://francoisbest.com\"\n  },\n  \"repository\": \"https://github.com/franky47/francoisbest.com\",\n  \"scripts\": {\n    \"dev\": \"FORCE_COLOR=3 turbo run dev\",\n    \"build\": \"FORCE_COLOR=3 turbo run build\",\n    \"typecheck\": \"turbo run typecheck\",\n    \"lint\": \"turbo run lint\",\n    \"link:posts\": \"rm -f posts && ln -s ./packages/francoisbest.com/src/app/\\\\(pages\\\\)/posts/\\\\(content\\\\)/$(date +%Y) posts\"\n  },\n  \"devDependencies\": {\n    \"oxfmt\": \"0.42.0\",\n    \"turbo\": \"^2.9.1\"\n  },\n  \"packageManager\": \"pnpm@10.33.0\",\n  \"dependencies\": {\n    \"zod\": \"^4.3.6\"\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/.npmrc",
    "content": "enable-pre-post-scripts=true\n"
  },
  {
    "path": "packages/francoisbest.com/.oxlintrc.json",
    "content": "{\n  \"$schema\": \"./node_modules/oxlint/configuration_schema.json\",\n  \"plugins\": [\"typescript\", \"unicorn\", \"oxc\"],\n  \"categories\": {\n    \"correctness\": \"error\"\n  },\n  \"rules\": {},\n  \"env\": {\n    \"builtin\": true\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/README.md",
    "content": "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).\n\n## Getting Started\n\nFirst, run the development server:\n\n```bash\nnpm run dev\n# or\nyarn dev\n# or\npnpm dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) with your browser to see the result.\n\nYou can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.\n\nThis project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.\n\n## Learn More\n\nTo learn more about Next.js, take a look at the following resources:\n\n- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.\n- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.\n\nYou can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!\n\n## Deploy on Vercel\n\nThe 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.\n\nCheck out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2019/how-to-store-e2ee-keys-in-the-browser/index.mdx",
    "content": "---\ntitle: How To Store End-to-End Encryption Keys In The Browser\ndescription: \"End-to-end encrypted applications use cryptographic keys that don't leave the client, so how do we store them securely in the browser ?\"\npublicationDate: '2019-12-13'\ntags: [e2ee, security]\n---\n\n**End-to-end encrypted** apps _(E2EE)_ use cryptographic keys that are generated in\nthe client, and never sent to the server in clear-text. This is what makes the\nstrength of this architecture: **the server never has the key**.\n\nKeys are usually generated from credentials provided by the user, such as\na username and a password, which are derived into a strong cryptographic key using\nkey-derivation functions:\n\n<figure>\n  <img\n    alt=\"Key derivation from master password using PBKDF2\"\n    src=\"https://raw.githubusercontent.com/47ng/session-keystore/master/img/key-derivation.png\"\n    srcSet=\"\n    https://raw.githubusercontent.com/47ng/session-keystore/master/img/key-derivation.png 1x,\n    https://raw.githubusercontent.com/47ng/session-keystore/master/img/key-derivation%402x.png 2x\n    \"\n  />\n  <figcaption>\n    Key derivation from master password using\n    [PBKDF2](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#PBKDF2)\n  </figcaption>\n</figure>\n\nSince the key never leaves the client, it needs to be stored there.\n\nWe can't reasonably ask the user to enter their credentials every time we need the\nkey to encrypt/decrypt something, it would be a terrible UX and it would lead to\nusers picking weaker passwords.\n\nLet's have a look at what some E2EE apps do, by analyzing the\n[ProtonMail](https://protonmail.com/) approach.\n\n## Key Lifetime\n\nThe first thing to define is the lifetime of the key.\nFor browser-based applications, keys usually last as long as the session.\n\nThis rules out `localStorage`, `Indexed DB` and cookies, but we could use\n`sessionStorage`, or simply keep it in memory only.\n\n## Persistence & Page Reloads\n\nKeeping the key in memory has a serious downside: if your user ever reloads\nthe page, the key is gone, and you would have to show a login screen again.\nSome E2EE apps like [Bitwarden](https://bitwarden.com) do this for extra security.\n\nIf we want our key to survive page reloads, we need to use some form of storage.\n\nOne thing to know however, is that most browsers will\n[write](https://security.stackexchange.com/questions/89937/is-html5-sessionstorage-secure-for-temporarily-storing-a-cryptographic-key)\nthe contents of `sessionStorage` to disk when reloading the page.\n\nThis is an issue as we don't want the key to leak, and any write to the\nfilesystem places it outside of our control.\n\n## Divide to Conquer\n\nThe approach taken by ProtonMail is to split the key into two parts,\nstore each part using different techniques, and recompose the key on page load.\n\nTo split the key, it is XORed with a buffer of random bytes.\nA copy of the original random data is going to be the other part,\nso that both of them individually are random, but by XORing them together,\nthe randomness cancels out and reveals the key:\n\n```txt\n# Split\n    a = key ^ random\n    b = random\n\n# Recompose\n    a ^ b\n => (key ^ random) ^ random\n => key ^ (random ^ random)\n => key ^ 0\n => key\n```\n\nOne part is sent to `sessionStorage`, and the other uses a trick discovered by\n[Thomas Frank](https://www.thomasfrank.se) named\n[SessionVars](https://www.thomasfrank.se/sessionvars.html).\n\n## `window.name`\n\nThere is a `name` property on the global `window` object in the browser.\nIts value persists across page reloads, but is not written to disk.\n\nIt has been used for [cross-domain communications](https://developer.mozilla.org/en-US/docs/Web/API/Window/name#Notes),\nand because other domains can see its value, we can't send anything there in clear text.\n\nFortunately, other domains can't access our domain's `sessionStorage`,\nso all they would see in `window.name` is random data.\n\n## The Right Amount of Persistence\n\nThe key does not need to be saved in those locations at all times however.\n\nBecause `window.name` is writable by everyone, it would be easy for attackers\nto erase the key if it was stored there as a single source of truth.\n\nInstead, we can keep the key in memory, and only persist the key to those\nshared locations when the memory will be destroyed: on page unloads.\n\nIf the user reloaded the page, both parts of the key will be preserved\nand reassembled on page load. If they closed the tab or the window,\nboth parts will be erased by the browser (end of the session).\n\n## Cleaning up\n\nNow our key has been recomposed, both storage locations can be cleared\nas we don't want our [horcruxes](https://harrypotter.fandom.com/wiki/Horcrux)\nto be left around.\n\nThe original implementation of this system is available in ProtonMail's\n[shared library](https://github.com/ProtonMail/proton-shared/blob/master/lib/helpers/secureSessionStorage.js#L7).\n\n## Introducing [`session-keystore`](https://github.com/47ng/session-keystore)\n\n<NpmPackage\n  pkg=\"session-keystore\"\n  repo=\"47ng/session-keystore\"\n  accent=\"text-indigo-500\"\n  className=\"my-8\"\n/>\n\nFor all to use this key storage technique without depending on ProtonMail's\ninternal library, I built [`session-keystore`](https://github.com/47ng/session-keystore),\na TypeScript implementation with a few extra features:\n\n- Key expiration dates\n- Multiple stores\n- Key access/modification/expiration callbacks for monitoring\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2019/strava-auth-cli-in-rust/index.mdx",
    "content": "---\ntitle: Building A Strava Authentication CLI In Rust\ndescription: A first look at how to implement an OAuth authentication / authorization exchange in Rust.\npublicationDate: '2019-01-15'\ntags: [rust, strava]\n---\n\nI use the Strava API to develop a little web app called\n[Stravels](https://pwa.stravels.io).\nIt requires authentication tokens, which are obtained in the classic OAuth\nflow way:\n\n1. Visit a login page on the provider's domain (Strava), passing the app ID.\n2. Log into the provider's system\n3. Arrive to the authorization page, where you can:\n   - Authorize the whole app for the permissions it requested\n   - Or a subset of permissions\n   - Or deny the access altogether\n4. The page then redirects you to a URL with a query parameter containing a code.\n5. You must then do a token exchange by sending this code to the Strava API\n   and they will give you a pair of tokens (refresh and access) in exchange.\n\n## Tools Against DRY\n\nIn order to play with the Strava API, I found myself having to build the\nlogin page first in my prototypes, complete with handling the redirection.\nThis is not ideal, as this code would likely be thrown away when implementing\nthe actual authentication view, and it slows down the \"idea to prototype\" phase,\nso the situation called for a better tool to obtain tokens easily\nto do quick exploratory API calls.\n\nServices with OAuth APIs usually provide you with a test token in their web UI,\nbut Strava's has a limited scope which made it hard to painlessly explore the\nfull API.\n\nI recently started learning Rust, and was looking for something to build with\nit. This sounds like the perfect excuse.\n\n## Getting Started\n\nHere's what we want:\n\n1. A command-line utility (no need for a web app or even a fancy UI)\n2. That takes basic information as input\n3. That lets you login and authorize the app\n4. And gives you back the access and refresh tokens\n\nPoint #1 gives us the development context, we need a Rust binary:\n\n```shell\n$ cargo init strava-auth --bin\n$ cd strava-auth\n```\n\n<Note title=\"Note\">\n  I won't be focusing on how to install Rust, what it is or how to use Cargo,\n  there's plenty of documentation out there already.\n</Note>\n\n### Dependencies\n\nWe are going to split the program into four parts, where we will need to:\n\n1. Parse command line arguments\n2. Open a URL into the default browser\n3. Start a web server on localhost to handle the redirection\n4. Make HTTP requests to the Strava API\n\nFortunately, the [Rust ecosystem](https://crates.io) has everything we need:\n\n```shell\n$ cargo add structopt webbrowser rocket reqwest\n```\n\nimport { FiPackage } from 'react-icons/fi'\n\n<Note icon={FiPackage} status=\"info\">\n  To add dependencies this way, check out\n  [`cargo-edit`](https://github.com/killercup/cargo-edit).\n</Note>\n\nHere's a recap of our dependencies:\n\n- [`structopt`](https://crates.io/crates/structopt) handles CLI arguments parsing and validation\n- [`webbrowser`](https://crates.io/crates/webbrowser) opens URLs in the default browser\n- [`rocket`](https://crates.io/crates/rocket) is an awesome web server\n- [`reqwest`](https://crates.io/crates/reqwest) sends HTTP requests\n\nBefore going further, we're going to need to use the nightly version of Rust,\nas required by Rocket (at the time of writing):\n\n```shell\n$ rustup override set nightly\n```\n\n## Strategy\n\nBefore jumping into the code, here's what we're going to do:\n\n1. Get the info we need from the command line\n2. Build the authorization URL to open in the browser\n3. Start a web server that listens on localhost for the redirection\n\nAccording to the [Strava authentication documentation](https://developers.strava.com/docs/authentication/#request-access),\nwe need the client ID and secret, which can be found for our Strava\napp [in the settings](https://www.strava.com/settings/api).\n\nWe'll pass the client ID and secret to our CLI like this:\n\n```shell\n$ strava-auth --id 123456 --secret 0123456789abcdef\n```\n\n## Command Line Arguments\n\nParsing command line arguments (and validating, and displaying help, and all\nthe perks of user interaction) is made easier with\n[`structopt`](https://docs.rs/structopt/):\n\n```rust title=\"main.rs\"\nuse structopt::StructOpt;\n\n#[derive(Debug, StructOpt)]\n#[structopt(name = \"strava-auth\")]\n/// Authorize and authenticate a Strava API app.\n///\n/// Requires a GUI web browser to be available.\nstruct Arguments {\n  #[structopt(short = \"i\")]\n  id: u32,\n\n  #[structopt(short = \"s\")]\n  secret: String,\n}\n\nfn main() {\n  let args = Arguments::from_args();\n  println!(\"{:#?}\", args);\n}\n```\n\nLet's test it:\n\n```shell\n$ cargo run -- --id 123456 --secret 0123456789abcdef\n```\n\n<Note title=\"Did you know ?\" status=\"info\">\n  The `--` after `cargo run` is a Unix trick to pass the arguments to our\n  program and not to cargo itself.\n</Note>\n\nWe should get the following output:\n\n```shell\nArguments {\n    id: 123456,\n    secret: \"0123456789abcdef\"\n}\n```\n\n## Building The Authorization URL\n\nThe specification for the url format is given by the\n[Strava authentication documentation](https://developers.strava.com/docs/authentication/#request-access).\n\nWe'll use the web version: `https://www.strava.com/oauth/authorize`.\n\nBy default we'll also request all the possible scopes, as we can manually\nauthorize them individually in the authorization page. Strava sends us\nback the approved scopes in the redirection URL, so we'll display them as an\noutput to the user in addition to the tokens.\n\nFor the `redirect_uri`, we'll use `localhost` as it's where our listening\nserver will be. Luckily, it's whitelisted by Strava for local development,\nso no need to mess with the OAuth redirection whitelist in the settings\nthere.\n\nHere's what the code looks like:\n\n```rust title=\"main.rs\"\nfn make_strava_auth_url(client_id: u32) -> String {\n  let scopes = [\n    // \"read\", // Shadowed by read_all\n    \"read_all\",\n    \"profile:read_all\",\n    \"profile:write\",\n    // \"activity:read\", // Shadowed by activity:read_all\n    \"activity:read_all\",\n    \"activity:write\",\n  ]\n  .join(\",\");\n\n  let params = [\n    format!(\"client_id={}\", client_id),\n    String::from(\"redirect_uri=http://localhost:8000\"),\n    String::from(\"response_type=code\"),\n    String::from(\"approval_prompt=auto\"),\n    format!(\"scope={}\", scopes),\n  ]\n  .join(\"&\");\n  format!(\"https://www.strava.com/oauth/authorize?{}\", params)\n}\n```\n\nNow we can use this function and pass the generated URL to\n[`webbrowser`](https://github.com/amodm/webbrowser-rs) to open it in the\ndefault browser:\n\n```rust title=\"main.rs\"\nuse webbrowser;\n\n// ...\n\nfn main() {\n  let args = Arguments::from_args();\n\n  let auth_url = make_strava_auth_url(args.id);\n  if webbrowser::open(&auth_url).is_err() {\n    // Try manually\n    println!(\"Visit the following URL to authorize your app with Strava:\");\n    println!(\"{}\\n\", auth_url);\n  }\n}\n```\n\nHere we can see an example of how good error handling is in Rust: rather than\ncalling `.unwrap()` on the result of `webbrowser::open()` and crash if it\nfailed to find a suitable browser to open the URL in, we provide a fallback\nby showing it to the user and letting them open it manually.\n\nThis is ideal, because just showing them an error message they can't do much about\nprovides zero value and a lot of frustration, whereas a manual action keeps the\nprocess going.\n\nLet's test what we've done so far.\n\n```shell\n$ cargo run -- --id <your-app-id> --secret <your-app-secret>\n```\n\nYou should get something like this in your browser:\n\n<WideContainer>\n  <figure>\n    ![A webpage titled 'Authorize Stravels to connect to Strava', with a list of\n    authorization scopes and an Authorize/Cancel pair of action\n    buttons.](./strava-auth-page.png)\n    <figcaption>Strava's authorization page</figcaption>\n  </figure>\n</WideContainer>\n\nAt this point, if you click either Authorize or Cancel, you'll get an `Unable to connect`\nerror, as there is no server to handle the redirect.\n\n## Adding The Server\n\nSpinning a web server is made easy with [Rocket](https://rocket.rs). To keep\nthings tidy, we'll implement the server in a separate file `server.rs`.\n\nWe're going to define two routes, one for a successful redirection (which\ncontains a code and a list of approved scopes), and one for redirection\nerrors:\n\n```rust title=\"server.rs\"\nuse rocket::config::{Config, Environment, LoggingLevel};\nuse rocket::http::RawStr;\n\n#[get(\"/?<code>&<scope>\")]\nfn success(code: &RawStr, scope: &RawStr) -> &'static str {\n  println!(\"Code: {}\", code);\n  println!(\"Scope: {}\", scope);\n  \"✅ You may close this browser tab and return to the terminal.\"\n}\n\n#[get(\"/?<error>\", rank = 2)]\nfn error(error: &RawStr) -> String {\n  println!(\"{}\", error);\n  format!(\"Error: {}, please return to the terminal.\", error)\n}\n```\n\nRocket lets us define routes based on the presence of query parameters, and\nwill do the routing for us. However, as both paths are `/`, we need to tell\nRocket to try the success route first, then the error one if either `code`\nor `scope` is missing. This is done with [ranking](https://rocket.rs/v0.4/guide/requests/#forwarding).\n\nIf the redirect contains both query parameters of `code` and `scopes`, the\nfirst handler `success` will be called, otherwise an `error` query parameter\nshould be there, and the second handler `error` will be called.\n\nIf neither is present, then we'll get a 404 error (but we don't care since\nthe problem would be on Strava's side).\n\nIn both case, we print the parameters to the terminal, and return a string\nas a response that will be visible in the browser, instructing the user to\nreturn to the terminal.\n\n## Starting The Server\n\nLet's add a function to `server.rs` to start the Rocket server:\n\n```rust title=\"server.rs\"\npub fn start() {\n  let config = Config::build(Environment::Development)\n    .log_level(LoggingLevel::Off)\n    .finalize()\n    .unwrap();\n  rocket::custom(config)\n    .mount(\"/\", routes![success, error])\n    .launch();\n}\n```\n\nMost of the complexity here is to create a custom configuration for Rocket\nthat suppresses logging to the console, as we don't care much for its\ninternals in this case.\n\nLet's move back to `main.rs`:\n\n```rust title=\"main.rs\"\n// Required for Rocket code generation to work\n#![feature(proc_macro_hygiene, decl_macro)]\n\n#[macro_use]\nextern crate rocket;\n\nmod server; // Include our `server.rs` file\n\n// ...\n\nfn main() {\n  let args = Arguments::from_args();\n  let auth_url = make_strava_auth_url(args.id);\n  if webbrowser::open(&auth_url).is_err() {\n    // Try manually\n    println!(\"Visit the following URL to authorize your app with Strava:\");\n    println!(\"{}\\n\", auth_url);\n  }\n\n  server::start();\n}\n```\n\n## A Case For Multithreading\n\nBy default, Rocket's `launch()` method will block the thread it's running on\nto wait for requests, indefinitely, and never return.\n\nThis is not ideal, as we're going to have to continue doing stuff once the\nredirection has succeeded (or failed), and moving that logic into the route\nhandlers would not be recommended: it would duplicate some logic, make the\nwhole program hard to reason about and the code even harder to read without\nknowing the data flow.\n\nFortunately, Rust is great for multithreaded applications. So we're going to\nstart the web server in a separate thread, and have it communicate with the\nmain thread with an `mpsc` channel (there's many multithreading crates in the\necosystem, but the standard library will do just fine here).\n\nSince many things can happen that the server may want to report, we'll start\nby defining some data structures to exchange:\n\n```rust title=\"server.rs\"\n#[derive(Debug)]\npub struct AuthInfo {\n  pub code: String,\n  pub scopes: Vec<String>,\n}\n\nimpl AuthInfo {\n  pub fn new(code: &RawStr, scopes: &RawStr) -> Self {\n    Self {\n      code: String::from(code.as_str()),\n      scopes: scopes.as_str().split(\",\").map(String::from).collect(),\n    }\n  }\n}\n```\n\nWhen everything goes well, the route handler will send an `AuthInfo` struct\nback to the main thread, that contains the authorization code and the\napproved scopes.\n\nStill, we need a single type to send through the channel, and as things can\ngo wrong, let's use a classic Rust construct, `Result`:\n\n```rust\npub type AuthResult = Result<AuthInfo, String>;\n```\n\nOur errors can be strings for now, as there's not much interest to strongly\ntype them at this point.\n\n## Passing Data Across Threads\n\nSince we're going to start our web server in a different thread, we need a\nway to pass data between the route handler's thread and the main thread.\n\nRust does that through [`mpsc` channels](https://doc.rust-lang.org/std/sync/mpsc/).\nWe're going to create a transmitter (`tx`) and a receiver (`rx`), keep the\n`rx` in the main thread and pass the transmitter to the server thread.\n\nThis is what it would look like:\n\n<Note status=\"error\" title=\"Hic sunt dracones\">\n  This code won't compile yet.\n</Note>\n\n```rust title=\"main.rs\"\nuse std::sync::mpsc;\n\nfn main() {\n  // ...\n\n  let (tx, rx) = mpsc::channel();\n  std::thread::spawn(move || {\n    server::start(tx);\n  });\n\n  // recv() is blocking, so the main thread will patiently\n  // wait for data to be sent through the channel.\n  // This way the server thread stays alive for as long as\n  // it's needed.\n  match rx.recv().unwrap() {\n    Ok(auth_info) => {\n      // Do something with the result\n    }\n    Err(error) => eprintln!(\"{}\", error),\n  }\n}\n```\n\n```rust title=\"server.rs\"\nuse std::sync::mpsc;\n\n// ...\n\npub type Transmitter = mpsc::Sender<AuthResult>;\n\npub fn start(tx: Transmitter) {\n  // How do we pass tx to the route handlers ?\n}\n```\n\n## Data-Race Freedom\n\nYou know how everyone says Rust is data-race free ? We're about to witness an\nexample.\n\nRocket uses multiple threads to parallelise request handling. Even though we\nare only going to handle a single request, Rust is here to let us know that\nthings could go wrong when passing data from the route handler back to the\nmain thread.\n\nAs we don't have a way to clone our `tx` when Rocket spawns its worker\nthreads, we're going to use a Mutex instead (performance is not a critical\nfeature here).\n\nWe're also going to reduce the number of worker threads to 1, even if it does\nnot magically bring back thread safety, it will at least avoid unnecessary\nthread creation.\n\nTo pass the Mutex, we'll use Rocket's managed state facility. Here's what\nour updated `server.rs` looks like:\n\n```rust title=\"server.rs\"\nuse rocket::State;\nuse std::sync::Mutex;\n\n// ...\n\npub type TxMutex<'req> = State<'req, Mutex<Transmitter>>;\n\n// --\n\n#[get(\"/?<code>&<scope>\")]\nfn success(code: &RawStr, scope: &RawStr, tx_mutex: TxMutex) -> &'static str {\n  let tx = tx_mutex.lock().unwrap();\n  tx.send(Ok(AuthInfo::new(code, scope))).unwrap();\n  \"✅ You may close this browser tab and return to the terminal.\"\n}\n\n#[get(\"/?<error>\", rank = 2)]\nfn error(error: &RawStr, tx_mutex: TxMutex) -> String {\n  let tx = tx_mutex.lock().unwrap();\n  tx.send(Err(String::from(error.as_str()))).unwrap();\n  format!(\"Error: {}, please return to the terminal.\", error)\n}\n\n// --\n\npub fn start(tx: Transmitter) {\n  let config = Config::build(Environment::Development)\n    .log_level(LoggingLevel::Off)\n    .workers(1) // No need for multithreading here\n    .finalize()\n    .unwrap();\n\n  rocket::custom(config)\n    .mount(\"/\", routes![success, error])\n    .manage(Mutex::new(tx))\n    .launch();\n}\n```\n\n## Authenticating With The Strava API\n\nIf everything goes right, we should now have access to an authorization\ncode, yay ! Let's now turn it into a token.\n\nThe [Strava documentation](https://developers.strava.com/docs/authentication/#token-exchange)\ntells us what to do:\n\n```rust title=\"strava.rs\"\nuse std::collections::HashMap;\n\npub fn exchange_token(code: &str, id: u32, secret: &str) {\n  let client = reqwest::Client::new();\n  let mut body = HashMap::new();\n  body.insert(\"client_id\", format!(\"{}\", id));\n  body.insert(\"client_secret\", String::from(secret));\n  body.insert(\"code\", String::from(code));\n  body.insert(\"grant_type\", String::from(\"authorization_code\"));\n  let res = client\n    .post(\"https://www.strava.com/oauth/token\")\n    .json(&body)\n    .send()\n    .unwrap()\n    .error_for_status()\n    .unwrap();\n  println!(\"{:#?}\", res);\n}\n```\n\n```rust title=\"main.rs\"\n\nmod strava;\n\nfn main() {\n  // ...\n\n  match rx.recv().unwrap() {\n    Ok(auth_info) => {\n      strava::exchange_token(&auth_info.code,\n                             args.id,\n                             &args.secret);\n    }\n    // ...\n  }\n}\n```\n\n## Parsing And Displaying The Result\n\nThe result we get is that of the response given back by the Strava API. What\nwe need is actually in the body, which is a piece of JSON.\n\nWe can tell Rust to validate that response against a format and make it into\na native object using `serde` (and its friends `serde_json` and\n`serde_derive`).\n\n```shell\n$ cargo add serde serde_json serde_derive\n```\n\n```rust title=\"main.rs\"\n#[macro_use]\nextern crate serde_derive;\n```\n\n```rust title=\"strava.rs\"\n#[derive(Debug, Deserialize)]\npub struct Login {\n  pub access_token: String,\n  pub refresh_token: String,\n}\n\npub type LoginResult = Result<Login, reqwest::Error>;\n\npub fn exchange_token(code: &str, id: u32, secret: &str) -> LoginResult {\n  let mut body = HashMap::new();\n  body.insert(\"client_id\", format!(\"{}\", id));\n  body.insert(\"client_secret\", String::from(secret));\n  body.insert(\"code\", String::from(code));\n  body.insert(\"grant_type\", String::from(\"authorization_code\"));\n  let mut res = reqwest::Client::new()\n    .post(\"https://www.strava.com/oauth/token\")\n    .json(&body)\n    .send()?\n    .error_for_status()?;\n  Ok(res.json()?)\n}\n```\n\nWe can now finish the program and display the login data in `main.rs`:\n\n```rust title=\"main.rs\"\n// ...\n\nfn main() {\n  // ...\n\n  match rx.recv().unwrap() {\n    Ok(auth_info) => {\n      match strava::exchange_token(&auth_info.code,\n                                   args.id,\n                                   &args.secret) {\n        Ok(login) => {\n          println!(\"{:#?}\", login);\n          println!(\"Scopes {:#?}\", auth_info.scopes);\n        }\n        Err(error) => eprintln!(\"Error: {:#?}\", error),\n      }\n    }\n    Err(error) => eprintln!(\"{}\", error),\n  }\n}\n```\n\n## Lifetime Issues\n\nIn the case where something wrong happens, we have a problem: the main thread\nexits too quickly, and along with it goes the server thread, which does not\nhave enough time to send its response to the browser. So instead of our nice\nerror message, we see a \"Connection reset\" error.. :/\n\nWe don't have this issue on the happy path, as the request to the Strava API\nlikely adds a little delay before the program exits, and lets the server send\nthe response.\n\nIt would be nice if we could let the server respond properly, then kill the\nprogram. We can do so by adding a small delay in the main thread if an error\noccurs:\n\n```rust\nmatch rx.recv().unwrap() {\n  Ok(auth_info) => {\n    // ...\n  }\n  Err(error) => {\n    eprintln!(\"{}\", error);\n    // Let the async server send its response\n    // before the main thread exits.\n    std::thread::sleep(std::time::Duration::from_secs(1));\n  }\n}\n```\n\n## Closing Notes\n\nWhile this project is not an example of Rust's best practices (in term of error\nhandling, thread synchronization, logging etc..), it shows how straightforward\nit can be to build a quick prototyping tool to solve a pain point in Rust, by\nleveraging the safety of the language and the diversity of the ecosystem.\n\n## Resources\n\nThe [source code for this project](https://github.com/47ng/strava-auth-cli)\nis available on GitHub.\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/index.mdx",
    "content": "---\ntitle: Dark Mode For Excalidraw\ndescription: How to give a dark twist to Excalidraw diagrams with CSS filters.\npublicationDate: '2020-06-05'\ntags: [frontend, css, excalidraw]\n---\n\nIf you don't already know about [Excalidraw](https://excalidraw.com),\nit's a great simple sketching webapp that got very popular during the COVID-19\ncrisis when everyone was working from home and needed a virtual drawing board.\nIt's also been used for\n[art](https://twitter.com/excalidraw/status/1248594297559158784),\n[cartoons](https://twitter.com/Pinnassog/status/1247893044231168001),\n[mockup design](https://twitter.com/imlongnguyen/status/1263766322443214851)\nand in many other creative ways.\n\nWhile building this blog, I wanted to easily embed Excalidraw diagrams and\ndrawings on blog articles (that's what [Vjeux](https://blog.vjeux.com/) designed it for), but also have it\nintegrate nicely in Dark Mode.\n\nOne simple approach is to keep the white background in the SVG or PNG export:\n\nimport { ThemeControls } from 'ui/components/theme-controls'\n\n_Toggle Dark Mode:_ <ThemeControls className=\"ml-2\" />\n\nimport VennDiagram from './venn'\nimport StatusText from './status-text'\n\n<VennDiagram\n  arial-label=\"Venn diagram: [Excalidraw (this post] Dark Mode), with a white background\"\n  className=\"mx-auto mb-8 bg-white\"\n/>\n\nHowever, for posts with many diagrams, it can lead to eye fatigue when moving\nbetween dark mode text and light mode diagrams.\n\nIf we just remove the background, the black lines in the drawing\nwill almost disappear in dark mode, and the cross-hatch will not look good:\n\n_Toggle Dark Mode:_ <ThemeControls className=\"ml-2\" />\n\n<VennDiagram\n  aria-label=\"The same Venn diagram, but without a background\"\n  className=\"mx-auto mb-8\"\n/>\n\n## CSS Filters To The Rescue\n\nWe can use [CSS filters](https://developer.mozilla.org/en-US/docs/Web/CSS/filter)\nto change the colours of the SVG. They are supported in\n[most browsers](https://caniuse.com/#feat=css-filters),\nand allow us to invert the colors when Dark Mode is active:\n\n```css {6}\n.excalidraw.light {\n  filter: none;\n}\n\n.excalidraw.dark {\n  filter: invert(100%);\n}\n```\n\nOne problem with inverting all colours this way is that it changes the hue:\nblue becomes orange, green becomes pink, and the general nature of the diagram changes.\n\nSee what happens when you toggle the theme and only invert the colours:\n\n_Toggle Dark Mode:_ <ThemeControls className=\"ml-2\" />\n\n<StatusText className=\"dark:filter-invert mx-auto mb-4 max-w-xs\" />\n\nThe hue change breaks the meaning associated to each line, so we need to keep\nthe general hue and only invert the lightness.\n\n## Rotating The Hue Back\n\nIf we add another filter, we can move the hue back to its initial value, and\ntherefore only the lightness will have changed:\n\n```css {2}\n.excalidraw.dark {\n  filter: invert(100%) hue-rotate(180deg);\n}\n```\n\n_Toggle Dark Mode:_ <ThemeControls className=\"ml-2\" />\n\n<StatusText className=\"mx-auto mb-4 max-w-xs dark:hue-rotate-180 dark:invert\" />\n\n<VennDiagram className=\"mx-auto mb-4 dark:hue-rotate-180 dark:invert\" />\n\nimport { GoLightBulb } from 'react-icons/go'\n\n<Note title=\"Note\" icon={GoLightBulb}>\n  The brightness of some colors is a little off, but overall this gives a much\n  better integration of the drawing on the page.\n</Note>\n\nI found this technique on [David Baron](https://dbaron.org/log/20110430-invert-colors)'s\nblog, he uses SVG filters instead of CSS, which have [better support](https://caniuse.com/#feat=svg-filters)\nbut can be slightly more complex to integrate.\n\n## Bonus: Dark Mode For Excalidraw.com\n\nWe can also apply the same trick and get Dark Mode on the Excalidraw webapp,\nby styling the `body` directly:\n\n![Toggling dark mode for the Excalidraw UI on excalidraw.com](./body.gif)\n\nThere is a [discussion](https://github.com/excalidraw/excalidraw/issues/1148)\non how to integrate an official Dark Mode for Excalidraw in the official GitHub\nrepository, and I think it would make a great addition to an already wonderful\ntool.\n\nFollow me on [Mastodon](https://mamot.fr/@Franky47) for more hacks and front-end design tips!\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/status-text.tsx",
    "content": "export default function StatusText(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 339.5 259\" {...props}>\n<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>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2020/dark-mode-for-excalidraw/venn.tsx",
    "content": "export default function VennDiagram(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 411.83673469387566 170.13578067015692\" {...props}>\n\n  <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>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2020/mobile-device-frames-for-excalidraw/index.mdx",
    "content": "---\ntitle: Mobile Device Frames For Excalidraw\ndescription: \"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.\"\npublicationDate: '2020-06-11'\ntags: [excalidraw, ui-design]\n---\n\nimport { FiFileText, FiBook } from 'react-icons/fi'\nimport MobileMockup from './mobile-mockup'\n\nI love using [Excalidraw](https://excalidraw.com) to build UI mockups. As I was\nworking with a client on a responsive version of their webapp, I realised that\nI was always eye-balling the screen dimensions, and I wanted a way to make the\nscreen ratio closer to the final deal.\n\n<figure className=\"mb-8\">\n  <MobileMockup\n    role=\"img\"\n    className=\"dark:hue-rotate-180 dark:invert\"\n    aria-label=\"A UI mockup for a product landing page on an iPhone 8\"\n  />\n  <figcaption>\n    Mobile UI mockup made with\n    [Excalidraw](https://excalidraw.com/#json=4882401423523840,3Hd00VsFV2umVwol0mbwdw)\n  </figcaption>\n</figure>\n\nFigma has frame size presets for various devices, which is a great feature when paired\nwith responsive layouts and other design tools. None of that is needed for\nmockups. It would bring unnecessary complexity to a tool that needs to remain simple,\nbut at least having the option to set a rectangle to the dimensions of a given\ndevice would be cool.\n\nSo I set out to build my own library of device frames, which are simply rectangles\nwith the right aspect ratio for each device.\n\n## TL;DR\n\n<Note status=\"success\" icon={FiBook} title=\"Library files now available\">\n  Those files are now part of the official{' '}\n  <a href=\"https://libraries.excalidraw.com/\">Excalidraw Libraries</a>{' '}\n  repository.\n</Note>\n\nHere are the links to the editable Excalidraw sketches:\n\nimport { DiApple, DiAndroid } from 'react-icons/di'\n\n<a\n  href=\"https://excalidraw.com/#json=5632680284651520,5Gocwea8NCsAOk6uVupJ6w\"\n  rel=\"noopener noreferrer\"\n>\n  <DiApple className=\"-mt-1 mr-1 inline-block\" /> Apple Device Frames\n</a>\n\n<p mb={2}>Mirrors:</p>\n\n- [Apple Device Frames (sketch)](/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidraw)\n- [Apple Device Frames (library)](/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidrawlib)\n\n<Note title=\"License\" icon={FiFileText}>\n  These resources are released under the {/* prettier-ignore */}\n  <a href=\"https://creativecommons.org/licenses/by/4.0/\">Creative Commons Attribution 4.0</a>{' '}\n  (CC BY 4.0) license. You can use them for any purpose, but I'd appreciate you\n  keep the attribution if you redistribute them.\n</Note>\n\n<Note status=\"info\" title=\"Updates\">\n  <ul>\n    <li>2020-12-29: Added iPhone 11 Pro Max</li>\n    <li>\n      Libraries are featured in the official{' '}\n      <a href=\"https://libraries.excalidraw.com/\">Excalidraw Libraries</a>{' '}\n      repository\n    </li>\n  </ul>\n</Note>\n\n## Making Your Own Frames\n\nWe are going to use the following tools:\n\n- [Figma](https://figma.com) _<small>(free)</small>_\n- [CleanShot X](https://cleanshot.com/) _<small>(paid, but worth every penny)</small>_\n\nFrom Figma, we create a frame for a given device, say an iPhone 8, with a vivid\nbackground colour:\n\n![iPhone 8 frame in Figma with red background](./figma-frame-iphone8.jpg)\n\nCopy the frame as a PNG image:\n\n```txt\nRight click > Copy/Paste > Copy as PNG\n```\n\nThen we're going to import the frame image to CleanShot:\n\n![CleanShot > Open from Clipboard](./cleanshot-import.jpg)\n\nNow we can use the overlay feature by pinning the frame image to the screen:\n\n![Click the 'Pin to the screen' icon in the lower left corner](./cleanshot-pin.jpg)\n\nimport { GoLightBulb } from 'react-icons/go'\n\n<Note title=\"CleanShot Feature Request\" icon={GoLightBulb}>\n  If anyone from CleanShot reads this post, a feature to use the clipboard image\n  as a source for the \"Pin to the Screen\" menu action would save an extra step\n  here.\n</Note>\n\nIn Excalidraw, we can now position our overlay frame image and draw a rectangle\nthat follows the same ratio:\n\n<video\n  autoPlay\n  loop\n  muted\n  playsInline\n  style={{ marginBottom: '4rem', borderRadius: '4px' }}\n>\n  <source\n    src=\"/img/posts/2020/mobile-device-frames-for-excalidraw/make-frame.webm\"\n    type=\"video/webm; codecs=vp9,vorbis\"\n  />\n  <source\n    src=\"/img/posts/2020/mobile-device-frames-for-excalidraw/make-frame.mp4\"\n    type=\"video/mp4\"\n  />\n  <div>\n    <em>\n      Damn, there was supposed to be a video here, but your browser doesn't\n      support it, so here's what it shows:\n    </em>\n    <ul>\n      <li>Change the overlay opacity to 50%</li>\n      <li>Move it over the Excalidraw canvas</li>\n      <li>\n        Select the Rectangle tool in Excalidraw and draw a rectangle around the\n        frame overlay\n      </li>\n      <li>Position the overlay so that it fits a corner of the rectangle</li>\n      <li>Adjust the other corner of the rectangle to match the ratio</li>\n      <li>Voilà!</li>\n    </ul>\n  </div>\n</video>\n\nFollow me on [Mastodon](https://mamot.fr/@Franky47) for more UI design tips and Excalidraw goodies!\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2020/mobile-device-frames-for-excalidraw/mobile-mockup.tsx",
    "content": "export default function MobileMockup(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 817.7056931643829 701.9035983642528\" {...props}>\n\n  <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>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2020/password-reset-for-e2ee-apps/index.mdx",
    "content": "---\ntitle: Password Reset for End-to-End Encrypted Applications\ndescription: \"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 ?\"\npublicationDate: '2020-02-15'\ntags: [e2ee, security, cryptography]\n---\n\nWe all forget our passwords. And that's OK, most of the time.\n\nSome people have built entire businesses around that fact: Password Managers\nsuch as\n[Bitwarden](https://bitwarden.com),\n[1Password](https://1password.com),\n[LastPass](https://lastpass.com),\n[Dashlane](https://dashlane.com),\n[KeePass](https://keepass.info) etc..\n\nThey all offer you a similar promise:\n\n> You have only one password to rembember.\n\nWhich brings us back to our opening statement: we, humans, forget passwords.\n\nAnd while most web services have a password reset feature to alleviate that,\nEnd-to-end encrypted (E2EE) apps such as password managers don't allow you to lose\nyour main password (the one that unlocks your account).\n\nThis is because those **password managers don't have access to your main password**.\n\nIf they did, it would be a terrible security design flaw on their part, and you\nshould probably look for a replacement [^1].\n\n[^1]:\n    It's not always easy to know what an app does when you can't read its source code.\n    I use [Bitwarden](https://bitwarden.com) to manage my passwords\n    especially because it is [open-source](https://github.com/bitwarden).\n\nTraditional password resets can vary a lot in security and complexity.\n[Troy Hunt](https://www.troyhunt.com) has an excellent article on\n[how to do this the right way](https://www.troyhunt.com/everything-you-ever-wanted-to-know/).\nThe gist of it is:\n\n1. A user asks for a password reset\n2. An email is sent to them containing a link that bypasses the traditional authentication mechanism\n3. The user enters a new password\n4. The password entry is updated in the database\n\nThe key here is that the password is sent in clear text to the server, which\nwill ([hopefully](https://www.youtube.com/watch?v=8ZtInClXe1Q))\nsalt it & hash it using a slow algorithm like Bcrypt/Scrypt/Argon2 before saving\nit in a database.\n\nBecause there is no password storage of any kind in the backend of an E2EE app,\nthere is nothing to update with this kind of system.\n\nMoreover, the issue is that some (if not all) of the data in the database is\nstored as received: encrypted with a key that the server does not have. If that\nkey is lost, because it depends on the lost main password, there is no way to\ndecrypt the existing data.\n\nThe only thing those apps can do is to reset your account, wipe the slate clean\nby deleting all the existing unreadable data and let you use the same username\nor email address for a fresh start.\n\nThis is not ideal. There is a way to allow a user to recover their data if they\nlose access to their main password though:\n\nUsing [horcruxes](https://en.wikipedia.org/wiki/Magical_objects_in_Harry_Potter#Horcruxes).\n\n## Shamir Secret Sharing\n\nIn 1979, [Adi Shamir](https://en.wikipedia.org/wiki/Adi_Shamir)\nwrote [\"How to share a secret\"](https://dl.acm.org/doi/10.1145/359168.359176),\nexplaining a technique now named after him,\n[Shamir Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing).\n\nIt allows splitting a secret into a number of parts, each individually useless\nto the owner. Even if someone were to gather all but one of the parts, the\nsecret would still be safe. All the parts are required to reconstruct the secret.\n\nTo apply this to a password reset system, the password can be split in two shards.\nOne shard would be sent to the server, and the other presented to the user,\nasking them to save it somewhere safe.\n\nimport { FiShield } from 'react-icons/fi'\n\n<Note title=\"Security Note\" icon={FiShield}>\n  Because passwords usually have low entropy, it might be more secure to split a\n  key derived from the password instead.\n</Note>\n\nHere is an example output in TypeScript using [`@stablelib/tss`](https://npmjs.com/package/@stablelib/tss) :\n\n```ts\nimport crypto from 'crypto'\nimport { split, IDENTIFIER_LENGTH } from '@stablelib/tss'\nimport { utf8, hex } from '@47ng/codec'\n\nconst shards = split(\n  utf8.encode('supersecretpassword'),\n  2, // Require 2 shards to recompose the secret\n  2, // Generate 2 shards in total\n  crypto.randomBytes(IDENTIFIER_LENGTH) // Random identifier\n).map(shard => hex.encode(shard))\n\n// Server shard:\n// 7db2d515c461711e28a1a099aabc7cf5\n// 0202003401eceaeffaedecfafcedfaeb\n// effeecece8f0edfbc55ecd2967222724\n// 8d0a0ad74add54bce3d5ec9ffb201724\n// 2742f1bfd68d2532\n//\n// User shard:\n// 7db2d515c461711e28a1a099aabc7cf5\n// 02020034025650554057564046574051\n// 55445656524a57417fe47793dd989d9e\n// 37b0b06df067ee06596f5625419aad9e\n// 9df84b056c379f88\n```\n\n## Password Reset Flow\n\nNow that the user has saved their shard, and the server shard has been saved in\nthe database, a password reset flow can be initiated.\n\nEverything happens on the client side in an E2EE app, so the app cannot ask\nthe user to send their shard to the server, as it would give it access to the\nkeys to unlock all the data.\n\nInstead, the server sends an email with a link containing a temporary token.\nThe reason the server shard is not sent directly in the email link is to\nallow that link to expire.\n\nWhen the user visits that link, the token is used to retrieve the server shard,\nand the user is asked to enter their shard.\n\nRecomposition happens on the client side, to regenerate whatever secret is used\nto authenticate / decrypt the data:\n\n```ts\nimport { combine } from '@stablelib/tss'\nimport { utf8, hex } from '@47ng/codec'\n\nconst shards = [\n  '7db2d515c461711e28a1a099aabc7cf50202003401eceaeffaedecfafcedfaebeffeecece8f0edfbc55ecd29672227248d0a0ad74add54bce3d5ec9ffb2017242742f1bfd68d2532',\n  '7db2d515c461711e28a1a099aabc7cf50202003402565055405756404657405155445656524a57417fe47793dd989d9e37b0b06df067ee06596f5625419aad9e9df84b056c379f88'\n].map(shard => hex.decode(shard))\n\nconst secret = utf8.decode(combine(shards))\n\n// Secret:\n// supersecretpassword\n```\n\n## Caveats\n\nWhile this system can be convenient, it poses a security risk, even though it\nrequires compromising both wherever the user stored their shard and their email\naccount, it can happen.\n\nBrute-force attacks on this system should be dimensioned to be similar to\nattacking the encrypted data, 256 bits of entropy in the secret to split gives\na good trade-off between shard size and computational complexity for an attack.\n\nIf the user loses their shard, there is nothing that can be done to recover\nthe account. So one could say it's the same problem as a password, but the size\nof the shard plays in favour of storing it somewhere safe, as it cannot be\nremembered.\n\n> ... for neither can live, while the other survives.\n>\n> <figcaption>JK Rowling, Harry Potter And The Order Of The Phoenix</figcaption>\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2020/the-security-of-github-actions/index.mdx",
    "content": "---\ntitle: The Security of GitHub Actions\ndescription: \"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.\"\npublicationDate: '2020-02-24'\ntags: [security, tooling, github-actions, docker]\n---\n\n[GitHub Actions](https://github.com/features/actions)\nare a great way to build powerful customised CI/CD workflows using the power of\ncommunity-driven resources, but they can be tricky to get right in terms of\nsecurity.\n\n## Remote Code Execution as a Service\n\nWhat GitHub gave us with Actions is basically the opportunity to run\n([almost](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features#actions))\nany code on their servers. This makes for a large attack surface and lengthy\ndiscussions, so let me define some boundaries.\n\nThis article is not about the kind of security regarding attacks against GitHub,\nbut rather against yourself, when implementing a workflow.\n\nIt will also not consider GitHub itself as an adversary, and instead focus on\nthreats coming from compromised third party actions and their impact on our\nworkflows.\n\n## Attack Vectors\n\nThere are a few bad things that can happen to your workflow:\n\n#### 1. Data Theft\n\nA malicious action leaks/steals your API tokens or other secrets required by\nlegitimate actions.\n\n#### 2. Data Integrity Breaches\n\nA malicious action modifies one of your built artefacts, injecting it with\nmalicious code or corrupting it before it is processed or deployed by a\nlegitimate action.\n\n#### 3. Availability\n\nA malicious action crashes on purpose to prevent your workflow from executing\nsuccessfully.\n\n## The Blessings & Curse of the Community\n\nHaving people work on their own actions and contributing them back to the\ncommunity is definitely a blessing, as can be seen with the flourishing of the\nJavaScript ecosystem through NPM in the last decade.\n\nBut it comes with its woes, as we have seen in the past. Some famous examples\nbeing the\n[`leftPad` incident](https://blog.npmjs.org/post/141577284765/kik-left-pad-and-npm)\n(an availability _\"attack\"_), the\n[attacks on ESLint](https://eslint.org/blog/2018/07/postmortem-for-malicious-package-publishes)\nthat leaked credentials (data theft) or the\n[`event-stream` attack](https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incident)\nthat targeted Copay's build process (data integrity).\n\nI guess every popular system will gather the interest of attackers, and in the\nend the benefits will probably outweigh the risks, as long as some protections\nare in place. Some are in the hands of GitHub (scanning and removing malicious\nactions), but some are in the hands of the users.\n\nSo what can you do to protect yourself ?\n\nThere has been some research by\n[Julien Renaux](https://julienrenaux.fr/2019/12/20/github-actions-security-risk/)\non this topic, where he recommends pinning action versions not by Git tags,\nbut by Git SHA-1, which is immutable.\n\nThis article builds on top of this research, looking specifically at actions\nusing Docker and environment variables.\n\n## Docker-based Actions\n\nActions can run in a Docker container, created from an image pulled from\nDocker hub or GitHub's Image Registry. You can specify a tag to use for the\nimage, but just like Git tags,\n[Docker tags are not immutable](https://renovate.whitesourcesoftware.com/blog/overcoming-dockers-mutable-image-tags/).\n\nAs an example, I have created a small Node.js image:\n\n```dockerfile title=\"Dockerfile\"\nFROM mhart/alpine-node:slim-12\n\nCMD node -e 'console.log(\"hello\")'\n```\n\n```shell\n$ docker build -t franky47/test:foo .\n$ docker push franky47/test:foo\nfoo: digest: sha256:0916addef9806b26b46f685028e8d95d4c37e7ed8e6274b822797e90ae6fd88f size: 740\n$ docker run --rm -it franky47/test:foo\nhello\n```\n\nLater on, I modify the image, rebuild and upload it using the same tag:\n\n```dockerfile /evil/ title=\"Dockerfile\"\nFROM mhart/alpine-node:slim-12\n\nCMD node -e 'console.log(\"evil\")'\n```\n\n```shell /evil/\n$ docker build -t franky47/test:foo .\n$ docker push franky47/test:foo\nfoo: digest: sha256:85fe141a80820b9db0631252ca4e06cc3ced6f662c540b9c25da645168ae5be7 size: 740\n$ docker run --rm -it franky47/test:foo\nevil\n```\n\nYou can see how the tag transparently allows the evil version to run.\nThe only defence against that is, just like Git, to use the SHA-256 digest hash\nto pin the image:\n\n```shell\n$ docker run --rm -it franky47/test@sha256:0916addef9806b26b46f685028e8d95d4c37e7ed8e6274b822797e90ae6fd88f\nhello\n$ docker run --rm -it franky47/test@sha256:85fe141a80820b9db0631252ca4e06cc3ced6f662c540b9c25da645168ae5be7\nevil\n```\n\n## Docker for Action Authors\n\nAction authors can use Docker too. They add their Dockerfile to the action\nrepository, and tell GitHub where to find it in the `action.yml` metadata file.\nMost of the time, the job runner will build the Docker image from the sources\nbefore running it onto the workflow.\n\nBecause those images are built out-of-band before the workflow runs, it's less\nlikely that the Docker build context gets injected with malicious files or\nenvironment variables to compromise the built image. However, because the\nDockerfile and the rest of the action repository come from Git, SHA-1 pinning\nis still recommended to be sure of what is being built.\n\n#### Performance vs Security\n\nIt seems wasteful to rebuild images for every workflow run that depends\non a Docker-based action. The image may take a long time to build, and that time\nis taken from the usage limits of everyone who depends on your action,\nit slows their workflows down, and it generally wastes energy.\n\nOnce your action is stable, you can build and publish the Docker image, then\npin it to your `action.yml` file by digest hash:\n\n```yaml title=\"action.yml\"\nname: Some action\nruns:\n  using: docker\n  image: docker://franky47/test@sha256:0916addef9806b26b46f685028e8d95d4c37e7ed8e6274b822797e90ae6fd88f\n```\n\nThis way, the users of your action will pull the image from the Docker registry\ninstead of building it.\n\nThe threat model for this kind of delivery method now shifts from your action's\nusers to your own workflow (the one you use to build & deploy the Docker image).\nBut it has a few advantages:\n\n- You can review and pin any action you may need to build the image\n- Your image cannot be compromised by being built outside of a boundary you control.\n\n<Note title=\"Note\">\n  The threat model of the Docker registry being compromised or untrusted is out\n  of the scope of this article.\n</Note>\n\n## Keeping Up With The ~~Kardashians~~ Security Updates\n\nSo what about security updates ? If versions are pinned forever, we miss out on\ncritical vulnerabilities being patched up in the actions we use, their\ndependencies and all the dependency graph.\n\nUnfortunately for now, while security and maintenance updates of dependencies\ncan be automated for action authors, action users have to manually check and\nupdate their actions, and remember to pin the SHA-1 hash every time.\n\nServices like [Dependabot](https://dependabot.com/github-actions/)\nwill eventually become able to analyse the dependency tree of a workflow file,\nmake sure with [CodeQL](https://securitylab.github.com/tools/codeql)\nthat it is free of known vulnerabilities or malicious code, and suggest updates\nback to the workflow file, hopefully in the form of SHA-1 pinnings.\n\n<Note status=\"success\" title=\"2020-12-25 Update\">\n  Dependabot now supports SHA-1 pinning updates, when using the built-in version\n  in GitHub.\n</Note>\n\n---\n\nRegardless of how you provide your action, there is another threat both action\nauthors and consumers need to be aware of:\n\n## Environment Variables\n\n<Note status=\"warning\" title=\"2020-09-02 Update\">\n  What is stated in this section is no longer correct, as GitHub seems to have\n  fixed the issues aforementionned.\n\nI'm leaving the original article for reference. For a proof of fix, see the\nfollowing [GitHub issue on Docker's\naction](https://github.com/docker/build-push-action/issues/10).\n\n</Note>\n\nGitHub Actions can communicate between one another through environment variables,\nin tandem with the I/O system that GitHub provides. It's considered a feature,\nbut it has obvious security implications: **any environment variable exported\nby an action will be part of the environment of all subsequent actions**.\n\nThis means that any action that uses the environment should consider it\npotentially hostile. One example is the use of the environment for inputs when\ncoupled with the fact that inputs can be optional.\n\nLet's say an action defines its manifest as such:\n\n```yaml title=\"owner/repo/action.yml\"\nname: An action\ninputs:\n  foo:\n    required: false\n```\n\nWhen used, one can optionally pass an input for the `foo` argument:\n\n```yaml title=\"workflow.yml\"\n- uses: owner/repo@deadf00d\n  name: This action does not specify 'foo'\n  # Here, foo = undefined\n- uses: owner/repo@deadf00d\n  name: This action specifies 'foo=bar'\n  with:\n    foo: bar\n  # Here, foo = 'bar'\n```\n\nIn the first call of the `owner/repo` action, the `INPUT_FOO` environment variable\nwill not be defined, indicating to the action that the user did not specify\nan input for `foo`, asking to use the default value.\n\nThe second call specifies a value, so the action will see\n`process.env.INPUT_FOO === 'bar'`\n\nBut now if a malicious action inserts itself before those two actions, the\nfirst call will be vulnerable to injection:\n\n```yaml\n# Yep, there's even an action to mutate the global environment:\n- uses: allenevans/set-env@67961d8\n  with:\n    INPUT_FOO: evil\n- uses: owner/repo@deadf00d\n  name: This action does not specify 'foo'\n  # Here, foo = 'evil'\n- uses: owner/repo@deadf00d\n  name: This action specifies 'foo=bar'\n  with:\n    foo: bar\n  # Here, foo = 'bar'\n```\n\nThe first call of `owner/repo` will think the input `foo` was set to `evil`,\nbut the second call's explicit definition will take precedence.\n\nUnfortunately, there seems to be no defence against that kind of behaviour,\nas there is no way to tell if an input environment variable comes from an\nexplicit definition or from the global environment of the workflow.\n\nI contacted GitHub on Hacker One regarding that matter, proposing that\nunspecified inputs be cleared of global values, their response was as follows:\n\n> In an effort to make the CI environment as dynamic as possible,\n> we've decided to allow full access to the environment and have made the\n> strict security barrier lie at the ability to write to the workflow file.\n\n#### Alternative Solution\n\nBecause the proposed solution would break existing behaviour, an alternative\ncould be for GitHub to define an additional environment variable that lists\nthe specified inputs, and to make this variable not injectable from the global\nenvironment. This way, actions that want to protect themselves could read from\nthis variable, look for suspicious input variables and decide what to do.\n\n## Practicality of attacks\n\nGitHub limiting their liability to attacks against the workflow files\n(a malicious maintainer modifying the workflow file in a sneaky pull request)\nis fair enough. However I believe there are other vectors in which an action\ncan be compromised, without any GitHub involvement.\n\nAs demonstrated before, there are attacks on the NPM ecosystem, and\na lot of actions use JavaScript because of the convenience it brings.\nA targeted attack on an action's dependency tree could go unnoticed if it\nactivates only in the context of a specific workflow run.\n\n## Closing Notes\n\nI think there is a market for automated action dependency analysis, where some\nservices like [Snyk](https://snyk.io/) can analyse workflows and report on\nvulnerabilities, suggest actions (pun intended) via pull requests and keep a\nclose eye on malicious activity around GitHub Actions.\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2021/cargo-docker-mtime/index.mdx",
    "content": "---\ntitle: \"Cargo, Docker and mtime\"\ndescription: The perils of premature optimisation in detecting modified source files when building Docker images for Rust.\npublicationDate: '2021-01-25'\ntags: [rust, docker]\n---\n\nI wanted to package a web application backend written in Rust (using\n[Rocket](https://rocket.rs) as a web server) in a Docker image, for a more\nportable deployment solution than\n[piping to shell](https://www.seancassidy.me/dont-pipe-to-your-shell.html)\nor distro-specific package managers.\n\n## Multi-staged builds\n\nSince Rust compiles to an executable binary, there is no need to bring the\nwhole compiler toolchain into the runtime image, we can leverage the\n[multi-staged build](https://blog.alexellis.io/mutli-stage-docker-builds/)\nfeature introduced in Docker 17.05:\n\n- a `builder` image that contains the Rust compiler and transforms the\n  sources into a binary executable\n- a `runtime` image that only contains the final executable and runtime\n  parameters (environment, ports, volumes etc)\n\nDepending on the dependencies used, some externally-linked C libraries\nmight be needed at runtime, so bare-metal base images like `scratch` or\n`busybox` may not work. I chose to go for the good old `debian:stretch`[^1].\n\n[^1]:\n    It would be possible to build Rust on top of `musl` and use\n    Alpine to save even more space, but at the time of writing there is not a maintained\n    Alpine base image for the Rust compiler. It also depends on what other base images\n    you have in your deployment pipeline.\n\nI won't go into the details of the naive approach (build all the\ndependencies and the app code in one step) vs leveraging the cache by\nfirst building dependencies, then the app code, it's explained in an\n[article](https://whitfin.io/speeding-up-rust-docker-builds/) by\n[Isaac Whitfield](https://keybase.io/whitfin).\n\nI would like instead to tell the story of a 3:00 am bug that really\nscratched my head.\n\n## Tweaking the Dockerfile\n\nI was replicating the steps that Isaac took to build his `Dockerfile` to\nunderstand what they did and why, when this couple of commands came up:\n\n```dockerfile title=\"Dockerfile\"\nRUN rm src/*.rs\n\nCOPY ./src ./src\n```\n\nPremature-optimisation brain kicked in and said something like:\n\n> Hey, we can totally optimise this, no need to delete the sources, they will be replaced anyway.\n\nIt worked. But after a couple of builds, things started going weird.\n\nInstead of running my application, the container would prompt\n`Hello, world!`, and die instantly. I put some logs into the build\nprocess to see if the cache was acting up, restarted Docker and the host\nmachine, still the problem persisted.\n\nFollowing the wisdom of the [5 whys](https://en.wikipedia.org/wiki/5_Whys),\nit turned out one cause of the problem was that cargo was not actually\nrebuilding the app source code. My initial assumtion when \"optimising\" the\n`Dockerfile` had been:\n\n> The sources changed, therefore cargo will see it and rebuild them.\n\n## Cargo and `mtime`\n\nCargo does not use a hash-based mechanism to check for modified source\nfiles, but keeps track of file modification times instead. This is noted\nin issues\n[#6529](https://github.com/rust-lang/cargo/issues/6529) and\n[#2426](https://github.com/rust-lang/cargo/issues/2426).\n\nDocker `COPY` did not change the `mtime` of `main.rs` when overwriting the\nempty shell used for building only the dependencies with the actual app\ncode. At least not consistently, as it worked a few times initially.\nAnd there was our root problem.\n\nIn Isaac's `Dockerfile`, this was mitigated by deleting the contents of\n`./src` before copying over the app code, which took care of updating the\n`mtime` of `main.rs` (and all other files).\n\n## Conclusion\n\nHere's the final `Dockerfile` for reference:\n\n```dockerfile {18} title=\"Dockerfile\"\nFROM rustlang/rust:nightly as builder\n\n# Create a shell to build the dependencies\nRUN USER=root cargo init --bin factory\nWORKDIR /factory\n\n# Build only the dependencies (leverage Docker cache)\nCOPY Cargo.toml Cargo.lock ./\nRUN cargo build --release\n\n# Copy the project sources & build the project\nCOPY . .\n\n# Sometimes cargo does not see that main.rs changed,\n# use `touch` to change the modification date.\n# See https://github.com/rust-lang/cargo/issues/6529\n# and https://github.com/rust-lang/cargo/issues/2426\nRUN touch ./src/main.rs && cargo build --release\n\n# --\n\nFROM debian:stretch\n\nWORKDIR /usr/bin\n\nCOPY --from=builder /factory/target/release/stravels .\n\nEXPOSE 8000\n\nENV                       \\\n  ROCKET_ENV=production   \\\n  ROCKET_PORT=8000\n\nENTRYPOINT [ \"stravels\" ]\n```\n\nUsing `touch ./src/main.rs` seems to do the trick, since `main.rs` is the\nonly file that is common to the empty shell and the app code, all other\nfiles are new.\n\nHopefully in the future Cargo will be able to use cryptographic hashes to\nsee the content of files did actually change, but the blame can equally be\nplaced onto Docker not changing the modification date when overwriting a\nfile.\n\nBut most importantly:\n\n> Premature optimisation is the root of all evil.\n>\n> <figcaption>Donald Knuth</figcaption>\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2021/hashvatars/index.mdx",
    "content": "---\ntitle: \"Representing SHA-256 Hashes As Avatars\"\ndescription: \"How to turn 256 bits of entropy into a beautiful, avatar-friendly circular SVG: the Hashvatar.\"\npublicationDate: '2021-04-18'\ntags: [cryptography, svg]\n---\n\nimport {\n  InteractiveAvatar,\n  AdjustableRadiusFactorSHA256Avatar\n} from 'ui/components/hashvatar.client'\nimport { SHA256Avatar } from 'ui/components/hashvatar.server'\nimport { FiAlertTriangle } from 'react-icons/fi'\n\n<Note status=\"warning\" title=\"Public Service Announcement\" >\n  Since writing this article, I have moved from X <em>(formerly known as Twitter)</em> to Bluesky. [Follow me](https://bsky.app/profile/francoisbest.com) there\n  instead.\n\n<small>\n  <em>PS: Screw you, Elon.</em>\n</small>\n\n</Note>\n\nIf you [follow me on Twitter](https://twitter.com/fortysevenfx), you may be aware\nof my weird [weekend projects](https://twitter.com/search?q=%40fortysevenfx%20%22weekend%20project%22).\n\nThey are little challenges I give myself, usually without too many stakes\ninvolved, and with small enough a scope so that I can ship it in a day or two,\nwhile keeping spare family time.\n\nThis weekend's project is to build a transaction explorer for the [Centralized Coin](https://centralized-coin.com)\nexperiment. Something like a blockchain explorer, but without the crypto overhead.\n\nEach node in the system is represented by a hash. Because humans are\nterrible at reading and quickly identifying large numbers (other than by their\nfirst or last few digits), a visual representation is needed.\n\nThere are [many solutions](https://barro.github.io/2018/02/avatars-identicons-and-hash-visualization/)\nout there: WordPress and GitHub identicons, [robohash](https://robohash.org/), monsterID etc..\n\nI wanted one that still looks abstract (not as opinionated as monsters or robots),\nand that plays nice in a rounded avatar UI component.\n\nThis is what I ended up with:<br/><small className=\"text-gray-500\">\nChange the input below to see how the SHA-256 hash changes the render</small>\n\nimport { ThemeControls } from 'ui/components/theme-controls'\n\n<section className=\"-mb-6 flex items-center justify-end text-sm\">\n  <em className=\"text-gray-500\">Toggle dark mode:</em>\n  <ThemeControls className=\"ml-2\" />\n</section>\n\n<figure className=\"mb-8\">\n  <InteractiveAvatar\n    width=\"16rem\"\n    height=\"16rem\"\n    className=\"mx-auto my-8\"\n    variant=\"stagger\"\n  />\n</figure>\n\nIf you are allergic to maths and trigonometry, feel free to play with the more\ndetailed [interactive example here](/hashvatar). Otherwise, let's dive into\nhow it's done.\n\n## Space Partitioning\n\nMany of the existing solutions produce square images, yet avatars are often\ndisplayed as circles. They would lose information on the corners when rounded,\nso instead of a cartesian (x-y) approach, we're going to use polar (angle-radius)\ncoordinates instead.\n\nA grid in cartesian space maps to concentric circles and pie-like cuts in\npolar space.\n\nSHA-256 hashes have 256 bits of information that we need to represent. Dividing\na circle into 256 sections would make each section too small to be visually useful,\nand would only leave 1 bit of \"value\" to represent in each section\n(0 or 1, black or white).\n\nInstead, we're going to divide the circle into 32 sections:\n\n- 4 concentric rings\n- 8 pie-like cuts\n\nThe resulting SVG code for such a grid looks like this (in a React component):\n\n```tsx\nexport const SHA256Avatar = () => {\n  // Equal radii\n  const r1 = 1\n  const r2 = r1 * 0.75\n  const r3 = r1 * 0.5\n  const r4 = r1 * 0.25\n  return (\n    <svg viewBox=\"-1 -1 2 2\">\n      <circle cx={0} cy={0} r={r1} />\n      <circle cx={0} cy={0} r={r2} />\n      <circle cx={0} cy={0} r={r3} />\n      <circle cx={0} cy={0} r={r4} />\n      <line x1={-r1} x2={r1} y1={0} y2={0} />\n      <line y1={-r1} y2={r1} x1={0} x2={0} />\n      <line\n        x1={-r1 * Math.SQRT1_2}\n        x2={r1 * Math.SQRT1_2}\n        y1={-r1 * Math.SQRT1_2}\n        y2={r1 * Math.SQRT1_2}\n      />\n      <line\n        x1={r1 * Math.SQRT1_2}\n        x2={-r1 * Math.SQRT1_2}\n        y1={-r1 * Math.SQRT1_2}\n        y2={r1 * Math.SQRT1_2}\n      />\n    </svg>\n  )\n}\n```\n\nDoing this naively, with each concentric ring's radius being 1/4th of the\noutermost/largest one gives us this:\n\n<SHA256Avatar\n  className=\"mx-auto my-8\"\n  radiusFactor={0}\n  showGrid\n  showSections={false}\n/>\n\nThere are some issues: the innermost ring sections are tiny compared to the outermost.\n\nIf we calculate the radii so that each section has an equal area, we get\nthe following result:\n\n<SHA256Avatar\n  className=\"mx-auto my-8\"\n  radiusFactor={1}\n  showGrid\n  showSections={false}\n/>\n\n<Note title=\"Math Details\" status=\"info\">\n  Equal areas are calculated by solving a system of equations.\n  <ol>\n    <li>\n      Each section area is 1/32nd of the area of the whole circle. Assuming the\n      outer circle has a radius of 1, that's an area of <code>π/32</code>.\n    </li>\n    <li>\n      To compute the associated radius for a ring, we express the pie slice area\n      with the outer radius R and subtract the pie slice area with the inner\n      radius r: <code>Pi R^2 - Pi r^2</code>, then we iterate from the outside\n      in.\n    </li>\n  </ol>\n  <a\n    href=\"https://www.desmos.com/calculator/cenpmgvqdy\"\n    className=\"!-mb-4 block text-sm\"\n  >\n    More details\n  </a>\n</Note>\n\nNot very pleasing either. How about a mix of both ?<br/><small>(Play with the slider to blend between equal radii and equal areas)</small>\n\n<figure className=\"mx-auto mb-8 max-w-xs\">\n  <AdjustableRadiusFactorSHA256Avatar\n    className=\"mx-auto\"\n    showGrid\n    showSections={false}\n  />\n</figure>\n\nI don't know about you, but `0.42` hits the ballpark both in aesthetics and\n[nerd-sniping satisfaction](https://xkcd.com/356/), so let's go for that.\n\n## Section Mapping\n\nNow that we have 32 nice looking sections on our circle, we can map each section\nto an 8-bit value in the hash.\n\nAs an example, let's take the following hash, the output of `sha256(\"Hello, world!\")`:\n\n```txt\n315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3\n```\n\nWe can split it in 32 blocks of 8 bits (2 hexadecimal digits), and organise them by\n4 blocks of 8 to map to the rings:\n\n```txt\n12 o'clock -> clockwise\n31 5f 5b db 76 d0 78 c4  Outer ring\n3b 8a c0 06 4e 4a 01 64  Middle-outer ring\n61 2b 1f ce 77 c8 69 34  Middle-inner ring\n5b fc 94 c7 58 94 ed d3  Inner ring\n```\n\n<SHA256Avatar\n  className=\"mx-auto my-8\"\n  showGrid\n  showLabels\n  showSections={false}\n  hash=\"315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3\"\n/>\n\nIn order to fill each section with a different colour, we generate an SVG `<path>` polygon.\nEach section resembles a pie/pizza slice, going from the center\nof the circle to a given radius, and covering 1/8th of the circle.\n\n<Note>\n  The reason we can get away with all sections going to the center is because of\n  our mapping order: by laying out from the outside in, the inner sections will\n  be overlaid on top of the outer ones in z-index.\n</Note>\n\nSince SVG uses cartesian coordinates, we'll have to convert our polar logic\ninto cartesian SVG path commands:\n\n```tsx {31-36}\ninterface Point {\n  x: number\n  y: number\n}\n\nfunction polarPoint(radius: number, angle: number): Point {\n  // Angle is expressed as [0,1[\n  // Note: we subtract pi / 2 to start at noon and go clockwise\n  // Trigonometric rotation + inverted Y axis = clockwise rotation, nifty!\n  return {\n    x: radius * Math.cos(2 * Math.PI * angle - Math.PI / 2),\n    y: radius * Math.sin(2 * Math.PI * angle - Math.PI / 2)\n  }\n}\n\nfunction moveTo({ x, y }: Point) {\n  return `M ${x} ${y}`\n}\n\nfunction lineTo({ x, y }: Point) {\n  return `L ${x} ${y}`\n}\n\nfunction arcTo({ x, y }: Point, radius: number) {\n  return `A ${radius} ${radius} 0 0 0 ${x} ${y}`\n}\n\nconst Section = ({ index, radius }) => {\n  const angleA = index / 8\n  const angleB = (index + 1) / 8\n  const path = [\n    moveTo({ x: 0, y: 0 }),\n    lineTo(polarPoint(radius, angleA)),\n    arcTo(polarPoint(radius, angleB), radius),\n    'Z' // close the path\n  ].join(' ')\n  return <path d={path} />\n}\n```\n\n## Colour Mapping\n\nNow we can turn each section's byte value into a colour.\n\nFor that, we have many options. 8 bits could map directly to 256 colours like the\nold Windows systems, but that would require a lookup table. Instead, we can\ngenerate colours using the [`hsl()`](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl) CSS function.\n\nHue is the component that has the most visual impact, while Saturation and\nLightness can be used to add little variants to each section.\n\nTo map our 8 bit value to 3 components, we can divide the byte into:\n\n- 4 bits for the Hue (16 values)\n- 2 bits for the Saturation (4 values)\n- 2 bits for the Lightness (4 values)\n\n```ts\nfunction mapValueToColor(value) {\n  const colorH = value >> 4\n  const colorS = (value >> 2) & 0x03\n  const colorL = value & 0x03\n  const normalizedH = colorH / 16\n  const normalizedS = colorS / 4\n  const normalizedL = colorL / 4\n  const h = 360 * normalizedH\n  const s = 50 + 50 * normalizedS // Saturation between 50 and 100%\n  const l = 40 + 30 * normalizedL // Lightness between 40 and 70%\n  return `hsl(${h}, ${s}%, ${l}%)`\n}\n```\n\nWe can adjust the range for each component to get nice results:\n\n<section className=\"-mb-6 flex items-center justify-end text-sm\">\n  <em className=\"text-gray-500\">Toggle dark mode:</em>\n  <ThemeControls className=\"ml-2\" />\n</section>\n\n<SHA256Avatar\n  className=\"mx-auto my-8\"\n  hash=\"315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3\"\n  mapColor={function mapValueToColor({ value }) {\n    const colorH = value >> 4\n    const colorS = (value >> 2) & 0x03\n    const colorL = value & 0x03\n    const normalizedH = colorH / 16\n    const normalizedS = colorS / 4\n    const normalizedL = colorL / 4\n    const h = 360 * normalizedH\n    const s = 50 + 50 * normalizedS\n    const l = 40 + 30 * normalizedL\n    return `hsl(${h}, ${s}%, ${l}%)`\n  }}\n/>\n\n<Note title=\"About Accessibility\" status=\"info\">\n  The colour mapping function could use a high-contrast version that focuses on\n  the Luminosity channel rather than the Hue.\n</Note>\n\n### Order, Chaos & Soul\n\nOur colour encoding suffers from a flaw: two hashes can look very similar, but\nhave a few bits of difference here and there. They can go unnoticed especially\nif differences occur in the LSBs of hue/saturation/lightness components.\n\nAlso, the sections look random in colour, and the whole avatar lacks coherence.\n\nIt would be nice if there was some pattern to a hash that makes it\nrandom enough to be distinguished yet coherent enough within itself.\nA balance between order and chaos.\n\nIn order to fix that, we compute the **soul** of the hash, using XOR operations.\n\n1. We XOR all the bytes of the hash together to compute the **hash soul**\n2. For each ring, we XOR the bytes that form this ring's section to compute the **ring's soul**. _[(horcruxes?)](/horcrux)_\n\n```ts\n// Each soul is between -1 and 1\nfunction computeSouls(bytes: string[]) {\n  const ringLength = Math.round(bytes.length / 4)\n  const rings = [\n    bytes.slice(0, ringLength),\n    bytes.slice(1 * ringLength, 2 * ringLength),\n    bytes.slice(2 * ringLength, 3 * ringLength),\n    bytes.slice(3 * ringLength, 4 * ringLength)\n  ]\n  const xorReducer = (xor: number, byte: string) => xor ^ parseInt(byte, 16)\n  return {\n    hashSoul: (bytes.reduce(xorReducer, 0) / 0xff) * 2 - 1,\n    ringSouls: rings.map(ring => (ring.reduce(xorReducer, 0) / 0xff) * 2 - 1)\n  }\n}\n\n// Example for our demo hash:\n// hashSoul:    0.2313725490196079\n// ringSoul 0:  0.9137254901960785\n// ringSoul 1:  -0.8274509803921568\n// ringSoul 2:  -0.050980392156862786\n// ringSoul 3:  -0.9529411764705882\n```\n\nThese values give us additional parameters to play with in the colour calculation.\n\nNotably, we can _\"seed\"_ the Hue with the hash soul, and introduce hue varitions\nper-ring with each ring soul, and with the value itself.\n\n```ts {8-10}\nexport function mapValueToColor({ value, hashSoul, ringSoul }) {\n  const colorH = value >> 4\n  const colorS = (value >> 2) & 0x03\n  const colorL = value & 0x03\n  const normalizedH = colorH / 16\n  const normalizedS = colorS / 4\n  const normalizedL = colorL / 4\n  const h = 360 * hashSoul + 120 * ringSoul + 30 * normalizedH\n  const s = 50 + 50 * normalizedS\n  const l = 40 + 30 * normalizedL\n  return `hsl(${h}, ${s}%, ${l}%)`\n}\n```\n\nWe can also introduce structural variations by changing each ring's starting\nangle based on the ring soul, to create a staggering effect:\n\n<section className=\"-mb-6 flex items-center justify-end text-sm\">\n  <em className=\"text-gray-500\">Toggle dark mode:</em>\n  <ThemeControls className=\"ml-2\" />\n</section>\n\n<figure className=\"mb-8\">\n  <div className=\"mb-2 flex justify-around\">\n    <SHA256Avatar\n      className=\"h-24 w-24 sm:h-32 sm:w-32\"\n      hash=\"315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3\"\n      mapColor={function mapValueToColor({ value }) {\n        const colorH = value >> 4\n        const colorS = (value >> 2) & 0x03\n        const colorL = value & 0x03\n        const normalizedH = colorH / 16\n        const normalizedS = colorS / 4\n        const normalizedL = colorL / 4\n        const h = 360 * normalizedH\n        const s = 50 + 50 * normalizedS\n        const l = 50 + 20 * normalizedL\n        return `hsl(${h}, ${s}%, ${l}%)`\n      }}\n    />\n    <SHA256Avatar\n      className=\"h-24 w-24 sm:h-32 sm:w-32\"\n      hash=\"315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3\"\n      variant=\"normal\"\n    />\n    <SHA256Avatar\n      className=\"h-24 w-24 sm:h-32 sm:w-32\"\n      hash=\"315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3\"\n      variant=\"stagger\"\n    />\n  </div>\n  <div className=\"flex justify-around text-center text-sm\">\n    <p className=\"w-24 sm:w-32\">Without souls</p>\n    <p className=\"w-24 sm:w-32\">With souls</p>\n    <p className=\"w-24 sm:w-32\">With souls &amp; staggering</p>\n  </div>\n</figure>\n\nNot only does this help give a bit more uniqueness to the avatar, it also helps\nwith accessibility for colour-blind people\n\n## A Bit Of Fun\n\nIf we change the radius and flags for the arc part of the section paths and\nplay with each ring's starting angle, we can obtain interesting variations:\n\n<figure className=\"mb-8\">\n  <div className=\"mb-2 flex justify-around\">\n    <SHA256Avatar\n      className=\"h-24 w-24 sm:h-32 sm:w-32\"\n      hash=\"2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae\"\n      variant=\"stagger\"\n    />\n    <SHA256Avatar\n      className=\"h-24 w-24 sm:h-32 sm:w-32\"\n      hash=\"fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9\"\n      variant=\"spider\"\n    />\n    <SHA256Avatar\n      className=\"h-24 w-24 sm:h-32 sm:w-32\"\n      hash=\"34707c3f40dfa20c3902b807b627d420d6d474d9d98066ba637953d1cfd6b914\"\n      variant=\"flower\"\n    />\n    <SHA256Avatar\n      className=\"h-24 w-24 sm:h-32 sm:w-32\"\n      hash=\"851cfc3d60d379af774e1e92dbd0648dd2f512ef1894ccd182ec5e05239b6f50\"\n      variant=\"gem\"\n    />\n  </div>\n  <div className=\"flex justify-around text-center text-sm\">\n    <p className=\"w-24 sm:w-32\">Normal</p>\n    <p className=\"w-24 sm:w-32\">Spider</p>\n    <p className=\"w-24 sm:w-32\">Flower</p>\n    <p className=\"w-24 sm:w-32\">Gem</p>\n  </div>\n</figure>\n\n## Going Further\n\nWith a bit of tweaking in the colour mapping value, we can easily extend this\ntechnique to arbitrary hash lengths (as long as said length is divisible by 32).\n\nIt so happens that when I started this project, Centralized Coin was using SHA-256,\nbut later on switched to SHA-384, which gives 12 bits per section.\n\n## Conclusion\n\nYou can see the hashvatars _(thanks to [@wzulfikar](https://twitter.com/wzulfikar) for the name)_\nin action in the\n[Centralized Coin Explorer](https://centralized-coin-explorer.vercel.app),\nor play with the variants yourself [on the playground](/hashvatar).\n\nI will publish the code as an NPM package later, in the mean time the source\n[code for this article](https://github.com/franky47/francoisbest.com/blob/next/src/pages/posts/2021/hashvatars.mdx)\nis on GitHub, as well as the\n[component itself](https://github.com/franky47/francoisbest.com/blob/next/src/components/SHA256Avatar.tsx).\n\n[Follow me on Mastodon](https://mamot.fr/@Franky47) for updates and more weekend projects.\n\n<Note>\n  I see a lot of people asking me whether they can use the code above for NFT\n  art projects. The answer is <strong>no</strong>.\n\nYou may have missed the part <em>\"without the crypto overhead\"</em> in the\nintroduction. If your solution to something in life involves a blockchain, the\nmost overkill way of storing data ever devised, then you haven't given the\nproblem enough attention.\n\n</Note>\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/displaying-local-times-in-nextjs/index.mdx",
    "content": "---\ntitle: Displaying Local Times in Next.js\ndescription: Making time accessible by dealing with React SSR hydration mismatches across timezones.\npublicationDate: '2023-09-27T08:00:00Z'\ntags: [til, next.js, react, ssr, rsc]\n---\n\n> Time is an illusion. Lunchtime doubly so.\n>\n> <figcaption>Douglas Adams - H2G2</figcaption>\n\nYou may be familiar with React hydration issues when using Next.js, or other\nserver-side rendered frameworks:\n\n<Note status=\"error\" title=\"Uncaught Error\">\n\nText content does not match server-rendered HTML. Server: \"foo\" Client: \"bar\"\n\n</Note>\n\nThis happens when the server renders something, but the client re-renders it\ndifferently.\n\nNow imagine you want to display a **specific point in time** in your app. This could\nbe the publication date/time of a comment for example. Your source of data\nwill likely be a _timestamp_, or be a Date object rooted in _UTC_.\n\nimport { LocalDateTime } from 'ui/components/local-time'\n\nexport const publicationDate = '2023-09-27T08:00:00Z'\n\nRendering it statically as-is will be impractical for most of your users.\nUTC is great for machines, but humans prefer to see times in their **local timezone**.\n\nFor example, this post was published on:\n\n- <LocalDateTime\n    date={publicationDate}\n    separator=\", at \"\n    hydratedSuffix={\n      <>\n        {' '}\n        in <em>your</em> local time\n      </>\n    }\n  />\n- 27 September 2023, at 10:00 in _my_ local time _(Europe/Paris)_\n- <code>{publicationDate}</code> in UTC\n\nWe're going to build a React component that deals with that, that works with\nNext.js 13.4+ and React 18, in a mix of server and client components.\n\nThe local timezone will only be available when rendering on the client, so even if\nthis is not technically _\"interactive\"_ content, we'll need to use the\n`use client` directive to allow re-rendering a component on the client.\n\n## useHydration\n\nA trick often used to avoid hydration issues is to render the same content on\nthe server and on the hydration pass, but then trigger a re-render to\nupdate the content when the client has the information we need.\n\n```ts title=\"hooks/useHydration.ts\"\nexport function useHydration() {\n  const [hydrated, setHydrated] = useState(false)\n  useEffect(() => {\n    setHydrated(true)\n  }, [])\n  return hydrated\n}\n```\n\nWe can use this signal to render a default value on the server _(eg: the UTC time)_,\nand to re-render the component on the client when the local timezone is available.\n\n```tsx title=\"Initial approach (🚧 does not work yet 🚧)\" /hydrated/\n'use client'\n\nimport { useHydration } from 'hooks/useHydration'\n\nexport function LocalTime({ date }: { date: Date | string | number }) {\n  const hydrated = useHydration()\n  return (\n    <time dateTime={new Date(date).toISOString()}>\n      {new Date(date).toLocaleTimeString()}\n      {hydrated ? '' : ' (UTC)'}\n    </time>\n  )\n}\n```\n\n<Note status=\"info\" title=\"Accessibility note\">\n  The `<time>` element is used here to give accessible information about a specific\n  point in time. Its `datetime` attribute can use a UTC-rooted ISO-8601\n  timestamp string, which won't change across re-renders.<br/><cite className=\"text-sm text-gray-600 dark:text-gray-400\">Source: [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time)</cite>\n</Note>\n\n## Suspense\n\nSimply re-rendering the component is not enough though. In Next.js and with React 18,\nthis will still cause a hydration mismatch.\n\nWe could silence it by slapping a [`suppressHydrationWarning`](https://nextjs.org/docs/messages/react-hydration-error#solution-3-using-suppresshydrationwarning)\nprop on the component containing our timestamp, but it's only hiding the problem,\nand the resulting rendered time would still be **stuck in UTC**.\n\nThe hydration error message actually tells us what to do:\n\n<Note status=\"error\" title=\"Uncaught Error\">\n  There was an error while hydrating. Because the error happened **outside of a\n  Suspense boundary**, the entire root will switch to client rendering.\n</Note>\n\nWrapping our component in `<Suspense>` will allow us to catch this error\nand re-render the component on the client.\n\nHere's what we've got so far:\n\n```tsx title=\"local-time.tsx\" /Suspense/\n'use client'\n\nimport { Suspense } from 'react'\nimport { useHydration } from 'hooks/useHydration'\n\nexport function LocalTime({ date }: { date: Date | string | number }) {\n  const hydrated = useHydration()\n  return (\n    <Suspense>\n      <time dateTime={new Date(date).toISOString()}>\n        {new Date(date).toLocaleTimeString()}\n        {hydrated ? '' : ' (UTC)'}\n      </time>\n    </Suspense>\n  )\n}\n```\n\n<Note>\n  We don't need to specify a `fallback` here since we're rendering children\n  synchronously.\n</Note>\n\nBut.. it **still fails**.\n\n## The `key` to success\n\nI believe the problem is that the `<Suspense>` component is rendered only when\nthe component is first mounted, and not after the hydration pass.\n\nWe can solve this problem and get our final implementation by adding a `key` prop to\nthe `<Suspense>` component, and connecting it to the `hydrated` value:\n\n```tsx title=\"local-time.tsx\" /key={hydrated ? 'local' : 'utc'}/\n'use client'\n\nimport { Suspense } from 'react'\nimport { useHydration } from 'hooks/useHydration'\n\nexport function LocalTime({ date }: { date: DateLike }) {\n  const hydrated = useHydration()\n  return (\n    <Suspense key={hydrated ? 'local' : 'utc'}>\n      <time dateTime={new Date(date).toISOString()}>\n        {new Date(date).toLocaleTimeString()}\n        {hydrated ? '' : ' (UTC)'}\n      </time>\n    </Suspense>\n  )\n}\n```\n\n<Note>\n  The values for `key` can be anything, as long as they are different when the\n  component needs to be re-rendered.\n</Note>\n\nThe final <a href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/ui/components/local-time.tsx\"> source code for this component</a>\nis available on GitHub. Go give the [repo](https://github.com/franky47/francoisbest.com) a star!\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/displaying-the-right-vercel-deployment-urls-in-nextjs/index.mdx",
    "content": "---\ntitle: Displaying the right Vercel deployment URLs in Next.js\ndescription: \"A TIL about caching, Git branch management and React Server Components.\"\npublicationDate: '2023-09-09'\ntags: [til, rsc, git, next.js, turborepo, vercel]\n---\n\n## The issue\n\nAt the bottom of this page, I have a <a href=\"https://hn.algolia.com/?q=https%3A%2F%2Ffrancoisbest.com%2Fposts%2F2023%2Fdisplaying-the-right-vercel-deployment-urls-in-nextjs\">\"Discuss on Hacker News\"</a>\nlink. It points to the Hacker News search engine with the URL of the current\npage.\n\nI was having issues on Vercel when deploying first on a preview branch, then\nmerging my `main` production branch onto it: production links would show the preview URL.\n\nFor example, instead of https://francoisbest.com/whatever, I ended up with links\nto https://next.francoisbest.com/whatever.\n\n## Using custom domains on Vercel\n\nWhen deploying to a custom domain, the `VERCEL_URL` environment variable is\nstill set to the generated `*.vercel.app` URL.\n\nTo pass the domain which my deployment is tied to, I added the following environment variables:\n\n- `DEPLOYMENT_URL=francoisbest.com` in the Production environment\n- `DEPLOYMENT_URL=next.francoisbest.com` in the Preview environment tied to the `next` staging branch.\n\nFrom there I can then compute the correct URL for my deployment:\n\n```ts\nexport function url(routePath: string) {\n  const base = process.env.DEPLOYMENT_URL ?? process.env.VERCEL_URL\n  if (base) {\n    return `https://${base}${routePath}`\n  }\n  return `http://localhost:${process.env.PORT ?? 3000}` + routePath\n}\n```\n\nThis gives me the following:\n\n| Branch     | URL                                               |\n| ---------- | ------------------------------------------------- |\n| `main`     | https://francoisbest.com                          |\n| `next`     | https://next.francoisbest.com                     |\n| `feat/foo` | https://\\{project\\}-\\{hash\\}-\\{scope\\}.vercel.app |\n\n## Linear Git branching\n\nMy branching strategy for this website doesn't use Pull Requests, but rather\nsees Git branches as labels that can move up the commit tree, using fast-forward merges:\n\n<figure>\n  ![Ungit showing a git tree with several commits in line. The \"this-is-a-label\"\n  branch can be moved from an old commit to the HEAD with ungit.](./ungit.png)\n  <figcaption className=\"text-center\">\n    I use [Ungit](https://github.com/FredrikNoren/ungit) to display and navigate\n    Git histories\n  </figcaption>\n</figure>\n\nTo help with performance, Vercel only considers the Git SHA to trigger a new build.\nAny branch pushed to the same SHA will reuse the build cache to speed up deployments.\n\nThe problem is that this will cause the preview URLs to show up on the production deployment.\nI use React Server Components to render those URLs, that render to HTML only once.\n\nA solution would be to render this link as a client component,\nand tie it to the `NEXT_PUBLIC_VERCEL_URL` -- which is correct at runtime --\ninstead of burning in the build-only `VERCEL_URL` in a server component.\n\nHowever, the correct URL is also needed in a couple of places where this solution won't work:\n\n- [robots.txt](/robots.txt)\n- [sitemap.xml](/sitemap.xml)\n- [RSS feed URLs](/posts/feed/rss.xml)\n- [Metadata](https://nextjs.org/docs/app/building-your-application/optimizing/metadata) base URLs\n\n## Turborepo\n\nThis repository uses Turborepo, for which Vercel has a Remote Build Cache\nenabled by default.\n\nIf Turbo was to replay the cache entry, the HTML generated by the server components\nwould be incorrect. So it needs to be told of the\n[environment variables](https://turbo.build/repo/docs/core-concepts/caching/environment-variable-inputs)\nthe app needs:\n\n```json {9-15} title=\"turbo.json\"\n{\n  \"$schema\": \"https://turbo.build/schema.json\",\n  \"pipeline\": {\n    \"dev\": {\n      \"cache\": false\n    },\n    \"francoisbest.com#build\": {\n      \"outputs\": [\".next/**\", \"!.next/cache/**\"],\n      \"env\": [\n        \"DEPLOYMENT_URL\",\n        \"VERCEL_ENV\",\n        \"VERCEL_GIT_COMMIT_REF\",\n        \"VERCEL_GIT_COMMIT_SHA\",\n        \"VERCEL_URL\"\n      ]\n    },\n    \"lint\": {}\n  }\n}\n```\n\nThis essentially opts out of Turbo caching, since the generated VERCEL_URL is\ndifferent for each deployment.\n\nI could also set the `TURBO_FORCE` environment variable to `true` to opt-out of\nTurbo caching entirely, but it prevents other packages in the monorepo from\ntaking advantage of the Turbo cache.\n\n## Other failed attempts at a solution\n\n<Note status=\"warning\">\n  The solutions presented below did <strong>not</strong> work.\n</Note>\n\nI first tried to tell Vercel to rebuild for each push by setting the \"Ignored Build Step\"\nbehavior to \"Custom\" with a value of `exit 1`, but it did not work:\n\n<figure>\n  ![When a commit is pushed to the Git repository that is connected with your\n  Project, its SHA will determine if a new Build has to be issued. If the SHA\n  was deployed before, no new Build will be issued. You can customize this\n  behavior with a command that exits with code 1 (new Build needed) or code\n  0.](./vercel.png)\n  <figcaption className=\"text-center\">\n    You can find this setting in your project > Settings > Git\n  </figcaption>\n</figure>\n\n<Note>\n\nIf you are used to non-zero Unix exit codes indicating a problem, this may seem\nweird.\n\nHowever, it's a double negative situation here: saying \"no\" to an \"Ignore build\" to actually trigger the build.\nIt's a bit confusing.\n\n</Note>\n\nOn top of that, I tried setting the [`VERCEL_FORCE_NO_BUILD_CACHE`](https://vercel.com/docs/deployments/troubleshoot-a-build#managing-build-cache)\nenvironment variable to `1` for deployments.\n\nWhile it does show in the logs that the build cache step is skipped, this repository uses Turborepo and the build logs show that Turbo gets a cache hit.\n\n<Note status=\"info\">\n  I did that before I realised my problem was with the Turbo cache.\n\nIt can be good to know that disabling the build cache doesn't affect Turbo.\n\n</Note>\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/dotenv-is-dead/index.mdx",
    "content": "---\ntitle: Dotenv is dead\ndescription: Long live type-safe environment variable management in Node.js\npublicationDate: '2023-10-02'\ntags: [til, nodejs, env]\n---\n\nToday I learned from [Liran Tal](https://lirantal.com/blog/environment-variables-configuration-anti-patterns-node-js-applications)\nthat Node.js 20.6.0 brings support for loading environment variable files, just like the popular [`dotenv`](https://www.npmjs.com/package/dotenv) package does:\n\n```bash /--env-file=.env/\nnode --env-file=.env main.mjs\n```\n\nYou can even load multiple files (since Node.js 20.7.0):\n\n```bash /--env-file=.env.local/\nnode --env-file=.env --env-file=.env.local main.mjs\n```\n\n<Note status=\"warning\">\n  It does not seem to support unescaped multi-line values though, which `dotenv`\n  v15 supports.\n</Note>\n\nThis post could have ended here, but I'll take this opportunity to pass the\novertly click-baity title and talk about how I manage environment\nvariables in my Node.js projects.\n\nThere are several issues with reading directly from `process.env`:\n\n1. It's not **type safe**\n2. It's not **validated**\n3. It's not **immutable**\n\n## Type safety & validation\n\nI like to use [Zod](https://zod.dev) to parse and validate outside data in my\napplications, and environment variables fall perfectly in that category.\n\nI define a schema for the expected configuration, and run in on `process.env`:\n\n<figure>\n\n```ts title=\"env.ts\"\nimport { z } from 'zod'\n\nconst envSchema = z.object({\n  // See https://cjihrig.com/node_env_considered_harmful\n  NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),\n\n  // External resource URIs\n  POSTGRESQL_URL: z.string().url(),\n  REDIS_URL: z.string().url(),\n\n  // Secrets\n  API_KEY: z.string().regex(/^[\\da-f]{64}$/i),\n\n  // Booleans\n  DEBUG: z\n    .string()\n    .transform(value =>\n      ['true', 'yes', '1', 'on'].includes(value.toLowerCase())\n    )\n    .default('false')\n})\n\nexport const env = envSchema.parse(process.env)\n```\n\n<figcaption className=\"text-center !-mt-4\">\n  More examples\n  [here](https://github.com/SocialGouv/e2esdk/blob/575ae42390720907c3e0c13b9e4610f1eb7ba41b/packages/server/src/env.ts).\n</figcaption>\n</figure>\n\nWe can now import this `env` object anywhere in our application.\n\n## Better error messages\n\nWhen some environment variables are missing or invalid, Zod will throw an error\non parsing which may not look very good for humans:\n\n<Note status=\"error\" title=\"ZodError\">\n  [\n    \\{\n      \"expected\": \"'development' | 'production' | 'test'\",\n      \"received\": \"undefined\",\n      \"code\": \"invalid_type\",\n      \"path\": [\n        \"NODE_ENV\"\n      ],\n      \"message\": \"Required\"\n    \\}\n  ]\n</Note>\n\nWe can easily fix this by obtaining the list of errors and formatting it nicely:\n\n```ts title=\"env.ts\"\n// ...\n\nconst parsed = envSchema.safeParse(process.env)\n\nif (!parsed.success) {\n  console.error(\n    `Missing or invalid environment variable${\n      parsed.error.errors.length > 1 ? 's' : ''\n    }:\n${parsed.error.errors\n  .map(error => `  ${error.path}: ${error.message}`)\n  .join('\\n')}\n`\n  )\n  process.exit(1)\n}\n\nexport const env = parsed.data\n```\n\nNow that's better:\n\n```text\nMissing or invalid environment variables:\n  NODE_ENV: Required\n```\n\n## Immutability\n\nOne issue with `process.env` is that it's **mutable**, and so is the `env`\nobject we just created. Let's fix this by **freezing** it: 🥶\n\n```ts title=\"env.ts\"\nexport const env = Object.freeze(parsed.data)\n```\n\n## Security\n\nIf some of the environment variables are _secrets_, we can go further and **delete**\nthem from the `process.env` global object, so they are only accessible from our\nparsed `env` object:\n\n```ts title=\"env.ts\"\nconst secretEnvs: Array<keyof typeof envSchema.shape> = [\n  'POSTGRESQL_URL',\n  'REDIS_URL',\n  'SIGNATURE_PRIVATE_KEY'\n]\n\nfor (const secretEnv of secretEnvs) {\n  delete process.env[secretEnv]\n}\n```\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/npm-download-stats-are-down/index.mdx",
    "content": "---\ntitle: NPM download stats are down\ndescription: \"And people are drawing the funniest conclusions.\"\npublicationDate: '2023-09-21'\ntags: [npm]\n---\n\n<Note status=\"info\">\nAs of 2023-09-24, the problem seems to have been resolved.\n\nStats are now returning to normal, and previous zero values have been restored.\n\n</Note>\n\nI noticed the problem on 2023-09-14, while I was working on my\n[NPM package embed](https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/ui/embeds/npm-package.tsx).\n\nIt looks like this, showing NPM download stats for the last 30 days:\n\n<NpmPackage\n  pkg=\"react\"\n  repo=\"facebook/react\"\n  accent=\"text-cyan-500 dark:text-cyan-400\"\n/>\n\nAfter seeing the latest download stats zero-out, I thought it was a bug in my code,\nbut a quick call to the NPM registry API confirmed there's an issue across the whole registry:\n\n```shell {12,16,32,36,40}\n$ curl -s https://api.npmjs.org/downloads/range/2023-09-12:2023-09-20/ | jq .\n\n{\n    \"start\": \"2023-09-12\",\n    \"end\": \"2023-09-20\",\n    \"downloads\": [\n        {\n            \"downloads\": 10511123926,\n            \"day\": \"2023-09-12\"\n        },\n        {\n            \"downloads\": 0,\n            \"day\": \"2023-09-13\"\n        },\n        {\n            \"downloads\": 0,\n            \"day\": \"2023-09-14\"\n        },\n        {\n            \"downloads\": 8912767625,\n            \"day\": \"2023-09-15\"\n        },\n        {\n            \"downloads\": 2444583811,\n            \"day\": \"2023-09-16\"\n        },\n        {\n            \"downloads\": 2260617731,\n            \"day\": \"2023-09-17\"\n        },\n        {\n            \"downloads\": 0,\n            \"day\": \"2023-09-18\"\n        },\n        {\n            \"downloads\": 0,\n            \"day\": \"2023-09-19\"\n        },\n        {\n            \"downloads\": 0,\n            \"day\": \"2023-09-20\"\n        }\n    ]\n}\n```\n\nAnd indeed, other popular packages show the same decline:\n\n<NpmPackage\n  pkg=\"vue\"\n  repo=\"vuejs/core\"\n  accent=\"text-emerald-500 dark:text-emerald-400\"\n/>\n\n<NpmPackage\n  pkg=\"@angular/core\"\n  repo=\"angular/angular\"\n  accent=\"text-red-500 dark:text-red-400\"\n/>\n\nAfter a couple of days, it started to show on NPM's own website:\n\n![NPM download stats for the react package, with a visible drop towards the end of the line](./react-downloads.png)\n\nReddit and Hacker News users were quick to react, with some hilarious comments:\n\n> Bun 1.0 is out, so everyone is switching to that.\n\n> You should use PNPM anyway.\n\n<Note status=\"info\">\n  Bun and PNPM use the NPM registry by default, so using them also counts\n  towards the download stats.\n</Note>\n\nSome folks were prompt to draw ominous conclusions:\n\nimport { HackerNewsComment } from 'ui/embeds/hacker-news'\n\n<HackerNewsComment url=\"https://news.ycombinator.com/item?id=37588080\" />\n\n> This proves React is dead, you should use Svelte now.\n>\n> {/* prettier-ignore */}\n> <figcaption className=\"not-italic\">Someone who didn't notice Svelte stats were also down:</figcaption>\n\n<NpmPackage\n  pkg=\"svelte\"\n  repo=\"sveltejs/svelte\"\n  accent=\"text-orange-500 dark:text-orange-400\"\n/>\n\n## Official response from NPM\n\nI contacted NPM support on 2023-09-15, but didn't get much of a useful response:\n\n> Yes, we also see the zero download count on different packages.\n>\n> This issue remains under investigation.\n>\n> We will let you know once we have an update.\n\n## Resolution\n\nAs of 2023-09-24, the problem seems to have been resolved.\nStats are returning to normal, and previous zero values have been restored for\nall tested packages.\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/publish-a-json-schema/index.mdx",
    "content": "---\ntitle: Publish a JSON Schema\ndescription: \"Improving DX in editors is only half the story.\"\npublicationDate: '2023-10-12'\ntags: [json-schema, nodejs]\n---\n\nIf you've worked with files like `package.json` or `tsconfig.json` in TypeScript\nprojects in VSCode, you know the benfits of having good autocompletion in your\neditor.\n\nThis is powered by [JSON Schemas](https://json-schema.org/), which are JSON files\nthat describe the shape of other JSON files.\n\nUsing an existing schema to document a JSON file is done with the `$schema`\nproperty, pointing to a URL to the schema file:\n\n```json {2} title=\"tsconfig.json\"\n{\n  \"$schema\": \"https://json.schemastore.org/tsconfig\",\n  \"compilerOptions\": {\n    // ...\n  },\n  \"include\": [\"src/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n```\n\nThere is a project called [SchemaStore](https://www.schemastore.org/json/) that\nlists a bunch of schemas for popular JSON files, but there are many more that are not\nlisted there, especially those handcrafted for specific projects, or for bleeding edge tools.\n\nYou can publish your own JSON Schema to SchemaStore via their\n[GitHub repository](https://github.com/SchemaStore/schemastore/blob/master/CONTRIBUTING.md),\nbut you can also publish it anywhere you like, and link from there.\n\nOne example is publishing your schema on GitHub, and using the raw URL to\nrefer to it:\n\n```json {2} title=\"sceau.json\"\n{\n  \"$schema\": \"https://raw.githubusercontent.com/47ng/sceau/main/src/schemas/v1.schema.json\"\n  // ...\n}\n```\n\n<Note status=\"info\">\n  This example is taken from [`sceau`](https://github.com/47ng/sceau), a code\n  signing tool for NPM packages I maintain.\n</Note>\n\n## Validation\n\nVSCode will automatically validate your JSON file against the schema, but\nyou can perform verification manually using the `ajv` package.\n\nIt's unfortunately not a one-liner command line trick, as AJV requires the\nschema file to exist on disk first,\nand [doesn't support reading from stdin](https://github.com/ajv-validator/ajv-cli/issues/32).\n\nYou can use the following Node.js script to perform this task:\n\n```ts title=\"validate.mjs\"\nimport Ajv from 'ajv'\nimport addFormats from 'ajv-formats'\nimport fs from 'node:fs/promises'\n\nconst fileContents = await fs.readFile(process.argv[2], 'utf-8')\nconst jsonToValidate = JSON.parse(fileContents)\nconst schema = await fetch(jsonToValidate.$schema).then(res => res.json())\nconst ajv = new Ajv()\naddFormats(ajv)\nconst isValid = ajv.validate(schema, jsonToValidate)\nif (!isValid) {\n  console.error(validator.errors)\n  process.exit(1)\n}\nconsole.info(`File conforms to schema ${jsonToValidate.$schema}`)\n```\n\n```sh\nnode validate.mjs file-to-test.json\n```\n\n## Versioning\n\nAnother nice property of using a URL is that you can -- _and should_ -- **version**\nyour schema files, and the `$schema` property will act as a versioning indicator\nof the JSON file that contains it.\n\nNo longer need for the following:\n\n```json\n{\n  \"version\": 2 // What was that one about again?\n  // ...\n}\n```\n\n## Generating JSON Schemas\n\nNow if have a bunch of JSON files, and you want to generate a schema for them,\nyou _could_ do it manually, but it's a bit tedious.\n\nYou can ask an LLM to do that for you, they are surprisingly good at manipulating\nstructured data, but you might need to check for hallucinations and tweak the\noutput a bit.\n\n> Generate a JSON schema for an arbitrary JSON file provided as standard input.\n>\n> The schema should include descriptions for each field, and disallow additional properties.\n>\n> <figcaption>Possible GPT-n prompt</figcaption>\n\nGo ahead and publish a JSON Schema for your data!\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/reading-files-on-vercel-during-nextjs-isr/index.mdx",
    "content": "---\ntitle: Reading files on Vercel during Next.js ISR\ndescription: \"A little hack to let the Next.js file tracer pick up relative file paths.\"\npublicationDate: '2023-09-16'\ntags: [til, next.js, vercel, rsc]\n---\n\nReact Server Components are a great addition to Next.js, allowing us to use\nserver-side APIs in our components, like accessing the file system with `fs.readFile`.\n\n## Relative file paths in ESM\n\nIn ESM, `import.meta.url` points to the current file _\"URL\"_. For Node.js programs, it looks like this:\n\n```txt\nfile:///absolute/path/to/file.ts\n```\n\nIn CommonJS, we used to have `__filename` and `__dirname`. As an aside,\nwe can trivially rebuild them in ESM using a little path manipulation:\n\n```ts\nimport { fileURLToPath } from 'node:url'\nimport path from 'node:path'\n\nconst __filename = fileURLToPath(import.meta.url)\nconst __dirname = path.dirname(__filename)\n```\n\nI'm using this extensively [in this website](https://github.com/franky47/francoisbest.com)\nto resolve **relative paths**:\n\n```ts\nexport function resolve(importMetaUrl: string, ...paths: string[]) {\n  const dirname = path.dirname(fileURLToPath(importMetaUrl))\n  return path.resolve(dirname, ...paths)\n}\n\n// Example usage:\nresolve(import.meta.url, './relative/path.txt')\n```\n\n## The issue with Vercel and Next.js ISR\n\nThis pattern works fine when developping locally and when building the Next.js\napplication on Vercel, but it fails in an **Incremental Static Regeneration** (ISR)\ncontext:\n\n![A screenshot of the Vercel logs showing errors (detail below)](./vercel-error.png)\n\n```txt\n[Error: ENOENT: no such file or directory, open '/vercel/path0/path/to/file.txt'] {\n  errno: -2,\n  code: 'ENOENT',\n  syscall: 'open',\n  path: '/vercel/path0/path/to/file.txt'\n}\n[Error: An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.] {\n  digest: '2649891783'\n}\n```\n\nThat's because ISR runs in serverless functions, which are **bundled**, and the\nNext.js file tracer hasn't been able to pick the file we needed, as it relies\non static tracing.\n\n## The fix\n\nTo tell the file tracer to pick up our file, we have to use `process.cwd()`.\n\n<Note status=\"info\">\n`process.cwd()` will point to the root of the **Next.js application**.\n\nIn a monorepo like this website, this may be different than the **repository root**.\n\n</Note>\n\nOur `resolve` function can then be updated like this:\n\n```ts title=\"src/lib/paths.ts\"\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\n// src/lib\nconst __dirname = path.dirname(fileURLToPath(import.meta.url))\nconst nextJsRootDir = path.resolve(__dirname, '../../')\n\nexport function resolve(importMetaUrl: string, ...paths: string[]) {\n  const dirname = path.dirname(fileURLToPath(importMetaUrl))\n  const absPath = path.resolve(dirname, ...paths)\n  // Required for ISR serverless functions to pick up the file path\n  // as a dependency to bundle.\n  return path.resolve(process.cwd(), absPath.replace(nextJsRootDir, '.'))\n}\n```\n\nAnd now Vercel doesn't complain about missing files on ISR updates. 🎉\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/demo.tsx",
    "content": "import { gitHubUrl, resolve } from 'lib/paths'\nimport { Suspense } from 'react'\nimport { PiHandTap } from 'react-icons/pi'\nimport { Greetings } from './greetings'\nimport { QuerySpy } from './query-spy'\n\nexport const Demo: React.FC = async () => {\n  const sourcePath = resolve(import.meta.url, './greetings.tsx')\n  const sourceCodeUrl = gitHubUrl(sourcePath)\n  return (\n    <figure className=\"relative mx-auto mb-12 max-w-xl rounded-lg border border-dashed border-gray-300 px-4 py-2 dark:border-gray-700\">\n      <figcaption className=\"bg-light dark:bg-dark absolute -top-6 right-3 px-1 !text-sm italic text-gray-500 md:-top-6\">\n        <PiHandTap className=\"-mt-1 mr-0.5 inline-block\" /> Interactive demo\n      </figcaption>\n      <Suspense\n        fallback={\n          <div\n            className=\"flex h-56 items-center justify-center text-sm text-gray-500 sm:h-60\"\n            aria-hidden\n          >\n            Loading...\n          </div>\n        }\n      >\n        <Greetings />\n        <QuerySpy />\n      </Suspense>\n      <a href={sourceCodeUrl} className=\"text-sm\">\n        Source code\n      </a>\n    </figure>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/greetings.tsx",
    "content": "'use client'\n\nimport { useQueryState } from 'nuqs'\nimport { Input } from 'ui/components/forms/inputs'\nimport { FormControl, FormLabel } from 'ui/components/forms/structure'\n\nconst DEFAULT = 'anonymous reader'\n\nexport const Greetings = () => {\n  const [hello, setHello] = useQueryState('hello')\n  return (\n    <>\n      <FormControl name=\"hello\">\n        <FormLabel>Say hello to</FormLabel>\n        <Input\n          type=\"text\"\n          value={hello ?? ''}\n          onChange={e => setHello(e.target.value)}\n          placeholder={DEFAULT}\n        />\n      </FormControl>\n      <blockquote className=\"my-6\">\n        <p>Hello, {hello || DEFAULT}!</p>\n      </blockquote>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/index.mdx",
    "content": "---\ntitle: Storing React state in the URL with Next.js\ndescription: \"A peek under the hood of the next-usequerystate 1.8.x update with support for the app router.\"\npublicationDate: '2023-09-20'\ntags: [next.js, react, typescript]\ncanonical: /posts/2023/storing-react-state-in-the-url-with-nextjs\n---\n\nimport { FiPackage } from 'react-icons/fi'\n\nRather than keeping React UI state internal to the application memory, where it is lost on unmount and page reloads,\nwe can sync it with the [URL query string](https://en.wikipedia.org/wiki/Query_string).\nThis opens up a lot of possibilities:\n\n- **Sharing** it with others\n- **Bookmarking** it to come back to it later\n- **Restoring** it after a page reload or browser crash\n- **Navigating** it with the back and forward buttons\n\nTake this example:\n\nimport { Demo } from './demo'\n\n<Demo />\n\nA couple of years ago, I wrote a library to do this in Next.js. It's used\nby a fair amount of people, including Vercel on their dashboard.\n\n<NpmPackage\n  pkg=\"next-usequerystate\"\n  repo=\"47ng/next-usequerystate\"\n  accent=\"text-indigo-500 dark:text-indigo-400\"\n/>\n\n<br />\n\n<Note status=\"success\" title=\"Announcement\" icon={FiPackage}>\n  Version\n  [**1.8.0**](https://github.com/47ng/next-usequerystate/releases/tag/v1.8.0) of\n  [`next-usequerystate`](https://github.com/47ng/next-usequerystate) now\n  supports the app router, in Next.js 13.4+.\n</Note>\n\nFor it to support the app router, a lot of internal changes were required, due\nto some limitations in the Next.js router and the Web History API.\n\nBut it payed off, as those changes also gave us:\n\n- Optimal performance (now identical to `React.useState`)\n- Batched updates\n- SSR in Server Components with correct query values _(no more hydration tricks or errors)_\n- Better DX for creating and configuring parsers\n- Finer control over options\n\nFor more details, refer to the [documentation](https://github.com/47ng/next-usequerystate#readme),\nor give the [playground](https://next-usequerystate.vercel.app/) a try.\n\nThis post will focus on a couple of internal tricks on how to store React\nstate in the URL with Next.js.\n\n## Shallow routing\n\n<Note status=\"info\" title=\"Definition\">\n  Shallow routing is when the URL changes, but the page doesn't reload or fetch\n  data.\n</Note>\n\nThe Next.js app router doesn't support shallow routing, as of version 13.4.\n\nThis has caused a lot of [frustration](https://github.com/vercel/next.js/discussions/48110)\nfor developpers who want to update query string parameters _without_ triggering\nnetwork calls to the server.\n\nIt also slowed down initial efforts to port `next-usequerystate` to the app router,\nespecially when binding a query state to high-frequency sources, like:\n\n{/* prettier-ignore */}\n```html\n<input type=\"text\" />\n<input type=\"range\" />\n```\n\nTo solve these issues, a new direction has been taken: `next-usequerystate` uses\na **shallow mode by default**, and does so by only using the Next.js router\non non-shallow updates.\n\nThis is done by tapping directly into the [Web History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API/Working_with_the_History_API)\nfor shallow updates.\n\n## Batching & Throttling\n\nOne issue when connecting `history.replaceState()` to a slider or a text input\nis that those APIs are **rate-limited** by the browser.\n\nTo avoid hitting the limits, we can **batch** updates and **throttle** them under the\nrate limit.\n\nEmpirically, a **50ms** throttle seems enough for Firefox, Chrome and Edge.\n\n<Note status=\"warning\" title=\"Note for Safari users\">\n\nSafari has incredibly low limits for this API: **100 calls per 30 seconds**.\n\nWhile it would be possible to throttle to every ~300ms, detecting Safari to do so\nseems like a mine field that I'd rather avoid. If someone is interested in tackling\nthis issue, please [open a Pull Request](https://github.com/47ng/next-usequerystate/fork),\nand I'll happily look into it!\n\n</Note>\n\nBetween updates of the URL with `history.{set,replace}State`, we're just **queueing**\nupdates. Namely:\n\n- The key to update\n- The new value, or `null` to remove it from the URL\n- The options it requires\n\nimport UpdateQueue from './update-queue'\n\n<figure>\n  <UpdateQueue\n    className=\"w-full dark:hue-rotate-180 dark:invert\"\n    height={null}\n  />\n  <figcaption>\n    More recent values take precedence in the query resolution\n  </figcaption>\n</figure>\n\nKeeping track of options in the queue allows overriding the defaults:\n\n- If at least one item in the queue requires a non-shallow update, call the Next.js router.\n  Otherwise do a client-only history update.\n- If at least one item in the queue requires scrolling to the top, do that. Otherwise don't.\n- History is `replaceState` by default, but if one item in the queue requests a `pushState`,\n  then the whole update creates a new history entry.\n\n<Note status=\"warning\">\n  This last one may cause inconsistencies with state navigation with the back\n  button, depending on what is queued alongside the `push` update.\n</Note>\n\n## Syncing state\n\nNow that the URL updates asynchronously, we need a way to return a state value\nthat corresponds to what **will be** stored in the URL. It also needs to play\nwell with standard Next.js navigation with `<Link>` and imperative routing calls.\n\nInternally, the hooks use an individual React state, which get synced by query key\nusing an [event emitter](https://github.com/developit/mitt).\n\nThis emitter is also used to transmit sync triggers from the history API, when\nexternal navigation occurs _(not query updates)_. This is done by [patching](https://github.com/47ng/next-usequerystate/blob/32c764403b86ced7a676470eb4bce4f5c6697945/src/lib/sync.ts#L28-L81)\nthe History methods.\n\nTo distinguish between internal and external navigation events, we use the\nunused `title` second parameter of `replaceState`.\n\n<Note status=\"info\" title={<>A bit of history <small><em>(no pun intended)</em></small></>}>\n  This unused parameter was originally intended to change the page title in the\n  history stack, but never got good browser support, and was later on marked as\n  deprecated.\n\nThe [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState#parameters) for `replaceState` states that this parameter should be\nset to an empty string for compatibility with older browsers.\n\n</Note>\n\nWe use this to our advantage, and set it to a special marker value when we're\nupdating the URL internally for query changes. The patched `replaceState` method\ncan then detect when it's called from the outside _(when the `title` parameter\nis **not** equal to our internal marker)_, and trigger a sync.\n\n## Optimising for performance\n\n> Make it work, make it right, make it fast. In that order.\n>\n> <figcaption>Kent Beck</figcaption>\n\nOne advantage of using individual React states per hook is the performance:\nupdates are synchronously propagated to all hooks, keeping the UI snappy.\n\nThere are a couple of tricks to make this work with **state updater functions** though.\n\nIf two hooks are updated in the same event loop with SUFs, we want the second one\nto be based on the result of the first one, as we would expect the URL to\n**eventually** behave:\n\n{/* prettier-ignore */}\n```ts\nconst [count, setCount] = useQueryState(\n  'count',\n  parseAsInteger.withDefault(0)\n)\n\nfunction onClick() {\n  setCount(x => x + 1)\n  setCount(x => x * 2)\n}\n\n// First click: counter = 0\n// batch update: counter = 1 (0 + 1)\n// batch update: counter = 2 (1 * 2)\n\n// Second click: counter = 2\n// batch update: counter = 3 (2 + 1)\n// batch update: counter = 6 (3 * 2)\n```\n\nIn order to avoid recreating the state updater function for each state change,\nwhich **breaks referential equality** and de-optimises a lot of consuming code,\nwe use a **ref** to store the **last known state** to be applied to the URL.\n\nThis is known as the [latest ref pattern](https://epicreact.dev/the-latest-ref-pattern-in-react/).\n\n### Update queue performance\n\nWhile writing this post, I realised using an array for the update queue was kind\nof dumb: since each item overrides previous ones with the same key, we can use\na **Map** instead, and only iterate over available keys on update.\n\n<Note status=\"success\">\n  This performance update was published in\n  [`1.8.1`](https://github.com/47ng/next-usequerystate/releases/tag/v1.8.1).\n</Note>\n\n## Next.js specifics\n\nSo far, we've described a system that would work for any React framework.\nWhat makes it specific to Next.js is that it handles **server-side rendering**\nwith correct query values. Those values can also be\n[parsed server-side](https://github.com/47ng/next-usequerystate#using-parsers-in-server-components).\n\nThe `shallow: false` option also allows notifying the server to query updates,\nto re-render server components, or run `getServerSideProps` in the pages router.\n\n<Note>\n  If someone wishes to adapt this code for other frameworks, go ahead and [leave\n  a little\n  note](https://github.com/47ng/next-usequerystate/discussions/new?category=show-and-tell)\n  for attribution, and I'll be happy to link it here and in the repository.\n</Note>\n\n## Credits\n\nA lot of thanks to everyone who helped testing this update, and especially:\n\n- [Andrei Socaciu](https://github.com/andreisocaciu) for laying the\n  ground work, in [#328](https://github.com/47ng/next-usequerystate/pull/328).\n- [Pierre Spring](https://fosstodon.org/@caillou) for early performance testing.\n- [Drew Goodwin](https://github.com/tacomanator) for fishing out two race-conditions\n  on initial navigation, in [#343](https://github.com/47ng/next-usequerystate/discussions/343#discussioncomment-6975945).\n- [Ryan Walsh Forte](https://github.com/ryan-walsh-forte) for uncovering a race\n  condition in state updater functions, in [#345](https://github.com/47ng/next-usequerystate/issues/345).\n- [Rich](https://github.com/r1chm8) for the idea of making the parsers\n  server-side accessible to let server components hydrate and validate query values,\n  in [#348](https://github.com/47ng/next-usequerystate/discussions/348).\n- [Jamie Diprose](https://github.com/jdddog) for uncovering issues with module\n  resolution, in [#352](https://github.com/47ng/next-usequerystate/discussions/352).\n\nAnd thanks to Erfan for featuring this post in [Next.js Weekly \\#24](https://nextjsweekly.com/issues/24)!\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/query-spy.tsx",
    "content": "'use client'\n\nimport Link from 'next/link'\nimport { useSearchParams } from 'next/navigation'\nimport React from 'react'\nimport { Button } from 'ui/components/buttons/button'\n\nexport const QuerySpy: React.FC = () => {\n  const searchParams = useSearchParams()\n\n  return (\n    <figure className=\"not-prose !my-0\">\n      <figcaption className=\"flex items-center justify-between text-sm font-medium\">\n        <span>Query string</span>\n        <Link href=\"?\" replace scroll={false} tabIndex={-1}>\n          <Button size=\"xs\" variant=\"ghost\">\n            Clear\n          </Button>\n        </Link>\n      </figcaption>\n      <pre\n        aria-label=\"Querystring spy - For browsers where the query is hard to see (eg: on mobile)\"\n        className=\"my-2 overflow-auto rounded-sm border border-gray-200 bg-gray-50/50 !p-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:shadow-inner\"\n      >\n        {searchParams.size === 0 && (\n          <span className=\"italic text-gray-500/50\">{'<empty>'}</span>\n        )}\n        {Array.from(searchParams.entries()).map(([key, value], i) => (\n          <React.Fragment key={key + value + i}>\n            <span className=\"text-gray-500\">{i === 0 ? '?' : '&'}</span>\n            <span className=\"text-blue-600 dark:text-blue-400\">{key}</span>\n            <span className=\"text-current\">=</span>\n            <span className=\"text-pink-600 dark:text-pink-400\">\n              {value.replaceAll(' ', '+')}\n            </span>\n          </React.Fragment>\n        ))}\n      </pre>\n    </figure>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/storing-react-state-in-the-url-with-nextjs/update-queue.tsx",
    "content": "export default function UpdateQueue(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 532.668 184.591\" {...props}>\n<defs><style className=\"style-fonts\">{`@font-face{font-family:&quot;Virgil&quot;;src:url(https://excalidraw.com/Virgil.woff2)}@font-face{font-family:&quot;Cascadia&quot;;src:url(https://excalidraw.com/Cascadia.woff2)}`}</style></defs><g fillOpacity=\".7\" strokeLinecap=\"round\" strokeOpacity=\".7\"><path fill=\"none\" stroke=\"#1e1e1e\" strokeWidth=\"1\" d=\"M22.05 0 M22.05 0 C41.17 0.7, 63.7 0.58, 99.51 0 M22.05 0 C53.93 0.13, 84.59 0.07, 99.51 0 M99.51 0 C116.09 0.15, 120.53 8.19, 121.56 22.05 M99.51 0 C114.29 1.36, 120.88 6.14, 121.56 22.05 M121.56 22.05 C122.08 34.96, 119.68 47.94, 121.56 66.14 M121.56 22.05 C120.47 33.43, 120.87 46.22, 121.56 66.14 M121.56 66.14 C123.21 82.34, 112.59 86.66, 99.51 88.19 M121.56 66.14 C123.01 82.41, 112.72 88.45, 99.51 88.19 M99.51 88.19 C83.22 89.04, 65.13 87.96, 22.05 88.19 M99.51 88.19 C78.72 88.37, 58.61 88.53, 22.05 88.19 M22.05 88.19 C8.19 88.18, -0.51 80.51, 0 66.14 M22.05 88.19 C6.59 87.57, -1.9 81.2, 0 66.14 M0 66.14 C-1.7 53.15, -1.89 41.36, 0 22.05 M0 66.14 C-0.35 55.28, 0.01 43.54, 0 22.05 M0 22.05 C-1.29 6.33, 8.4 -0.73, 22.05 0 M0 22.05 C1.89 5.82, 6.64 -0.44, 22.05 0\" transform=\"translate(10 11.572836264927446) rotate(0 60.77812374345956 44.09298695839243)\"/></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"17.433\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(29.18926133398554 25.499168763756984) rotate(0 17.433332443237305 12.5)\">key:</text></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"16.95\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(30.71442130083699 58.83767393347375) rotate(0 16.950000762939453 12.5)\">val:</text></g><g><text x=\"0\" y=\"0\" fill=\"#1971c2\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(77.09354882141133 25.83422868361646) rotate(0 15.933333396911621 12.5)\">foo</text></g><g><text x=\"16.05\" y=\"0\" fill=\"#c2255c\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(79.30771928006891 59.17273385333323) rotate(0 16.049999237060547 12.5)\">bar</text></g><g fillOpacity=\".7\" strokeLinecap=\"round\" strokeOpacity=\".7\"><path fill=\"none\" stroke=\"#1e1e1e\" strokeWidth=\"1\" d=\"M22.05 0 M22.05 0 C42.96 2.53, 61.18 0.59, 99.51 0 M22.05 0 C47.64 0.14, 70.82 0.44, 99.51 0 M99.51 0 C114.34 -1.62, 122.51 7.1, 121.56 22.05 M99.51 0 C114.22 0.12, 123.11 8.48, 121.56 22.05 M121.56 22.05 C119.23 36.82, 120.93 46.99, 121.56 66.14 M121.56 22.05 C121.74 36.44, 121.5 48.75, 121.56 66.14 M121.56 66.14 C119.69 79.26, 114.62 87.69, 99.51 88.19 M121.56 66.14 C119.48 83.07, 112.26 89.21, 99.51 88.19 M99.51 88.19 C73.63 87.66, 48.06 90.29, 22.05 88.19 M99.51 88.19 C73.71 88.49, 48.1 87.55, 22.05 88.19 M22.05 88.19 C8.96 89.03, 0.19 81.45, 0 66.14 M22.05 88.19 C6.53 87.19, -0.09 80.64, 0 66.14 M0 66.14 C-0.3 55.95, -1.02 46.74, 0 22.05 M0 66.14 C0.34 53.11, -0.46 40.36, 0 22.05 M0 22.05 C1.88 6.06, 6.03 1.96, 22.05 0 M0 22.05 C2.18 8.83, 5.2 -1.16, 22.05 0\" transform=\"translate(270.04226246798964 10) rotate(0 60.77812374345956 44.09298695839243)\"/></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"17.433\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(289.2315238019752 23.4020537438538) rotate(0 17.433332443237305 12.5)\">key:</text></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"16.95\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(290.75668376882663 56.74055891357057) rotate(0 16.950000762939453 12.5)\">val:</text></g><g><text x=\"0\" y=\"0\" fill=\"#1971c2\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(337.135811289401 24.261392418689013) rotate(0 15.933333396911621 12.5)\">foo</text></g><g><text x=\"15.5\" y=\"0\" fill=\"#c2255c\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(339.8999809851191 57.59989758840578) rotate(0 15.5 12.5)\">egg</text></g><g fillOpacity=\".7\" strokeLinecap=\"round\" strokeOpacity=\".7\"><path fill=\"none\" stroke=\"#1e1e1e\" strokeWidth=\"1\" d=\"M22.05 0 M22.05 0 C47.32 -0.15, 73.45 0.43, 99.51 0 M22.05 0 C50.39 0.89, 78.33 0.5, 99.51 0 M99.51 0 C114.46 -1.07, 120.7 7.09, 121.56 22.05 M99.51 0 C113.43 -0.67, 120.51 6.91, 121.56 22.05 M121.56 22.05 C120.31 31.83, 123.67 45.01, 121.56 66.14 M121.56 22.05 C120.44 35.07, 120.78 48.19, 121.56 66.14 M121.56 66.14 C122.1 81.25, 112.46 87.91, 99.51 88.19 M121.56 66.14 C120.06 79.33, 113.26 88.79, 99.51 88.19 M99.51 88.19 C77.27 87.86, 59.1 87.51, 22.05 88.19 M99.51 88.19 C75.33 88.3, 49.91 88.57, 22.05 88.19 M22.05 88.19 C9.06 87.3, -1.28 82.78, 0 66.14 M22.05 88.19 C5.14 87.29, -1.47 79.81, 0 66.14 M0 66.14 C-1.43 50.09, 1.33 31.42, 0 22.05 M0 66.14 C-0.8 49.76, 0.25 31.77, 0 22.05 M0 22.05 C1.45 5.42, 5.83 1.24, 22.05 0 M0 22.05 C0.59 8.3, 5.89 1.48, 22.05 0\" transform=\"translate(138.9725737240434 11.048557509951593) rotate(0 60.77812374345956 44.09298695839243)\"/></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"17.433\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(151.34621124334365 23.926332498829538) rotate(0 17.433332443237305 12.5)\">key:</text></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"16.95\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(152.8713712101951 56.74055891357057) rotate(0 16.950000762939453 12.5)\">val:</text></g><g><text x=\"0\" y=\"0\" fill=\"#1971c2\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(199.25049873076944 24.261392418689013) rotate(0 26.549999237060547 12.5)\">count</text></g><g><text x=\"2.717\" y=\"0\" fill=\"#c2255c\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(214.79800172803175 57.59989758840578) rotate(0 2.7166666984558105 12.5)\">1</text></g><g fillOpacity=\".7\" strokeLinecap=\"round\" strokeOpacity=\".7\"><path fill=\"none\" stroke=\"#1e1e1e\" strokeWidth=\"1\" d=\"M22.05 0 M22.05 0 C42.41 -0.92, 66.59 1.36, 99.51 0 M22.05 0 C48.9 0.8, 75.9 0.83, 99.51 0 M99.51 0 C115.61 1.08, 122.77 9.2, 121.56 22.05 M99.51 0 C116.06 -1.84, 121.62 5.59, 121.56 22.05 M121.56 22.05 C119.89 36.17, 123.56 47.02, 121.56 66.14 M121.56 22.05 C122.4 35.46, 121.17 49.86, 121.56 66.14 M121.56 66.14 C119.75 80.15, 115.01 88.53, 99.51 88.19 M121.56 66.14 C121.62 81.51, 112.45 87.22, 99.51 88.19 M99.51 88.19 C83.69 89.33, 64.44 89.15, 22.05 88.19 M99.51 88.19 C80.94 88.02, 63.99 89.22, 22.05 88.19 M22.05 88.19 C8 89.72, -0.5 82.65, 0 66.14 M22.05 88.19 C6.84 87.06, -0.25 78.89, 0 66.14 M0 66.14 C0.14 48.66, 0.45 33.63, 0 22.05 M0 66.14 C0.14 49.85, 0.6 32.68, 0 22.05 M0 22.05 C1.95 7.16, 9.15 -0.09, 22.05 0 M0 22.05 C1.24 9.47, 5.68 -0.72, 22.05 0\" transform=\"translate(401.11195121193623 10.524278754975796) rotate(0 60.77812374345956 44.09298695839246)\"/></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"17.433\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(413.4855887312365 24.97489000878113) rotate(0 17.433332443237305 12.5)\">key:</text></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"16.95\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(415.0107486980879 58.3133951784979) rotate(0 16.950000762939453 12.5)\">val:</text></g><g><text x=\"0\" y=\"0\" fill=\"#1971c2\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(461.38987621866227 25.834228683616345) rotate(0 26.549999237060547 12.5)\">count</text></g><g><text x=\"7.117\" y=\"0\" fill=\"#c2255c\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(472.53737912055715 58.648455098357374) rotate(0 7.116666793823242 12.5)\">2</text></g><g strokeLinecap=\"round\"><g fillOpacity=\".7\" strokeOpacity=\".7\"><path fill=\"none\" stroke=\"#1e1e1e\" strokeWidth=\"1\" d=\"M1.03 -0.9 C82.82 -1, 409.07 -1.16, 490.67 -0.94 M0.12 1.24 C81.79 1.29, 408.31 0.6, 489.96 0.11\" transform=\"translate(19.357497062524146 124.64333555206156) rotate(0 245.3910906803373 0.09478785067594231)\"/></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><path fill=\"none\" stroke=\"#1e1e1e\" strokeWidth=\"1\" d=\"M460.3 11.25 C468.59 7.81, 479.17 2.37, 491.03 -0.27 M461.94 10.61 C470.73 7.03, 478.6 4.73, 489.53 0.23\" transform=\"translate(19.357497062524146 124.64333555206156) rotate(0 245.3910906803373 0.09478785067594231)\"/></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><path fill=\"none\" stroke=\"#1e1e1e\" strokeWidth=\"1\" d=\"M460.23 -9.27 C468.47 -6.66, 479.08 -6.04, 491.03 -0.27 M461.87 -9.91 C470.61 -7.45, 478.5 -3.69, 489.53 0.23\" transform=\"translate(19.357497062524146 124.64333555206156) rotate(0 245.3910906803373 0.09478785067594231)\"/></g></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"0\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(98.53105074805319 147.442932419846) rotate(0 41.25 12.5)\">result: ?</text></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"0\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(218.47138148158956 149.59111744790937) rotate(0 6.166666507720947 12.5)\">=</text></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"0\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(266.44751377500415 148.15899409586706) rotate(0 7.183333396911621 12.5)\">&amp;</text></g><g><text x=\"0\" y=\"0\" fill=\"#1971c2\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(184.25664345889345 147.4240006943204) rotate(0 15.933333396911621 12.5)\">foo</text></g><g><text x=\"15.5\" y=\"0\" fill=\"#c2255c\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(233.5648220959838 144.9594220629815) rotate(0 15.5 12.5)\">egg</text></g><g><text x=\"0\" y=\"0\" fill=\"#1971c2\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(282.7324880513945 146.13259025516334) rotate(0 26.549999237060547 12.5)\">count</text></g><g><text x=\"7.117\" y=\"0\" fill=\"#c2255c\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"middle\" style={{ whiteSpace: 'pre' }} transform=\"translate(354.02917173906303 147.08207208696484) rotate(0 7.116666793823242 12.5)\">2</text></g><g fillOpacity=\".7\" strokeOpacity=\".7\"><text x=\"0\" y=\"0\" fill=\"#343a40\" direction=\"ltr\" dominantBaseline=\"text-before-edge\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} transform=\"translate(339.4858047291576 147.44293241984604) rotate(0 6.166666507720947 12.5)\">=</text></g>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/testing-against-every-nextjs-canary-release/index.mdx",
    "content": "---\ntitle: Testing against every Next.js canary release\ndescription: How to run a GitHub Actions workflow when a new pre-release version of Next.js is published.\npublicationDate: '2023-11-06'\ntags: [next.js, github-actions, testing]\n---\n\nI recently got a [bug report](https://github.com/47ng/next-usequerystate/issues/388)\non [`next-usequerystate`](https://github.com/47ng/next-usequerystate)\nthat was only present in a recent canary _(pre-release)_ version of Next.js.\n\nInitially, I shrugged it off as not wanting to maintain support for canary\nreleases, since the Next.js team tends to push those out a couple of times a day.\n\nBut since the library is subject to the App router's whims, I'd like to make\nsure I catch issues as early as possible, without waiting for the next stable\nrelease.\n\nThis is something that is prime for **automation**: how can I run the CI suite\non _every_ new canary release of Next.js?\n\nUnfortunately, doing so in a \"clean\" manner would require setting up a WebHook\non the Next.js repository, which is not an option. There may be other services\nout there that provide RSS feeds for releases or a way to subscribe to new\nreleases, but I don't want to depend on a third-party service for this.\n\nInstead, I'll use a good old **cron** job to check for new releases every 15 minutes,\nusing the GitHub API, and trigger a workflow run if a new release is found.\n\nBut first, we need something to trigger.\n\n## GitHub Actions workflow\n\nUsing the `workflow_dispatch` event, I can trigger a run manually from the\nGitHub web UI, but also from the REST API.\n\n```yml title=\"test-against-nextjs-release.yml\" /workflow_dispatch/\nname: 'Test against Next.js release'\n\non:\n  workflow_dispatch:\n```\n\nThen I can define a `version` input for it, to tell the CI suite which version\nof Next.js to install and test against:\n\n```yml title=\"test-against-nextjs-release.yml\" /version/1-2\n# Bonus: make it look nice in the runs list\nrun-name: 'Test against Next.js ${{ inputs.version }}'\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Next.js version to test against'\n        required: true\n        type: string\n```\n\nFinally, I add the rest of the CI suite, along with a custom install step\nto override the Next.js version in the playground where the end-to-end tests run:\n\n```yml title=\"test-against-nextjs-release.yml\" /pnpm add --filter playground next@${{ inputs.version }}/\nname: 'Test against Next.js release'\n\n# Bonus: make it look nice in the runs list\nrun-name: 'Test against Next.js ${{ inputs.version }}'\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'Next.js version to test against'\n        required: true\n        type: string\n\njobs:\n  test_against_nextjs_release:\n    name: Integration\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11\n      - uses: pnpm/action-setup@d882d12c64e032187b2edb46d3a0d003b7a43598\n        with:\n          version: 8\n      - uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65\n        with:\n          node-version: lts/*\n          cache: pnpm\n      - name: Install dependencies\n        run: pnpm install\n      - name: Install Next.js version ${{ inputs.version }}\n        run: pnpm add --filter playground next@${{ inputs.version }}\n      - name: Run integration tests\n        run: pnpm run ci\n        env:\n          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}\n          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}\n```\n\nimport Link from 'next/link'\n\n<Note status=\"info\" title=\"Why all the SHA-1?\">\n  I like to pin my dependencies to a specific commit, and Dependabot is smart\n  enough to update them for me.{' '}\n  <Link href=\"/posts/2020/the-security-of-github-actions\">Read more</Link>.\n</Note>\n\nNow I can trigger this manually from the GitHub web UI:\n\n![Screenshot from the GitHub web UI. A popup asks for a branch to use, and a text input for the \"version\" reads \"14.0.2-canary.12\".](./trigger-workflow.png)\n\nTo do the same from the REST API, I can call:\n\n```bash\ncurl -s\n  -X POST\n  -H \"Accept: application/vnd.github.v3+json\"\n  -H \"Authorization: token $GITHUB_TOKEN\"\n  -H \"X-GitHub-Api-Version: 2022-11-28\"\n  -d '{\"ref\":\"next\",\"inputs\":{\"version\":\"14.0.2-canary.12\"}}'\n  \"https://api.github.com/repos/47ng/next-usequerystate/actions/workflows/test-against-nextjs-release.yml/dispatches\"\n```\n\nThis will require a Personal Access Token with the `repo` scope, or a fine-grained\ntoken with the `Actions:write` permission. I went with the latter as they\ncan expire and allow for rotation. This will run on my Raspberry Pi at home.\n\n## Delayed windowing\n\nThe cron script will run every 15 minutes, but I don't want to simply look for\nreleases made in the _\"last 15 minutes\"_, for a couple of reasons:\n\n1. The Next.js release script might take a while to complete (in case the package\n   on NPM is only available **after** the GitHub release has been published).\n2. A release might be rolled back or unpublished\n\nTo do that, the cron script will look for releases made between 15 and 30 minutes\n**before its invocation**:\n\nimport Windowing from './windowing'\n\n<Windowing\n  className=\"mb-8 dark:hue-rotate-180 dark:invert\"\n  aria-label=\"The arrow of time is divided in 15 minute intervals. A release 'a' is published around 12:06, which is handled in the 12:30 cron run. Another release 'b' is published around 12:35, which is handled in the 13:00 cron run. Finally, two releases 'c' and 'd' are published close to each other at 12:50 and 12:55, and both handled in the 13:15 cron run.\"\n/>\n\nNotice how there may be more than one release in a 15-minute window? The script will\nneed to handle that too, to run the workflow **for each** release. This will help\nfish out which one introduced an issue.\n\n## Cron script\n\nHere's the final cron script _(big thanks to the LLMs, I'm not a Bash person)_:\n\n```bash\n#!/usr/bin/env bash\n# crontab -e:\n# */15 * * * * /home/pi/next-usequerystate/watch-nextjs-releases.sh\n\nset -e\n\n# Use a classic PAT with the `repo` scope,\n# or a fine-grained access token with the Actions:write permission.\nTOKEN=your-github-token-here\n\n# The repository to read Releases from\nSOURCE_REPO=vercel/next.js\n\n# Configure where to trigger workflow calls\nTARGET_REPO=47ng/next-usequerystate\nTARGET_WORKFLOW=test-against-nextjs-release.yml\nTARGET_BRANCH=next\n\n# Delayed window: give time for the release to be stabilised or revoked\nt_start=$(date -u --date=\"30 minutes ago\" +\"%Y-%m-%dT%H:%M:%SZ\")\nt_end=$(date -u --date=\"15 minutes ago\" +\"%Y-%m-%dT%H:%M:%SZ\")\n\n# Query the GitHub API for releases\nresponse=$(curl -s \"https://api.github.com/repos/$SOURCE_REPO/releases\")\n\n# Only keep releases in the window\nrecent_releases=$(            \\\n  echo \"$response\"            \\\n  | jq                        \\\n    --arg t_start \"$t_start\"  \\\n    --arg t_end \"$t_end\"      \\\n    '.[] | select(.published_at >= $t_start and .published_at <= $t_end)' \\\n  )\n\necho \"Time range: $t_start -> $t_end\"\necho \"Releases:\"\necho $recent_releases | jq -r '.name'\n\ntrigger_workflow() {\n  local version=\"$1\"\n  local trigger_url=\"https://api.github.com/repos/$TARGET_REPO/actions/workflows/$TARGET_WORKFLOW/dispatches\"\n  local data=\"{\\\"ref\\\":\\\"$TARGET_BRANCH\\\",\\\"inputs\\\":{\\\"version\\\":\\\"$version\\\"}}\"\n  local response=$(                               \\\n    curl                                          \\\n      -s                                          \\\n      -X POST                                     \\\n      -H \"Authorization: token $TOKEN\"            \\\n      -H \"Accept: application/vnd.github.v3+json\" \\\n      -d \"$data\"                                  \\\n      \"$trigger_url\"                              \\\n  )\n  echo \"$response\"\n  echo \"Triggered workflow for version $version\"\n}\n\nfor release in $(echo \"$recent_releases\" | jq -r '.name'); do\n  # Remove the leading 'v' from the release name\n  version=\"${release#v}\"\n  trigger_workflow \"$version\"\ndone\n```\n\n<Note status=\"info\" title=\"TIL about matching in parameter expansion\" icon=\"🤩\">\n\nYou can do [this](https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html) in Bash apparently, no `sed` required:\n\n```bash\n# Remove the leading 'v' from the release name\nversion=\"${release#v}\"\n```\n\n</Note>\n\n## What's next for useQueryState?\n\nNext up on the [roadmap](https://github.com/47ng/next-usequerystate/issues/375)\nis to rework the documentation, as the README starts becoming a bit too long\nto read comfortably, and merge it with the [playground](https://next-usequerystate.vercel.app),\nto show actual use-cases and examples.\n\nIn the mean time, you can find the project here:\n\n<NpmPackage pkg=\"next-usequerystate\" repo=\"47ng/next-usequerystate\">\n\n<div className=\"pointer-events-none absolute -bottom-4 right-8 md:right-12\">\n  ![A cheeky canary](./canary-small.png)\n</div>\n\n</NpmPackage>\n\n<Note title=\"Hey, Vercel!\" icon=\"👀\">\n  If anyone at Vercel reads this, it looks like your Dashboard is still using\n  `1.8.0-beta.11`, you might want to update. 😉\n</Note>\n"
  },
  {
    "path": "packages/francoisbest.com/content/blog/2023/testing-against-every-nextjs-canary-release/windowing.tsx",
    "content": "export default function Windowing(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 745.529077934857 217.11590076898455\" {...props}>\n{/* svg-source:excalidraw */}<defs><style className=\"style-fonts\">{`@font-face {\n        font-family: &quot;Virgil&quot;;\n        src: url(&quot;https://excalidraw.com/Virgil.woff2&quot;);\n      }\n      @font-face {\n        font-family: &quot;Cascadia&quot;;\n        src: url(&quot;https://excalidraw.com/Cascadia.woff2&quot;);\n      }`}</style></defs><g strokeLinecap=\"round\" transform=\"translate(157.03306911285904 40.6642028749111) rotate(0 5.386134419624682 5.386134419624625)\"><path d=\"M5.33 -0.06 C6.4 -0.17, 7.66 0.47, 8.56 1.2 C9.46 1.93, 10.5 3.21, 10.74 4.31 C10.98 5.41, 10.54 6.79, 10 7.81 C9.47 8.82, 8.6 9.99, 7.53 10.42 C6.45 10.86, 4.67 10.79, 3.57 10.42 C2.48 10.06, 1.54 9.2, 0.95 8.24 C0.36 7.28, -0.13 5.79, 0.01 4.66 C0.15 3.53, 0.91 2.22, 1.79 1.45 C2.67 0.68, 4.75 0.31, 5.3 0.07 C5.86 -0.18, 5.11 -0.08, 5.11 -0.01 M4.98 -0.02 C6.07 -0.12, 7.87 0.63, 8.83 1.32 C9.79 2, 10.48 2.98, 10.74 4.1 C11 5.23, 10.91 7.04, 10.4 8.05 C9.88 9.06, 8.71 9.76, 7.64 10.15 C6.56 10.54, 5.1 10.71, 3.94 10.38 C2.78 10.06, 1.35 9.11, 0.68 8.2 C0.01 7.29, -0.24 6.09, -0.09 4.93 C0.05 3.77, 0.64 2.11, 1.53 1.26 C2.41 0.4, 4.62 0.03, 5.23 -0.2 C5.83 -0.43, 5.14 -0.2, 5.15 -0.11\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g><g strokeLinecap=\"round\" transform=\"translate(472.9982368069991 42.51365315904741) rotate(0 5.386134419624682 5.386134419624625)\"><path d=\"M4.85 -0.06 C5.91 -0.22, 7.5 0.33, 8.43 1.04 C9.36 1.75, 10.14 3.11, 10.44 4.2 C10.75 5.3, 10.74 6.61, 10.25 7.61 C9.76 8.61, 8.59 9.72, 7.51 10.22 C6.44 10.71, 4.91 10.89, 3.8 10.58 C2.7 10.28, 1.53 9.34, 0.89 8.37 C0.25 7.41, -0.2 5.91, -0.05 4.78 C0.1 3.65, 0.94 2.42, 1.78 1.61 C2.63 0.8, 4.48 0.14, 5.01 -0.09 C5.53 -0.31, 4.91 0.12, 4.94 0.24 M5.82 0.1 C6.93 0.11, 8.43 0.53, 9.21 1.35 C10 2.17, 10.43 3.85, 10.53 5.01 C10.62 6.18, 10.39 7.43, 9.79 8.36 C9.18 9.28, 7.98 10.2, 6.9 10.56 C5.82 10.93, 4.39 11.02, 3.3 10.54 C2.21 10.06, 0.92 8.71, 0.37 7.67 C-0.18 6.64, -0.29 5.44, -0.01 4.35 C0.27 3.26, 1.1 1.84, 2.04 1.12 C2.98 0.41, 5.01 0.17, 5.64 0.03 C6.26 -0.11, 5.75 0.17, 5.79 0.28\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g><g strokeLinecap=\"round\" transform=\"translate(362.58021308250693 40.0477194468657) rotate(0 5.386134419624682 5.386134419624625)\"><path d=\"M5.66 -0.11 C6.72 -0.12, 8.18 0.63, 9.01 1.42 C9.84 2.21, 10.46 3.5, 10.62 4.63 C10.78 5.76, 10.57 7.2, 9.97 8.2 C9.37 9.19, 8.1 10.23, 7 10.6 C5.9 10.98, 4.43 10.88, 3.37 10.42 C2.31 9.96, 1.16 8.89, 0.64 7.84 C0.12 6.8, 0.01 5.26, 0.24 4.15 C0.48 3.04, 1.14 1.87, 2.08 1.2 C3.02 0.53, 5.28 0.32, 5.88 0.12 C6.47 -0.08, 5.72 -0.04, 5.68 0.01 M5.04 0.02 C6.18 -0.09, 7.91 0.16, 8.82 0.89 C9.73 1.62, 10.26 3.25, 10.49 4.41 C10.71 5.56, 10.7 6.79, 10.17 7.81 C9.63 8.84, 8.31 10.09, 7.28 10.55 C6.25 11.01, 5.05 10.96, 3.98 10.59 C2.92 10.22, 1.5 9.31, 0.87 8.35 C0.24 7.38, 0.09 5.97, 0.2 4.79 C0.31 3.61, 0.72 2.09, 1.54 1.26 C2.37 0.43, 4.56 -0.02, 5.16 -0.17 C5.76 -0.33, 5.12 0.21, 5.14 0.34\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g><g strokeLinecap=\"round\"><g transform=\"translate(27.860160051462117 130.89890906392304) rotate(0 353.7795275612878 0.031108930843828375)\"><path d=\"M-0.11 0.13 C117.85 0.11, 589.73 -0.06, 707.67 -0.07 M0.1 0.07 C118.04 0.07, 589.62 0.13, 707.57 0.09\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(27.860160051462117 130.89890906392304) rotate(0 353.7795275612878 0.031108930843828375)\"><path d=\"M679.38 10.35 C686.89 7.79, 693.77 4.82, 707.57 0.09 M679.38 10.35 C689.07 6.85, 698.71 3.27, 707.57 0.09\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(27.860160051462117 130.89890906392304) rotate(0 353.7795275612878 0.031108930843828375)\"><path d=\"M679.38 -10.17 C686.89 -7.45, 693.77 -5.12, 707.57 0.09 M679.38 -10.17 C689.07 -6.63, 698.71 -3.17, 707.57 0.09\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(122.402558074968 125.26982054746225) rotate(0 0.020587493112145694 7.043403752321524)\"><path d=\"M0.09 -0.07 C0.1 2.24, 0.06 11.67, 0.07 14.01 M-0.05 0.21 C-0.05 2.6, -0.03 11.85, -0.01 14.16\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(338.1099931975265 124.03685369137145) rotate(0 0.07258064072851766 6.965432882451694)\"><path d=\"M-0.03 -0.14 C-0.03 2.17, -0.1 11.57, -0.1 13.95 M0.22 0.1 C0.27 2.42, 0.24 11.77, 0.18 14.07\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(225.84824459993513 125.26982054746225) rotate(0 -0.03751398453027832 7.0121312040695045)\"><path d=\"M-0.1 -0.13 C-0.1 2.25, -0.06 11.8, -0.05 14.16 M0.12 0.12 C0.09 2.47, -0.15 11.6, -0.19 13.94\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(558.1945770097323 124.03685369137145) rotate(0 -0.006062649996295022 7.02747877593859)\"><path d=\"M-0.14 -0.14 C-0.1 2.18, 0.1 11.67, 0.12 14.03 M0.06 0.11 C0.09 2.51, 0.09 11.85, 0.07 14.19\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(445.9328284121409 125.26982054746225) rotate(0 0.0016028878887937026 7.073760277029635)\"><path d=\"M0.14 0.05 C0.16 2.43, -0.01 11.86, -0.02 14.2 M0.04 -0.05 C0.04 2.3, -0.14 11.61, -0.14 14\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\" transform=\"translate(118.50108333598939 63.23322338977198) rotate(0 51.47636624179046 7.089559422522029)\"><path d=\"M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.2 8.12 C1.94 5.88, 4.15 3.65, 6.76 0.57 M0.2 8.12 C2.12 5.9, 4.17 3.49, 6.76 0.57 M1.25 13.01 C4.77 9.22, 7.81 5.79, 12.4 0.18 M1.25 13.01 C4.48 9.51, 7.61 5.7, 12.4 0.18 M4.92 14.88 C8.07 11.03, 11.15 7.89, 17.39 0.54 M4.92 14.88 C8.24 11, 11.39 7.33, 17.39 0.54 M10.56 14.48 C13.1 11.72, 15.44 9, 22.37 0.9 M10.56 14.48 C14.67 9.85, 18.52 5.37, 22.37 0.9 M15.55 14.84 C18.45 10.9, 21.92 7.96, 28.02 0.5 M15.55 14.84 C19.74 9.88, 23.9 5.25, 28.02 0.5 M21.19 14.45 C24.68 10.83, 27.55 6.49, 33 0.86 M21.19 14.45 C24.28 11.04, 27.1 7.64, 33 0.86 M26.18 14.81 C29.16 11.53, 31.77 8.99, 38.65 0.47 M26.18 14.81 C30.1 10.24, 34.32 5.4, 38.65 0.47 M31.82 14.41 C35.36 10.15, 39.73 5.84, 43.63 0.83 M31.82 14.41 C35.07 10.81, 38.28 6.81, 43.63 0.83 M36.81 14.77 C40.74 10.05, 44.9 5.68, 49.28 0.44 M36.81 14.77 C39.48 11.94, 42.09 8.96, 49.28 0.44 M42.45 14.38 C44.95 11.49, 47.97 7.93, 54.26 0.8 M42.45 14.38 C45.08 11.62, 47.58 8.74, 54.26 0.8 M47.44 14.74 C51.48 9.83, 55.52 5.45, 59.91 0.4 M47.44 14.74 C50.99 10.7, 54.17 6.84, 59.91 0.4 M53.08 14.35 C57.06 9.38, 61.81 4.79, 64.89 0.76 M53.08 14.35 C56.75 10.08, 60.1 6.25, 64.89 0.76 M58.07 14.71 C61.93 10.21, 65.14 6.11, 70.54 0.37 M58.07 14.71 C60.52 11.74, 63.31 8.91, 70.54 0.37 M63.72 14.31 C68.03 9.36, 72.6 3.85, 75.52 0.73 M63.72 14.31 C67.67 9.85, 71.52 5.46, 75.52 0.73 M68.7 14.67 C71.89 10.94, 75.17 7.31, 81.17 0.33 M68.7 14.67 C73.22 9.41, 77.69 4.34, 81.17 0.33 M74.35 14.28 C76.85 11.08, 79.83 7.85, 86.15 0.69 M74.35 14.28 C77.96 9.78, 81.9 5.5, 86.15 0.69 M79.33 14.64 C83.76 9.96, 87.42 4.87, 91.8 0.3 M79.33 14.64 C83.05 10.29, 86.89 6.18, 91.8 0.3 M84.98 14.24 C89.04 9.67, 92.73 5.68, 96.78 0.66 M84.98 14.24 C87.81 10.98, 90.58 7.61, 96.78 0.66 M89.96 14.6 C92.58 11.55, 95.25 8.92, 101.77 1.02 M89.96 14.6 C93.25 10.82, 96.37 7.04, 101.77 1.02 M95.61 14.21 C98.52 11.18, 100.84 7.79, 103.48 5.15 M95.61 14.21 C98.01 11.51, 100.41 8.8, 103.48 5.15 M101.25 13.81 C102 12.9, 102.8 11.99, 103.87 10.79 M101.25 13.81 C102.09 12.82, 103 11.84, 103.87 10.79\" stroke=\"#4dabf7\" strokeWidth=\"0.5\" fill=\"none\"/><path d=\"M3.54 0 C30.62 -0.41, 57.46 0.22, 99.41 0 M3.54 0 C38.48 0, 73.41 0.16, 99.41 0 M99.41 0 C101.61 -0.25, 102.6 1.44, 102.95 3.54 M99.41 0 C101.61 -0.1, 102.75 1.34, 102.95 3.54 M102.95 3.54 C102.94 6.34, 102.93 9.04, 102.95 10.63 M102.95 3.54 C102.94 6.31, 102.99 9.05, 102.95 10.63 M102.95 10.63 C102.96 13.15, 101.86 14.21, 99.41 14.18 M102.95 10.63 C103.11 13.26, 101.64 14.52, 99.41 14.18 M99.41 14.18 C77.24 14.22, 55.57 14.32, 3.54 14.18 M99.41 14.18 C75.15 14.05, 51.05 14.01, 3.54 14.18 M3.54 14.18 C1.51 13.97, 0.13 13.08, 0 10.63 M3.54 14.18 C1.12 13.8, 0.18 13.17, 0 10.63 M0 10.63 C0.12 8.81, 0.07 6.77, 0 3.54 M0 10.63 C-0.05 7.8, 0.03 5.03, 0 3.54 M0 3.54 C0.33 1.34, 0.92 -0.3, 3.54 0 M0 3.54 C0.32 1.13, 1.1 0.32, 3.54 0\" stroke=\"#1971c2\" strokeWidth=\"1\" fill=\"none\"/></g><g strokeLinecap=\"round\" transform=\"translate(228.38568324393339 63.84970681781738) rotate(0 51.47636624179046 7.089559422522029)\"><path d=\"M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.2 8.12 C2.57 5.32, 5.19 2.25, 6.76 0.57 M0.2 8.12 C2.61 5.22, 5.02 2.54, 6.76 0.57 M1.25 13.01 C4.47 9.3, 7.48 6.18, 12.4 0.18 M1.25 13.01 C3.6 10.45, 5.91 7.73, 12.4 0.18 M4.92 14.88 C8.15 10.97, 12.31 6.46, 17.39 0.54 M4.92 14.88 C7.92 11.47, 10.71 7.98, 17.39 0.54 M10.56 14.48 C15.3 9.07, 19.62 3.82, 22.37 0.9 M10.56 14.48 C14.93 9.24, 19.52 4.15, 22.37 0.9 M15.55 14.84 C19.34 11.01, 22.35 6.47, 28.02 0.5 M15.55 14.84 C18.48 11.63, 21.44 8.25, 28.02 0.5 M21.19 14.45 C24.5 10.9, 27.61 7.41, 33 0.86 M21.19 14.45 C24.02 11.14, 26.83 7.94, 33 0.86 M26.18 14.81 C30.21 9.96, 34.33 5.11, 38.65 0.47 M26.18 14.81 C28.78 11.79, 31.58 8.79, 38.65 0.47 M31.82 14.41 C34.97 10.68, 38.89 6.47, 43.63 0.83 M31.82 14.41 C35.79 9.87, 39.56 5.28, 43.63 0.83 M36.81 14.77 C39.82 11.4, 42.58 8.57, 49.28 0.44 M36.81 14.77 C40.15 10.89, 43.78 6.93, 49.28 0.44 M42.45 14.38 C47.05 9.42, 51.57 3.95, 54.26 0.8 M42.45 14.38 C47.01 8.98, 51.84 3.67, 54.26 0.8 M47.44 14.74 C51.17 10.83, 54.21 6.5, 59.91 0.4 M47.44 14.74 C50.73 11.02, 53.88 7.25, 59.91 0.4 M53.08 14.35 C57.53 9.2, 62.13 3.34, 64.89 0.76 M53.08 14.35 C55.92 11.22, 58.71 7.71, 64.89 0.76 M58.07 14.71 C61.5 10.92, 64.37 7.06, 70.54 0.37 M58.07 14.71 C61.14 11.03, 64.13 7.73, 70.54 0.37 M63.72 14.31 C67.65 10.21, 70.7 6.21, 75.52 0.73 M63.72 14.31 C67.26 10.38, 70.5 6.33, 75.52 0.73 M68.7 14.67 C71.61 11.01, 74.69 7.41, 81.17 0.33 M68.7 14.67 C71.99 10.91, 75.32 7.03, 81.17 0.33 M74.35 14.28 C77.62 10.57, 80.61 7.49, 86.15 0.69 M74.35 14.28 C77.34 10.68, 80.39 7.32, 86.15 0.69 M79.33 14.64 C83.8 9.15, 88.24 4.18, 91.8 0.3 M79.33 14.64 C83.96 9.41, 88.71 3.74, 91.8 0.3 M84.98 14.24 C87.89 10.5, 91.64 6.94, 96.78 0.66 M84.98 14.24 C87.75 11.18, 90.41 7.72, 96.78 0.66 M89.96 14.6 C93.24 11.02, 96.44 6.97, 101.77 1.02 M89.96 14.6 C94.6 9.27, 98.93 4.19, 101.77 1.02 M95.61 14.21 C98.43 11, 101.09 7.97, 103.48 5.15 M95.61 14.21 C97.4 12.16, 99.03 10.25, 103.48 5.15 M101.25 13.81 C102.01 13.03, 102.66 12.26, 103.87 10.79 M101.25 13.81 C102.19 12.74, 103.1 11.66, 103.87 10.79\" stroke=\"#a18072\" strokeWidth=\"0.5\" fill=\"none\"/><path d=\"M3.54 0 C32.32 0.23, 61.62 -0.05, 99.41 0 M3.54 0 C37.39 -0.01, 71.53 -0.08, 99.41 0 M99.41 0 C101.67 -0.05, 103.23 0.96, 102.95 3.54 M99.41 0 C101.44 -0.34, 102.65 1.26, 102.95 3.54 M102.95 3.54 C102.98 5.07, 102.95 6.77, 102.95 10.63 M102.95 3.54 C102.89 5.62, 102.92 7.56, 102.95 10.63 M102.95 10.63 C103.14 13.13, 101.99 14.08, 99.41 14.18 M102.95 10.63 C102.73 13.39, 101.43 14.07, 99.41 14.18 M99.41 14.18 C65.06 14.65, 30.12 14.5, 3.54 14.18 M99.41 14.18 C67.31 14.11, 34.76 14.14, 3.54 14.18 M3.54 14.18 C0.96 13.86, -0.24 12.92, 0 10.63 M3.54 14.18 C0.82 13.81, 0.08 12.74, 0 10.63 M0 10.63 C0.02 7.98, 0.04 5.58, 0 3.54 M0 10.63 C-0.06 8.5, 0 6.25, 0 3.54 M0 3.54 C0.28 1.25, 1.23 0.2, 3.54 0 M0 3.54 C0.23 0.99, 0.96 0.37, 3.54 0\" stroke=\"#846358\" strokeWidth=\"1\" fill=\"none\"/></g><g strokeLinecap=\"round\" transform=\"translate(336.736216864059 63.84970681781738) rotate(0 51.47636624179046 7.089559422522029)\"><path d=\"M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.2 8.12 C2.2 5.77, 4.51 3.31, 6.76 0.57 M0.2 8.12 C1.9 6.06, 3.64 4.1, 6.76 0.57 M1.25 13.01 C4.28 9.78, 6.93 6.82, 12.4 0.18 M1.25 13.01 C3.59 10.32, 5.99 7.67, 12.4 0.18 M4.92 14.88 C8.88 9.77, 13.19 5.39, 17.39 0.54 M4.92 14.88 C9.49 9.73, 13.68 4.76, 17.39 0.54 M10.56 14.48 C13.37 11.72, 16 8.73, 22.37 0.9 M10.56 14.48 C14.59 9.88, 18.62 4.85, 22.37 0.9 M15.55 14.84 C18.41 11.2, 21.88 7.77, 28.02 0.5 M15.55 14.84 C20.05 9.5, 24.88 4.43, 28.02 0.5 M21.19 14.45 C25.16 10.22, 28.99 5.2, 33 0.86 M21.19 14.45 C23.71 11.31, 26.38 8.38, 33 0.86 M26.18 14.81 C29.57 10.41, 33.62 6.12, 38.65 0.47 M26.18 14.81 C30.61 9.48, 35.46 4.31, 38.65 0.47 M31.82 14.41 C35.59 10.45, 38.68 6.54, 43.63 0.83 M31.82 14.41 C36.55 9.14, 41.1 3.87, 43.63 0.83 M36.81 14.77 C41.31 9.29, 45.83 4.27, 49.28 0.44 M36.81 14.77 C39.83 11.14, 42.97 7.67, 49.28 0.44 M42.45 14.38 C45.76 10.29, 49.37 6.69, 54.26 0.8 M42.45 14.38 C47.01 9.19, 51.44 4.05, 54.26 0.8 M47.44 14.74 C51.05 10.87, 54.42 6.66, 59.91 0.4 M47.44 14.74 C50.82 11.04, 54.3 7.1, 59.91 0.4 M53.08 14.35 C56.21 10.25, 59.81 6.25, 64.89 0.76 M53.08 14.35 C55.64 11.28, 58.14 8.4, 64.89 0.76 M58.07 14.71 C61.4 10.42, 65.36 6.3, 70.54 0.37 M58.07 14.71 C61.81 10.45, 65.89 5.75, 70.54 0.37 M63.72 14.31 C68.13 9.65, 72.43 4.66, 75.52 0.73 M63.72 14.31 C67.75 9.66, 71.53 5.31, 75.52 0.73 M68.7 14.67 C73.34 9.12, 77.8 3.71, 81.17 0.33 M68.7 14.67 C73.73 9.02, 78.58 3.23, 81.17 0.33 M74.35 14.28 C78.02 10.26, 82 5.33, 86.15 0.69 M74.35 14.28 C77.11 11.16, 79.97 7.78, 86.15 0.69 M79.33 14.64 C83.93 9.7, 88.46 4.58, 91.8 0.3 M79.33 14.64 C81.9 11.5, 84.73 8.39, 91.8 0.3 M84.98 14.24 C88.75 10.14, 91.81 5.84, 96.78 0.66 M84.98 14.24 C87.92 10.84, 90.54 7.67, 96.78 0.66 M89.96 14.6 C94.53 9.85, 98.36 4.87, 101.77 1.02 M89.96 14.6 C94.28 9.64, 98.72 4.36, 101.77 1.02 M95.61 14.21 C98.77 10.71, 101.78 7.11, 103.48 5.15 M95.61 14.21 C98.3 11.25, 100.85 7.98, 103.48 5.15 M101.25 13.81 C101.8 13.22, 102.34 12.5, 103.87 10.79 M101.25 13.81 C102.13 12.79, 102.96 11.86, 103.87 10.79\" stroke=\"#ff8787\" strokeWidth=\"0.5\" fill=\"none\"/><path d=\"M3.54 0 C40.05 0.17, 75.99 -0.22, 99.41 0 M3.54 0 C41.07 -0.01, 78.52 0.16, 99.41 0 M99.41 0 C102.08 -0.03, 103.18 1.35, 102.95 3.54 M99.41 0 C101.6 0.14, 102.84 1.56, 102.95 3.54 M102.95 3.54 C102.86 6.28, 102.95 8.8, 102.95 10.63 M102.95 3.54 C102.92 6.11, 102.9 8.83, 102.95 10.63 M102.95 10.63 C102.84 13.05, 102.01 14.46, 99.41 14.18 M102.95 10.63 C102.61 13.34, 101.85 13.81, 99.41 14.18 M99.41 14.18 C77.34 14.09, 54.92 14.12, 3.54 14.18 M99.41 14.18 C61.62 14.17, 23.52 13.91, 3.54 14.18 M3.54 14.18 C1.36 13.94, 0.07 13.01, 0 10.63 M3.54 14.18 C0.81 14.03, 0.37 13.14, 0 10.63 M0 10.63 C-0.06 8.81, 0.09 7.15, 0 3.54 M0 10.63 C0.05 8.6, -0.01 6.7, 0 3.54 M0 3.54 C0.35 1.44, 1.47 -0.31, 3.54 0 M0 3.54 C-0.22 0.91, 1.13 -0.07, 3.54 0\" stroke=\"#fa5252\" strokeWidth=\"1\" fill=\"none\"/></g><g strokeLinecap=\"round\" transform=\"translate(446.47026705613916 63.84970681781738) rotate(0 51.47636624179046 7.089559422522029)\"><path d=\"M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.2 8.12 C2.61 5.31, 5 2.3, 6.76 0.57 M0.2 8.12 C2.47 5.51, 4.83 2.8, 6.76 0.57 M1.25 13.01 C4.22 9.71, 7.38 5.85, 12.4 0.18 M1.25 13.01 C3.68 10.33, 6.03 7.63, 12.4 0.18 M4.92 14.88 C7.84 11.46, 10.79 8.07, 17.39 0.54 M4.92 14.88 C9.58 9.44, 14.39 3.74, 17.39 0.54 M10.56 14.48 C15.22 9.72, 19.41 4.54, 22.37 0.9 M10.56 14.48 C13.69 11.17, 16.68 7.47, 22.37 0.9 M15.55 14.84 C20.58 9.44, 25.26 3.86, 28.02 0.5 M15.55 14.84 C18.75 11.3, 21.91 7.31, 28.02 0.5 M21.19 14.45 C25.34 9.72, 29.19 4.79, 33 0.86 M21.19 14.45 C25.04 10.17, 28.63 5.68, 33 0.86 M26.18 14.81 C29.04 11.38, 32.37 7.8, 38.65 0.47 M26.18 14.81 C28.98 11.55, 31.84 8.48, 38.65 0.47 M31.82 14.41 C35.55 10.07, 39.13 5.74, 43.63 0.83 M31.82 14.41 C34.21 11.62, 36.89 8.74, 43.63 0.83 M36.81 14.77 C39.94 10.76, 43.82 7.23, 49.28 0.44 M36.81 14.77 C41.3 9.51, 46.13 4.24, 49.28 0.44 M42.45 14.38 C44.6 11.72, 47.2 8.37, 54.26 0.8 M42.45 14.38 C45.08 11.25, 47.89 8.13, 54.26 0.8 M47.44 14.74 C51.79 9.62, 56.75 4.23, 59.91 0.4 M47.44 14.74 C51.73 9.92, 55.72 5.2, 59.91 0.4 M53.08 14.35 C57.02 9.86, 60.44 5.97, 64.89 0.76 M53.08 14.35 C56.21 10.85, 59.48 7.28, 64.89 0.76 M58.07 14.71 C61.55 10.63, 65.24 6.64, 70.54 0.37 M58.07 14.71 C62.69 9.33, 67.54 3.69, 70.54 0.37 M63.72 14.31 C68.01 8.94, 72.29 4.33, 75.52 0.73 M63.72 14.31 C67 10.52, 70.18 7.05, 75.52 0.73 M68.7 14.67 C71.33 11.95, 73.87 8.9, 81.17 0.33 M68.7 14.67 C72.08 10.68, 75.95 6.37, 81.17 0.33 M74.35 14.28 C77.22 11.06, 79.69 7.69, 86.15 0.69 M74.35 14.28 C79.01 8.93, 83.48 3.65, 86.15 0.69 M79.33 14.64 C83.88 8.96, 88.9 3.59, 91.8 0.3 M79.33 14.64 C83 10.67, 86.28 6.35, 91.8 0.3 M84.98 14.24 C87.74 11.34, 90.57 8.12, 96.78 0.66 M84.98 14.24 C88.18 10.7, 91.27 7.18, 96.78 0.66 M89.96 14.6 C92.82 11.2, 95.65 8.01, 101.77 1.02 M89.96 14.6 C93.84 10.03, 97.76 5.46, 101.77 1.02 M95.61 14.21 C97.19 12.34, 99.03 10.5, 103.48 5.15 M95.61 14.21 C97.8 11.64, 100.25 8.91, 103.48 5.15 M101.25 13.81 C102.14 12.8, 102.95 11.75, 103.87 10.79 M101.25 13.81 C101.86 13.12, 102.45 12.48, 103.87 10.79\" stroke=\"#9775fa\" strokeWidth=\"0.5\" fill=\"none\"/><path d=\"M3.54 0 C41.68 0.03, 79.51 -0.16, 99.41 0 M3.54 0 C26.93 0.03, 50.22 -0.06, 99.41 0 M99.41 0 C101.78 -0.06, 102.7 1.45, 102.95 3.54 M99.41 0 C102.05 0.1, 103.02 0.95, 102.95 3.54 M102.95 3.54 C102.83 5.42, 103.01 7.11, 102.95 10.63 M102.95 3.54 C102.99 5.23, 102.97 6.95, 102.95 10.63 M102.95 10.63 C103.27 12.65, 101.53 14.35, 99.41 14.18 M102.95 10.63 C103.31 13.39, 102.14 13.94, 99.41 14.18 M99.41 14.18 C67.51 14.05, 35.49 13.85, 3.54 14.18 M99.41 14.18 C77.87 14.2, 56.22 14.26, 3.54 14.18 M3.54 14.18 C1.5 14.26, 0.05 12.79, 0 10.63 M3.54 14.18 C1.52 13.82, -0.1 13.29, 0 10.63 M0 10.63 C-0.08 8.61, 0.09 6.58, 0 3.54 M0 10.63 C-0.01 8.26, 0.06 6.06, 0 3.54 M0 3.54 C-0.29 0.87, 1.35 -0.08, 3.54 0 M0 3.54 C-0.41 1.56, 1.11 -0.03, 3.54 0\" stroke=\"#6741d9\" strokeWidth=\"1\" fill=\"none\"/></g><g strokeLinecap=\"round\" transform=\"translate(10 63.84970681781738) rotate(0 51.47636624179046 7.089559422522029)\"><path d=\"M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.46 1.72 C0.46 1.72, 0.46 1.72, 0.46 1.72 M0.2 8.12 C2.86 5.23, 5.21 2.23, 6.76 0.57 M0.2 8.12 C2.26 5.85, 4.23 3.48, 6.76 0.57 M1.25 13.01 C4.74 8.91, 8.31 4.49, 12.4 0.18 M1.25 13.01 C4.31 9.21, 7.52 5.69, 12.4 0.18 M4.92 14.88 C7.8 11.37, 10.57 8.3, 17.39 0.54 M4.92 14.88 C8.36 10.91, 12 6.56, 17.39 0.54 M10.56 14.48 C14.63 9.51, 19.19 4.63, 22.37 0.9 M10.56 14.48 C13.89 10.83, 17.15 6.92, 22.37 0.9 M15.55 14.84 C19.67 10, 24.56 4.3, 28.02 0.5 M15.55 14.84 C20.01 9.59, 24.52 4.24, 28.02 0.5 M21.19 14.45 C25.21 9.84, 28.85 5.49, 33 0.86 M21.19 14.45 C25.28 9.87, 29.11 5.24, 33 0.86 M26.18 14.81 C30.05 10.49, 33.95 6.29, 38.65 0.47 M26.18 14.81 C29.31 11.14, 32.63 7.49, 38.65 0.47 M31.82 14.41 C36.14 9.58, 40.67 4.23, 43.63 0.83 M31.82 14.41 C35.9 9.74, 39.82 5.21, 43.63 0.83 M36.81 14.77 C41.25 9.64, 45.44 5.02, 49.28 0.44 M36.81 14.77 C39.31 11.88, 42.01 9.07, 49.28 0.44 M42.45 14.38 C44.7 11.22, 47.48 8.49, 54.26 0.8 M42.45 14.38 C46.96 9.32, 51.4 3.81, 54.26 0.8 M47.44 14.74 C51.61 9.87, 55.98 4.71, 59.91 0.4 M47.44 14.74 C51.4 10.45, 54.95 6.12, 59.91 0.4 M53.08 14.35 C56.45 11.07, 59.25 7.09, 64.89 0.76 M53.08 14.35 C55.56 11.54, 58.27 8.68, 64.89 0.76 M58.07 14.71 C62.62 9.21, 67.54 3.77, 70.54 0.37 M58.07 14.71 C62.85 8.96, 67.69 3.65, 70.54 0.37 M63.72 14.31 C66.96 10.25, 70.75 6.38, 75.52 0.73 M63.72 14.31 C66.63 11.14, 69.43 7.67, 75.52 0.73 M68.7 14.67 C71.98 10.53, 75.65 6.32, 81.17 0.33 M68.7 14.67 C72.85 9.93, 77.15 4.91, 81.17 0.33 M74.35 14.28 C78.86 9.22, 83.46 4.21, 86.15 0.69 M74.35 14.28 C77.54 10.4, 81.08 6.78, 86.15 0.69 M79.33 14.64 C82.49 10.67, 85.97 6.88, 91.8 0.3 M79.33 14.64 C83.93 9.59, 88.42 4.41, 91.8 0.3 M84.98 14.24 C89.46 9.09, 93.06 4.41, 96.78 0.66 M84.98 14.24 C87.6 11.23, 90.35 8.2, 96.78 0.66 M89.96 14.6 C93.81 10.33, 98.19 5.62, 101.77 1.02 M89.96 14.6 C93.93 10.17, 97.51 5.71, 101.77 1.02 M95.61 14.21 C98.4 10.71, 101.48 7.52, 103.48 5.15 M95.61 14.21 C98.54 10.81, 101.5 7.44, 103.48 5.15 M101.25 13.81 C101.92 13.06, 102.66 12.18, 103.87 10.79 M101.25 13.81 C101.85 13.11, 102.46 12.47, 103.87 10.79\" stroke=\"#69db7c\" strokeWidth=\"0.5\" fill=\"none\"/><path d=\"M3.54 0 C33.52 0.38, 63.5 0.2, 99.41 0 M3.54 0 C24.44 0.21, 45.82 -0.08, 99.41 0 M99.41 0 C101.46 0.17, 102.88 0.82, 102.95 3.54 M99.41 0 C102.15 -0.07, 102.92 1.14, 102.95 3.54 M102.95 3.54 C103.04 6.16, 103.08 8.6, 102.95 10.63 M102.95 3.54 C102.91 6.39, 102.96 9.23, 102.95 10.63 M102.95 10.63 C102.83 12.88, 101.69 14.19, 99.41 14.18 M102.95 10.63 C102.7 12.6, 101.97 14.38, 99.41 14.18 M99.41 14.18 C69.14 13.93, 38.16 14.2, 3.54 14.18 M99.41 14.18 C68.57 14.13, 37.42 14.37, 3.54 14.18 M3.54 14.18 C0.89 14.45, -0.04 13.32, 0 10.63 M3.54 14.18 C1.07 13.89, 0.33 13.21, 0 10.63 M0 10.63 C-0.08 8.44, 0.01 5.93, 0 3.54 M0 10.63 C-0.04 9.18, -0.02 7.58, 0 3.54 M0 3.54 C0.31 1.45, 1.3 0.09, 3.54 0 M0 3.54 C-0.18 1.52, 1 0.31, 3.54 0\" stroke=\"#2f9e44\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(154.65795701279956 10) rotate(0 6.666666507720947 12.5)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">a</text></g><g transform=\"translate(362.6285208419655 13.914549146140416) rotate(0 5.083333492279053 12.5)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">b</text></g><g transform=\"translate(472.3625710340457 10.832132005913422) rotate(0 5.016666889190674 12.5)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">c</text></g><g strokeLinecap=\"round\" transform=\"translate(514.3026264860405 42.51365315904741) rotate(0 5.386134419624682 5.386134419624625)\"><path d=\"M5.46 0.02 C6.53 -0.06, 7.84 0.49, 8.72 1.24 C9.6 1.99, 10.55 3.39, 10.76 4.52 C10.97 5.65, 10.58 7.03, 9.98 8.02 C9.38 9, 8.21 10.02, 7.13 10.43 C6.05 10.84, 4.55 10.89, 3.49 10.5 C2.42 10.11, 1.34 9.07, 0.75 8.08 C0.16 7.09, -0.26 5.7, -0.05 4.55 C0.16 3.39, 1.12 1.9, 2.03 1.13 C2.94 0.35, 4.85 0.06, 5.41 -0.13 C5.97 -0.32, 5.35 -0.13, 5.38 -0.03 M5.42 0 C6.49 -0.12, 7.86 0.43, 8.7 1.15 C9.53 1.86, 10.19 3.11, 10.43 4.28 C10.66 5.45, 10.67 7.14, 10.12 8.15 C9.57 9.15, 8.25 9.91, 7.13 10.29 C6.01 10.67, 4.44 10.78, 3.4 10.43 C2.37 10.09, 1.45 9.24, 0.93 8.23 C0.41 7.21, 0.09 5.51, 0.27 4.34 C0.45 3.18, 1.14 1.99, 2.02 1.26 C2.89 0.53, 4.97 0.17, 5.52 -0.04 C6.06 -0.26, 5.34 -0.17, 5.31 -0.04\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(513.6669607130871 10.832132005913422) rotate(0 5.683333396911621 12.5)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">d</text></g><g strokeLinecap=\"round\"><g transform=\"translate(224.93489133390767 126.30541161590077) rotate(0 -81.29866387617756 -20.28813825121634)\"><path d=\"M0.12 0.22 C-26.97 -6.51, -135.41 -33.46, -162.52 -40.3 M-0.26 0.01 C-27.38 -6.81, -135.64 -34.03, -162.72 -40.79\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(224.93489133390767 126.30541161590077) rotate(0 -81.29866387617756 -20.28813825121634)\"><path d=\"M-162.42 -40.62 L-147.53 -43.74 L-151.47 -30.98 L-162.22 -40.78\" stroke=\"none\" strokeWidth=\"0\" fill=\"#868e96\" fillRule=\"evenodd\"/><path d=\"M-162.72 -40.79 C-157.91 -41.43, -153.3 -42.34, -147.99 -43.64 M-162.72 -40.79 C-159.45 -41.29, -155.94 -42.19, -147.99 -43.64 M-147.99 -43.64 C-148.68 -41.43, -149.37 -38.84, -151.07 -31.34 M-147.99 -43.64 C-149.37 -39.15, -150.39 -34.35, -151.07 -31.34 M-151.07 -31.34 C-154.9 -34.59, -158.37 -37.15, -162.72 -40.79 M-151.07 -31.34 C-153.74 -33.65, -156.68 -35.81, -162.72 -40.79 M-162.72 -40.79 C-162.72 -40.79, -162.72 -40.79, -162.72 -40.79 M-162.72 -40.79 C-162.72 -40.79, -162.72 -40.79, -162.72 -40.79\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(335.9348913339077 126.30541161590077) rotate(0 -81.44406398411064 -20.750567047332765)\"><path d=\"M-0.21 -0.32 C-27.4 -7.25, -135.83 -34.42, -162.96 -41.27 M0.36 0.32 C-26.9 -6.73, -135.99 -34.94, -163.25 -41.82\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(335.9348913339077 126.30541161590077) rotate(0 -81.44406398411064 -20.750567047332765)\"><path d=\"M-162.82 -41.98 L-148.26 -44.78 L-151.45 -32.82 L-163.78 -41.67\" stroke=\"none\" strokeWidth=\"0\" fill=\"#868e96\" fillRule=\"evenodd\"/><path d=\"M-163.25 -41.82 C-158.49 -42.77, -152.53 -43.78, -148.51 -44.59 M-163.25 -41.82 C-158.84 -42.82, -153.91 -43.47, -148.51 -44.59 M-148.51 -44.59 C-149.28 -41.48, -150.68 -37.36, -151.65 -32.31 M-148.51 -44.59 C-149.08 -41.91, -149.82 -39.66, -151.65 -32.31 M-151.65 -32.31 C-155.64 -35.76, -160.24 -39.29, -163.25 -41.82 M-151.65 -32.31 C-155.06 -35.24, -158.6 -38.03, -163.25 -41.82 M-163.25 -41.82 C-163.25 -41.82, -163.25 -41.82, -163.25 -41.82 M-163.25 -41.82 C-163.25 -41.82, -163.25 -41.82, -163.25 -41.82\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(444.9348913339077 126.30541161590077) rotate(0 -80.58637152295398 -21.25778143474281)\"><path d=\"M-0.15 0.05 C-27.09 -7.05, -134.53 -35.51, -161.41 -42.56 M0.47 -0.27 C-26.52 -7.32, -134.63 -35.28, -161.65 -42.2\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(444.9348913339077 126.30541161590077) rotate(0 -80.58637152295398 -21.25778143474281)\"><path d=\"M-161.06 -42.91 L-146.68 -45.5 L-150.29 -32.21 L-162.29 -41.89\" stroke=\"none\" strokeWidth=\"0\" fill=\"#868e96\" fillRule=\"evenodd\"/><path d=\"M-161.65 -42.2 C-156.38 -42.97, -151.6 -43.96, -146.9 -44.95 M-161.65 -42.2 C-157.14 -42.93, -152.45 -43.89, -146.9 -44.95 M-146.9 -44.95 C-147.98 -40.23, -149.34 -35.96, -150.06 -32.67 M-146.9 -44.95 C-148.15 -40.34, -149.24 -35.99, -150.06 -32.67 M-150.06 -32.67 C-152.6 -35.37, -155.26 -36.86, -161.65 -42.2 M-150.06 -32.67 C-153.05 -35.03, -156.41 -38.08, -161.65 -42.2 M-161.65 -42.2 C-161.65 -42.2, -161.65 -42.2, -161.65 -42.2 M-161.65 -42.2 C-161.65 -42.2, -161.65 -42.2, -161.65 -42.2\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(557.9348913339077 130.30541161590077) rotate(0 -83.59220611015871 -22.865298276165646)\"><path d=\"M0.38 -0.34 C-27.49 -7.99, -139.32 -38.59, -167.42 -46.19 M0.03 0.46 C-27.85 -7.1, -139.66 -38.07, -167.56 -45.71\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(557.9348913339077 130.30541161590077) rotate(0 -83.59220611015871 -22.865298276165646)\"><path d=\"M-167.14 -46.48 L-153.39 -47.55 L-156.34 -35.67 L-166.89 -45.75\" stroke=\"none\" strokeWidth=\"0\" fill=\"#868e96\" fillRule=\"evenodd\"/><path d=\"M-167.56 -45.71 C-164.26 -45.82, -160.28 -47.58, -152.77 -48.21 M-167.56 -45.71 C-163.03 -46.74, -157.7 -47.35, -152.77 -48.21 M-152.77 -48.21 C-153.59 -44.27, -154.8 -40.58, -156.14 -35.99 M-152.77 -48.21 C-153.72 -45.25, -154.65 -42.01, -156.14 -35.99 M-156.14 -35.99 C-158.92 -38.55, -161.22 -39.36, -167.56 -45.71 M-156.14 -35.99 C-159.49 -38.85, -162.81 -41.26, -167.56 -45.71 M-167.56 -45.71 C-167.56 -45.71, -167.56 -45.71, -167.56 -45.71 M-167.56 -45.71 C-167.56 -45.71, -167.56 -45.71, -167.56 -45.71\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(672.5249098087711 122.3183211359501) rotate(0 0.039762011369248285 8.137658715964108)\"><path d=\"M0.12 0.14 C0.11 2.81, 0.09 13.37, 0.06 16.02 M0.01 0.08 C-0.02 2.78, -0.06 13.51, -0.03 16.19\" stroke=\"#1e1e1e\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g strokeLinecap=\"round\"><g transform=\"translate(671.24159669865 129.05571496408538) rotate(0 -85.2406155163992 -22.772837541561614)\"><path d=\"M-0.07 0.45 C-28.36 -7.17, -141.8 -38.3, -170.15 -45.99 M-0.64 0.29 C-28.95 -7.25, -142.14 -37.93, -170.41 -45.51\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g><g transform=\"translate(671.24159669865 129.05571496408538) rotate(0 -85.2406155163992 -22.772837541561614)\"><path d=\"M-170.99 -45.29 L-155.62 -47.62 L-159.61 -35.62 L-169.59 -45.96\" stroke=\"none\" strokeWidth=\"0\" fill=\"#868e96\" fillRule=\"evenodd\"/><path d=\"M-170.41 -45.51 C-166.6 -45.83, -164.25 -46.35, -155.64 -48.09 M-170.41 -45.51 C-165.53 -46.49, -161.13 -47.05, -155.64 -48.09 M-155.64 -48.09 C-156.51 -45.39, -157.12 -43.11, -158.94 -35.85 M-155.64 -48.09 C-156.7 -43.54, -158.28 -38.42, -158.94 -35.85 M-158.94 -35.85 C-162.78 -39.75, -167.23 -42.25, -170.41 -45.51 M-158.94 -35.85 C-162.54 -38.51, -165.96 -41.67, -170.41 -45.51 M-170.41 -45.51 C-170.41 -45.51, -170.41 -45.51, -170.41 -45.51 M-170.41 -45.51 C-170.41 -45.51, -170.41 -45.51, -170.41 -45.51\" stroke=\"#868e96\" strokeWidth=\"1\" fill=\"none\"/></g></g><mask/><g transform=\"translate(102.89115513812294 151.64969197869323) rotate(0 20.799999237060547 10)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"16px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">12:00</text></g><g transform=\"translate(207.6047416876387 151.64969197869323) rotate(0 16.91666603088379 10)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"16px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">12:15</text></g><g transform=\"translate(323.8615582504867 151.64969197869323) rotate(0 20.75 10)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"16px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">12:30</text></g><g transform=\"translate(426.10159551143124 151.64969197869323) rotate(0 19.866666793823242 10)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"16px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">12:45</text></g><g transform=\"translate(534.9377642085657 151.64969197869323) rotate(0 20.549999237060547 10)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"16px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">13:00</text></g><g transform=\"translate(653.6681300599851 151.64969197869323) rotate(0 16.66666603088379 10)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"16px\" fill=\"#1e1e1e\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">13:15</text></g><g transform=\"translate(337.0538211228667 177.15679987107194) rotate(0 6.666666507720947 12.5)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20px\" fill=\"#1971c2\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">a</text></g><g transform=\"translate(554.7408489209982 182.11590076898455) rotate(0 5.083333492279053 12.5)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20px\" fill=\"#e03131\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">b</text></g><g transform=\"translate(649.5602383162291 177.9933186213658) rotate(0 27.883333206176758 12.5)\"><text x=\"0\" y=\"0\" fontFamily=\"Virgil, Segoe UI Emoji\" fontSize=\"20px\" fill=\"#6741d9\" textAnchor=\"start\" style={{ whiteSpace: 'pre' }} direction=\"ltr\" dominantBaseline=\"text-before-edge\">c &amp; d</text></g>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/knip.json",
    "content": "{\n  \"$schema\": \"https://unpkg.com/knip@6/schema.json\",\n  \"ignore\": [\"content/**\"],\n  \"ignoreDependencies\": [\"sharp\", \"@types/dompurify\"],\n  \"entry\": [\"source.config.ts\"],\n  \"exclude\": [\"types\", \"duplicates\", \"exports\"]\n}\n"
  },
  {
    "path": "packages/francoisbest.com/next.config.ts",
    "content": "import type { NextConfig } from 'next'\nimport configureBundleAnalyzer from 'next-bundle-analyzer'\nimport { createMDX } from 'fumadocs-mdx/next'\n\nconst nextConfig: NextConfig = {\n  pageExtensions: ['ts', 'tsx'],\n  turbopack: {},\n  images: {\n    remotePatterns: [\n      {\n        // Spotify albums & artists\n        protocol: 'https',\n        hostname: 'i.scdn.co',\n        pathname: '/image/*'\n      },\n      {\n        // GitHub hosted images\n        protocol: 'https',\n        hostname: 'raw.githubusercontent.com',\n        pathname: '/47ng/*'\n      },\n      {\n        // GitHub avatars\n        protocol: 'https',\n        hostname: 'avatars.githubusercontent.com',\n        pathname: '/u/*'\n      }\n    ]\n  },\n  async redirects() {\n    return [\n      {\n        source: '/resume',\n        destination: '/francois-best-full-stack-typescript-dev-resume.pdf',\n        permanent: false\n      },\n      {\n        source: '/resume.pdf',\n        destination: '/francois-best-full-stack-typescript-dev-resume.pdf',\n        permanent: false\n      },\n      {\n        source: '/sponsor(s)?',\n        destination: 'https://github.com/sponsors/franky47',\n        permanent: true\n      },\n      {\n        source: '/bsky',\n        destination: 'https://bsky.app/profile/francoisbest.com',\n        permanent: true\n      },\n      {\n        source: '/mastodon',\n        destination: 'https://mamot.fr/@Franky47',\n        permanent: true\n      },\n      {\n        source: '/discord',\n        destination: 'https://discord.com/users/francois.best#7881',\n        permanent: true\n      },\n      {\n        source: '/github',\n        destination: 'https://github.com/franky47',\n        permanent: true\n      },\n      {\n        source: '/linkedin',\n        destination: 'https://www.linkedin.com/in/francoisbest',\n        permanent: true\n      },\n      // Legacy\n      {\n        source: '/keybase',\n        destination: 'https://keybase.io/franky47',\n        permanent: true\n      },\n      {\n        source: '/x',\n        destination: 'https://x.com/fortysevenfx',\n        permanent: true\n      },\n      {\n        source: '/twitter',\n        destination: 'https://twitter.com/fortysevenfx',\n        permanent: true\n      }\n    ]\n  },\n}\n\nconst withAnalyzer = configureBundleAnalyzer({\n  enabled: process.env.ANALYZE === 'true',\n  clientOnly: true\n})\n\nconst withMDX = createMDX()\n\nexport default withAnalyzer(withMDX(nextConfig))\n"
  },
  {
    "path": "packages/francoisbest.com/package.json",
    "content": "{\n  \"name\": \"francoisbest.com\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"typecheck\": \"tsc --noEmit\",\n    \"lint\": \"oxlint\",\n    \"test\": \"vitest run\",\n    \"knip\": \"knip\",\n    \"isr\": \"node --env-file=.env ./scripts/isr.mjs\"\n  },\n  \"dependencies\": {\n    \"@47ng/codec\": \"1.1.0\",\n    \"@icons-pack/react-simple-icons\": \"13.4.0\",\n    \"@js-temporal/polyfill\": \"0.5.1\",\n    \"@spotify/web-api-ts-sdk\": \"1.2.0\",\n    \"@stablelib/tss\": \"2.0.1\",\n    \"@tailwindcss/postcss\": \"4.1.11\",\n    \"@tailwindcss/typography\": \"0.5.16\",\n    \"dompurify\": \"3.3.3\",\n    \"feed\": \"5.1.0\",\n    \"fumadocs-core\": \"16.7.9\",\n    \"fumadocs-mdx\": \"14.2.11\",\n    \"hast-util-from-html\": \"2.0.3\",\n    \"jsdom\": \"26.1.0\",\n    \"next\": \"16.2.3\",\n    \"next-themes\": \"0.4.6\",\n    \"nuqs\": \"2.8.6\",\n    \"qrcode\": \"1.5.4\",\n    \"react\": \"19.1.0\",\n    \"react-dom\": \"19.1.0\",\n    \"react-icons\": \"5.5.0\",\n    \"reading-time\": \"1.5.0\",\n    \"rehype-autolink-headings\": \"7.1.0\",\n    \"rehype-pretty-code\": \"0.14.1\",\n    \"rehype-slug\": \"6.0.0\",\n    \"remark-gfm\": \"4.0.1\",\n    \"remark-smartypants\": \"3.0.2\",\n    \"server-only\": \"0.0.1\",\n    \"sharp\": \"0.34.2\",\n    \"shiki\": \"3.7.0\",\n    \"spotify-uri\": \"4.1.0\",\n    \"tailwind-merge\": \"3.3.1\",\n    \"tweetnacl\": \"1.0.3\",\n    \"vegemite\": \"1.0.0\",\n    \"zod\": \"4.3.6\"\n  },\n  \"devDependencies\": {\n    \"@types/dompurify\": \"3.2.0\",\n    \"@types/jsdom\": \"21.1.7\",\n    \"@types/mdx\": \"2.0.13\",\n    \"@types/node\": \"24.0.12\",\n    \"@types/qrcode\": \"1.5.5\",\n    \"@types/react\": \"19.1.8\",\n    \"@types/react-dom\": \"19.1.6\",\n    \"knip\": \"^6.2.0\",\n    \"next-bundle-analyzer\": \"0.6.8\",\n    \"oxlint\": \"1.58.0\",\n    \"postcss\": \"8.5.6\",\n    \"tailwindcss\": \"4.1.11\",\n    \"typescript\": \"6.0.2\",\n    \"vite\": \"6.4.2\",\n    \"vitest\": \"4.1.2\"\n  },\n  \"postcss\": {\n    \"plugins\": {\n      \"@tailwindcss/postcss\": {}\n    }\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/public/.well-known/atproto-did",
    "content": "did:plc:rfoxp4hc5fgthjfaaigyw3c2\n"
  },
  {
    "path": "packages/francoisbest.com/public/.well-known/keybase.txt",
    "content": "https://keybase.io/franky47\n--------------------------------------------------------------------\n\nI hereby claim:\n\n  * I am an admin of https://francoisbest.com\n  * I am franky47 (https://keybase.io/franky47) on keybase.\n  * I have a public key ASD-QMOWfs9_Zj-uqXXmxrDZo0Wq4ahinrjxO06wxA57Igo\n\nTo do so, I am signing this object:\n\n{\n  \"body\": {\n    \"key\": {\n      \"eldest_kid\": \"0101df7d7b9b01b8e9b70a409a4991339bf67224baaf164a97fe0bb80eb0d5d32f960a\",\n      \"host\": \"keybase.io\",\n      \"kid\": \"0120fe40c3967ecf7f663faea975e6c6b0d9a345aae1a8629eb8f13b4eb0c40e7b220a\",\n      \"uid\": \"bc5f606c58a8cc8d5c8746e5984cdc19\",\n      \"username\": \"franky47\"\n    },\n    \"merkle_root\": {\n      \"ctime\": 1550492798,\n      \"hash\": \"fbe7ebf9dc76e983d92f74fd156e870d030444eb865c4451b94c4c908aed7e5060dc67b3b1b4dffb98ab4680b14d905fed7a384c53a140e36ae3b6f649c04bc2\",\n      \"hash_meta\": \"591079571e43e91b296df006d4946cbc3a7c8bff572bb9e512eca9b98c59683b\",\n      \"seqno\": 4784766\n    },\n    \"service\": {\n      \"entropy\": \"IxiG1+KQZxXWsWpAS3+z4ivc\",\n      \"hostname\": \"francoisbest.com\",\n      \"protocol\": \"https:\"\n    },\n    \"type\": \"web_service_binding\",\n    \"version\": 2\n  },\n  \"client\": {\n    \"name\": \"keybase.io go client\",\n    \"version\": \"3.0.0\"\n  },\n  \"ctime\": 1550492908,\n  \"expire_in\": 504576000,\n  \"prev\": \"3ea27d0fe8d865e1ab1d6b11c2655f97077b0b5dfed1d28cd0d0b66f3b78db3b\",\n  \"seqno\": 41,\n  \"tag\": \"signature\"\n}\n\nwhich yields the signature:\n\nhKRib2R5hqhkZXRhY2hlZMOpaGFzaF90eXBlCqNrZXnEIwEg/kDDln7Pf2Y/rql15saw2aNFquGoYp648TtOsMQOeyIKp3BheWxvYWTESpcCKcQgPqJ9D+jYZeGrHWsRwmVflwd7C13+0dKM0NC2bzt42zvEII3zY/A5xCl6aLBMUKZlq8jbkCkA0IenzvhaQ+xpM8E+AgHCo3NpZ8RADs14Yr6KK9m9m7cfTIxhOibvyYVraLopg2/DX9bIQ5+L7x4lPeVd7AL/dM5ej4pRNvE09eqVbm8Lx1XcyMLzC6hzaWdfdHlwZSCkaGFzaIKkdHlwZQildmFsdWXEIH9nJof2rW+MLcC0pd7KsDFuv+PDiYYYVT2G7GkWPbJMo3RhZ80CAqd2ZXJzaW9uAQ==\n\nAnd finally, I am proving ownership of this host by posting or\nappending to this document.\n\nView my publicly-auditable identity here: https://keybase.io/franky47\n"
  },
  {
    "path": "packages/francoisbest.com/public/.well-known/security.txt",
    "content": "Contact: https://keybase.io/franky47\nEncryption: https://keybase.io/franky47/pgp_keys.asc\nPreferred-Languages: en, fr\nCanonical: https://francoisbest.com/.well-known/security.txt\nExpires: Sat, 31 Dec 2047 23:59:59 +0100\n"
  },
  {
    "path": "packages/francoisbest.com/public/favicons/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig><msapplication><tile><square70x70logo src=\"./ms-icon-70x70.png\"/><square150x150logo src=\"./ms-icon-150x150.png\"/><square310x310logo src=\"./ms-icon-310x310.png\"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>\n"
  },
  {
    "path": "packages/francoisbest.com/public/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidraw",
    "content": "{\n  \"type\": \"excalidraw\",\n  \"version\": 2,\n  \"source\": \"https://excalidraw.com\",\n  \"elements\": [\n    {\n      \"type\": \"rectangle\",\n      \"version\": 390,\n      \"versionNonce\": 535411969,\n      \"isDeleted\": false,\n      \"id\": \"lGFcln3rVyD8dJDiAL6QT\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4385.660379509671,\n      \"y\": -974.769992136763,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 373.2520963004604,\n      \"height\": 668.5206869633105,\n      \"seed\": 204525261,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 268,\n      \"versionNonce\": 2052935567,\n      \"isDeleted\": false,\n      \"id\": \"mSnQoc1Qg6JMFAQFIKEe9\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4385.542365433885,\n      \"y\": -974.7254126935213,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 373.50264366819783,\n      \"height\": 28.91566265060237,\n      \"seed\": 995053101,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 413,\n      \"versionNonce\": 1619817697,\n      \"isDeleted\": false,\n      \"id\": \"S2lXGGXTahLjEXEcaVGME\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4549.91796237458,\n      \"y\": -970.048156907792,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 36,\n      \"height\": 21,\n      \"seed\": 1047130093,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 16,\n      \"fontFamily\": 1,\n      \"text\": \"10:47\",\n      \"baseline\": 15,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 336,\n      \"versionNonce\": 340428207,\n      \"isDeleted\": false,\n      \"id\": \"RBE16_O88hYvJgq3VlXKj\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4713.749566282495,\n      \"y\": -967.7456364456712,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 33.92857142857156,\n      \"height\": 15.476190476190823,\n      \"seed\": 1528698371,\n      \"groupIds\": [\n        \"YPuDD5bttLUHLq5PDbIOi\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 440,\n      \"versionNonce\": 265105601,\n      \"isDeleted\": false,\n      \"id\": \"T8yoZLMVYK5fMtzzPh-41\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4715.23766152059,\n      \"y\": -962.9837316837684,\n      \"strokeColor\": \"transparent\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 20.83333333333344,\n      \"height\": 8.33333333333394,\n      \"seed\": 465472045,\n      \"groupIds\": [\n        \"YPuDD5bttLUHLq5PDbIOi\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 210,\n      \"versionNonce\": 57407439,\n      \"isDeleted\": false,\n      \"id\": \"gFkoms6ys2Gc2cZ93adKV\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4498.630689954985,\n      \"y\": -650.2839201044599,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 148,\n      \"height\": 46,\n      \"seed\": 365660835,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 36,\n      \"fontFamily\": 1,\n      \"text\": \"iPhone 8\",\n      \"baseline\": 32,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 477,\n      \"versionNonce\": 860582049,\n      \"isDeleted\": false,\n      \"id\": \"RugguxXr0qbN7MvBLqwAD\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4817.310815032502,\n      \"y\": -972.534925731331,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 321.2520963004604,\n      \"height\": 563.6159250585482,\n      \"seed\": 970403619,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 350,\n      \"versionNonce\": 232400367,\n      \"isDeleted\": false,\n      \"id\": \"NAYr1abX8fUXKPcRI9XOs\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4817.192800956716,\n      \"y\": -973.395108192849,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 322.50264366819783,\n      \"height\": 27.915662650602368,\n      \"seed\": 1439762573,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 467,\n      \"versionNonce\": 1891021953,\n      \"isDeleted\": false,\n      \"id\": \"pxPNVGMe0h6yTVGTbTilc\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4956.568397897416,\n      \"y\": -969.7178524071214,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 36,\n      \"height\": 21,\n      \"seed\": 1913344707,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 16,\n      \"fontFamily\": 1,\n      \"text\": \"10:47\",\n      \"baseline\": 15,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 396,\n      \"versionNonce\": 580751375,\n      \"isDeleted\": false,\n      \"id\": \"opALne5Ykns5VLyArlwAz\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5090.40000180533,\n      \"y\": -967.4153319449988,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 33.92857142857156,\n      \"height\": 15.476190476190823,\n      \"seed\": 85618413,\n      \"groupIds\": [\n        \"Ib0XKgs9oUSeEHokxAZW5\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 500,\n      \"versionNonce\": 198081633,\n      \"isDeleted\": false,\n      \"id\": \"6IBAjMK02vr3vPu8MrbW1\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5091.888097043426,\n      \"y\": -962.653427183096,\n      \"strokeColor\": \"transparent\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 20.83333333333344,\n      \"height\": 8.33333333333394,\n      \"seed\": 1127875171,\n      \"groupIds\": [\n        \"Ib0XKgs9oUSeEHokxAZW5\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 289,\n      \"versionNonce\": 1001305647,\n      \"isDeleted\": false,\n      \"id\": \"QhQOBAHxUvFdgn-5iLRxl\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4896.281125477821,\n      \"y\": -703.9536156037893,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 170,\n      \"height\": 46,\n      \"seed\": 656935245,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 36,\n      \"fontFamily\": 1,\n      \"text\": \"iPhone SE\",\n      \"baseline\": 32,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 407,\n      \"versionNonce\": 610420801,\n      \"isDeleted\": false,\n      \"id\": \"Y8m1TVlwXXCyQ8cSIXktL\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3470.4931849526038,\n      \"y\": -977.9153972114891,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 373.2520963004604,\n      \"height\": 809.9016393442624,\n      \"seed\": 568174497,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 433,\n      \"versionNonce\": 2146834511,\n      \"isDeleted\": false,\n      \"id\": \"3ESY3PUd2WKzJNemIryqo\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3483.1882678175134,\n      \"y\": -971.8126096015658,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 36,\n      \"height\": 21,\n      \"seed\": 1575619457,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 16,\n      \"fontFamily\": 1,\n      \"text\": \"10:47\",\n      \"baseline\": 15,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 327,\n      \"versionNonce\": 877762593,\n      \"isDeleted\": false,\n      \"id\": \"1mlDKA6BQ_Jc3XjmnR02b\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3798.582371725428,\n      \"y\": -969.510089139445,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 33.92857142857156,\n      \"height\": 15.476190476190823,\n      \"seed\": 1182724961,\n      \"groupIds\": [\n        \"3Mn3ssDut9EdF7680ec_x\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 431,\n      \"versionNonce\": 625751663,\n      \"isDeleted\": false,\n      \"id\": \"8RRZMEPTlr5gQgWU8dQDv\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3800.0704669635234,\n      \"y\": -964.7481843775422,\n      \"strokeColor\": \"transparent\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 20.83333333333344,\n      \"height\": 8.33333333333394,\n      \"seed\": 1072422721,\n      \"groupIds\": [\n        \"3Mn3ssDut9EdF7680ec_x\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 235,\n      \"versionNonce\": 1301015553,\n      \"isDeleted\": false,\n      \"id\": \"Bi8nCuKkb_iu-aiT03JMy\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3561.4634953979185,\n      \"y\": -588.0483727982337,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 180,\n      \"height\": 46,\n      \"seed\": 2022723361,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 36,\n      \"fontFamily\": 1,\n      \"text\": \"iPhone 11/X\",\n      \"baseline\": 32,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"draw\",\n      \"version\": 407,\n      \"versionNonce\": 309956751,\n      \"isDeleted\": false,\n      \"id\": \"0Pvpi73EWXpJ7ajc1JdJf\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3570.4296875,\n      \"y\": -973.6966911764703,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 168.75,\n      \"height\": 21.875,\n      \"seed\": 651936513,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"round\",\n      \"boundElementIds\": [],\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          168.75,\n          0.78125\n        ],\n        [\n          139.0625,\n          21.09375\n        ],\n        [\n          27.34375,\n          21.875\n        ],\n        [\n          0,\n          0\n        ]\n      ],\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": null\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 438,\n      \"versionNonce\": 403751905,\n      \"isDeleted\": false,\n      \"id\": \"T9DoeKgSOdIrHtoZNikg9\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3908.7338797416887,\n      \"y\": -977.2226596581258,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 413.2520963004611,\n      \"height\": 734.5206869633104,\n      \"seed\": 1480897837,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 296,\n      \"versionNonce\": 1094100655,\n      \"isDeleted\": false,\n      \"id\": \"JZCFKTWgk1-GaxhS19gCn\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3908.6158656659027,\n      \"y\": -977.1780802148842,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 411.1173225672806,\n      \"height\": 30.750525035923605,\n      \"seed\": 1131298851,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 443,\n      \"versionNonce\": 267362241,\n      \"isDeleted\": false,\n      \"id\": \"CEmw892MdK8BZpINe3BY1\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4099.596967193749,\n      \"y\": -970.665962043835,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 36,\n      \"height\": 21,\n      \"seed\": 565151629,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 16,\n      \"fontFamily\": 1,\n      \"text\": \"10:47\",\n      \"baseline\": 15,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 335,\n      \"versionNonce\": 1613494479,\n      \"isDeleted\": false,\n      \"id\": \"iSiGFITO5CLNf5_nOz59N\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4274.823066514513,\n      \"y\": -970.198303967034,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 33.92857142857156,\n      \"height\": 15.476190476190823,\n      \"seed\": 949502915,\n      \"groupIds\": [\n        \"XSHbgwCu4WwRPj6D4DhWw\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 439,\n      \"versionNonce\": 55050145,\n      \"isDeleted\": false,\n      \"id\": \"NQNttSdvTemuFz-z0N8QD\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4276.311161752608,\n      \"y\": -965.4363992051312,\n      \"strokeColor\": \"transparent\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 20.83333333333344,\n      \"height\": 8.33333333333394,\n      \"seed\": 1859194349,\n      \"groupIds\": [\n        \"XSHbgwCu4WwRPj6D4DhWw\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 210,\n      \"versionNonce\": 888276719,\n      \"isDeleted\": false,\n      \"id\": \"l-177MAwPPPhFDOY2MTET\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4028.2041901870034,\n      \"y\": -628.7365876258227,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 171,\n      \"height\": 46,\n      \"seed\": 1828770659,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 36,\n      \"fontFamily\": 1,\n      \"text\": \"iPhone 8+\",\n      \"baseline\": 32,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 178,\n      \"versionNonce\": 1346986881,\n      \"isDeleted\": false,\n      \"id\": \"jRQ1ky1J-QfskQUKg2Ru5\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3470.2005208333358,\n      \"y\": -56.795649509805116,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 1024,\n      \"height\": 768,\n      \"seed\": 1085359875,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 408,\n      \"versionNonce\": 1041322255,\n      \"isDeleted\": false,\n      \"id\": \"DWM3uqF3CAS-g1JIzoxGU\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3744.5598958333358,\n      \"y\": 270.5077189966905,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 452,\n      \"height\": 98,\n      \"seed\": 1669118307,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 76.77786988042662,\n      \"fontFamily\": 1,\n      \"text\": \"iPad Mini/Air\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 336,\n      \"versionNonce\": 1664306017,\n      \"isDeleted\": false,\n      \"id\": \"CkvSW0b5LhhZNJOL1La-T\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3472.3671875,\n      \"y\": 820.0376838235293,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 1194.0000000000014,\n      \"height\": 832.0000000000001,\n      \"seed\": 1279104877,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 446,\n      \"versionNonce\": 352474927,\n      \"isDeleted\": false,\n      \"id\": \"oIGA2wVovrd_Y0uLapA0F\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3845.8515625,\n      \"y\": 1210.1626838235293,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 417,\n      \"height\": 98,\n      \"seed\": 1388596525,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 76.77786988042662,\n      \"fontFamily\": 1,\n      \"text\": \"iPad Pro 11\\\"\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 478,\n      \"versionNonce\": 1951741761,\n      \"isDeleted\": false,\n      \"id\": \"p0yXWbn3weBY89QiQ4C4U\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4986.077594739818,\n      \"y\": -260.10711255656406,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 1114.0000000000014,\n      \"height\": 832.0000000000001,\n      \"seed\": 1833933923,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 615,\n      \"versionNonce\": 271423823,\n      \"isDeleted\": false,\n      \"id\": \"10FklxD_zHLPG0EVkDR30\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5296.249469739818,\n      \"y\": 102.95538744343594,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 521,\n      \"height\": 98,\n      \"seed\": 105975629,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 76.77786988042662,\n      \"fontFamily\": 1,\n      \"text\": \"iPad Pro 10.5\\\"\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 209,\n      \"versionNonce\": 101623585,\n      \"isDeleted\": false,\n      \"id\": \"eLX5QkWIPVspUBUwczxS3\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 4750.190716911766,\n      \"y\": 633.4494485294117,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 1364,\n      \"height\": 1022,\n      \"seed\": 1597755203,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 557,\n      \"versionNonce\": 619419503,\n      \"isDeleted\": false,\n      \"id\": \"Vy7UbdRxBLTI8sHZdWytX\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5176.940716911766,\n      \"y\": 1096.1681985294117,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 499,\n      \"height\": 98,\n      \"seed\": 911403587,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 76.77786988042662,\n      \"fontFamily\": 1,\n      \"text\": \"iPad Pro 12.9\\\"\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 238,\n      \"versionNonce\": 583998209,\n      \"isDeleted\": false,\n      \"id\": \"9zWlzfnRRmXSKM9IwzlfI\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5255.094460227272,\n      \"y\": -973.5532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 135,\n      \"height\": 170,\n      \"seed\": 1216894797,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 225,\n      \"versionNonce\": 159699343,\n      \"isDeleted\": false,\n      \"id\": \"ncCiOBiAtYOVAFmzhF6Aa\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5292.094460227272,\n      \"y\": -930.0532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 57,\n      \"height\": 77,\n      \"seed\": 457695843,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 20,\n      \"fontFamily\": 1,\n      \"text\": \"Apple\\nWatch\\n38\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 273,\n      \"versionNonce\": 1528173281,\n      \"isDeleted\": false,\n      \"id\": \"etdsd9xoXr-KLOr7uM-qB\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5432.094460227272,\n      \"y\": -975.5532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 164,\n      \"height\": 197,\n      \"seed\": 1752129283,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 264,\n      \"versionNonce\": 389138351,\n      \"isDeleted\": false,\n      \"id\": \"Woo7Exhlr9SAq2zI4anRa\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5484.594460227272,\n      \"y\": -917.0532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 57,\n      \"height\": 77,\n      \"seed\": 1917738669,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 20,\n      \"fontFamily\": 1,\n      \"text\": \"Apple\\nWatch\\n40\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 372,\n      \"versionNonce\": 57872065,\n      \"isDeleted\": false,\n      \"id\": \"BhCjNDjuAASDRJHyxEuuX\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5636.094460227272,\n      \"y\": -976.5532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 154,\n      \"height\": 196,\n      \"seed\": 14970371,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 302,\n      \"versionNonce\": 1813707215,\n      \"isDeleted\": false,\n      \"id\": \"Y_R_hKcU7WZAya0yOGnat\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5682.594460227272,\n      \"y\": -915.0532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 57,\n      \"height\": 77,\n      \"seed\": 1341972237,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 20,\n      \"fontFamily\": 1,\n      \"text\": \"Apple\\nWatch\\n42\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 279,\n      \"versionNonce\": 847613601,\n      \"isDeleted\": false,\n      \"id\": \"mSmm7t6tya07tz7h-cNA3\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5827.094460227272,\n      \"y\": -976.5532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 184.99999999999997,\n      \"height\": 226.99999999999997,\n      \"seed\": 198811085,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 355,\n      \"versionNonce\": 1297672175,\n      \"isDeleted\": false,\n      \"id\": \"0IeZjoR6yYD75KIN1xzO4\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5894.594460227272,\n      \"y\": -902.0532252673775,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 57,\n      \"height\": 77,\n      \"seed\": 879106435,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 20,\n      \"fontFamily\": 1,\n      \"text\": \"Apple\\nWatch\\n44\",\n      \"baseline\": 69,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 282,\n      \"versionNonce\": 2130514561,\n      \"isDeleted\": false,\n      \"id\": \"ZnQbGVVRPQJc7A89aDf8y\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5363.19327445653,\n      \"y\": -637.9572010869578,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 530,\n      \"height\": 132,\n      \"seed\": 724587843,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 51.7166846776603,\n      \"fontFamily\": 1,\n      \"text\": \"Apple Device Frames\\nFor Excalidraw\",\n      \"baseline\": 112,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 191,\n      \"versionNonce\": 1223954959,\n      \"isDeleted\": false,\n      \"id\": \"9Z-Cb84PGUiD1mF1sIqum\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5339.8671875,\n      \"y\": -473.109375,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 234,\n      \"height\": 36,\n      \"seed\": 133723437,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 28,\n      \"fontFamily\": 1,\n      \"text\": \"by François Best\",\n      \"baseline\": 25,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 322,\n      \"versionNonce\": 2107185761,\n      \"isDeleted\": false,\n      \"id\": \"tiYgiLEOUdi6qage2UX-1\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5597.8671875,\n      \"y\": -464.109375,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 191,\n      \"height\": 21,\n      \"seed\": 804151053,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 16,\n      \"fontFamily\": 1,\n      \"text\": \"https://francoisbest.com\",\n      \"baseline\": 15,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 394,\n      \"versionNonce\": 400881711,\n      \"isDeleted\": false,\n      \"id\": \"gK5KZbdcaPGO0vG7X604Y\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5811.8671875,\n      \"y\": -464.109375,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 111,\n      \"height\": 20,\n      \"seed\": 193714701,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 16,\n      \"fontFamily\": 1,\n      \"text\": \"@fortysevenfx\",\n      \"baseline\": 14,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 182,\n      \"versionNonce\": 2133403201,\n      \"isDeleted\": false,\n      \"id\": \"ULkOgzry-Q-rUk_tzP2Nl\",\n      \"fillStyle\": \"hachure\",\n      \"strokeWidth\": 1,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 5342.398972602754,\n      \"y\": -419.2082619863007,\n      \"strokeColor\": \"#888\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 194,\n      \"height\": 25,\n      \"seed\": 510101662,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 20,\n      \"fontFamily\": 1,\n      \"text\": \"License: CC BY 4.0\",\n      \"baseline\": 18,\n      \"textAlign\": \"left\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 596,\n      \"versionNonce\": 1194759457,\n      \"isDeleted\": false,\n      \"id\": \"f6zJK0ez4xwCUWTzzRtvH\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 2979.0814202467213,\n      \"y\": -981.7977501526657,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 417.2520963004604,\n      \"height\": 895.9016393442625,\n      \"seed\": 1782849967,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 543,\n      \"versionNonce\": 1560381807,\n      \"isDeleted\": false,\n      \"id\": \"WEQ2Ykjd8NQBsCviYeYCF\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 2995.4128667479945,\n      \"y\": -975.6949625427424,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 36,\n      \"height\": 21,\n      \"seed\": 1691047105,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 16,\n      \"fontFamily\": 1,\n      \"text\": \"10:47\",\n      \"baseline\": 15,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 469,\n      \"versionNonce\": 348735745,\n      \"isDeleted\": false,\n      \"id\": \"y2SsDP9_A2OSYipnnpClQ\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3341.1706070195455,\n      \"y\": -973.3924420806215,\n      \"strokeColor\": \"#000000\",\n      \"backgroundColor\": \"transparent\",\n      \"width\": 33.92857142857156,\n      \"height\": 15.476190476190823,\n      \"seed\": 533861327,\n      \"groupIds\": [\n        \"xoYxtbiFHqqbzopg_Gu-L\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"rectangle\",\n      \"version\": 573,\n      \"versionNonce\": 1846313871,\n      \"isDeleted\": false,\n      \"id\": \"myB-E4pt4y3dnemdsOyaa\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3342.658702257641,\n      \"y\": -968.6305373187188,\n      \"strokeColor\": \"transparent\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 20.83333333333344,\n      \"height\": 8.33333333333394,\n      \"seed\": 216339617,\n      \"groupIds\": [\n        \"xoYxtbiFHqqbzopg_Gu-L\"\n      ],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": []\n    },\n    {\n      \"type\": \"text\",\n      \"version\": 366,\n      \"versionNonce\": 697991393,\n      \"isDeleted\": false,\n      \"id\": \"6YzyvGTZvGP4QduAYAfZ8\",\n      \"fillStyle\": \"solid\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3040.3699125102175,\n      \"y\": -591.0216348303195,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 303,\n      \"height\": 46,\n      \"seed\": 1864155631,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"sharp\",\n      \"boundElementIds\": [],\n      \"fontSize\": 36,\n      \"fontFamily\": 1,\n      \"text\": \"iPhone 11 Pro Max\",\n      \"baseline\": 32,\n      \"textAlign\": \"center\",\n      \"verticalAlign\": \"top\"\n    },\n    {\n      \"type\": \"draw\",\n      \"version\": 614,\n      \"versionNonce\": 871578031,\n      \"isDeleted\": false,\n      \"id\": \"xA7HXLu-lADpFs0fYt_RW\",\n      \"fillStyle\": \"cross-hatch\",\n      \"strokeWidth\": 2,\n      \"strokeStyle\": \"solid\",\n      \"roughness\": 1,\n      \"opacity\": 100,\n      \"angle\": 0,\n      \"x\": 3081.7451955213905,\n      \"y\": -979.3971665643244,\n      \"strokeColor\": \"#000\",\n      \"backgroundColor\": \"#000\",\n      \"width\": 206.88279736450622,\n      \"height\": 21.876332052978537,\n      \"seed\": 796645505,\n      \"groupIds\": [],\n      \"strokeSharpness\": \"round\",\n      \"boundElementIds\": [],\n      \"points\": [\n        [\n          0,\n          0\n        ],\n        [\n          206.88279736450622,\n          0.7812975733206621\n        ],\n        [\n          170.48674968000978,\n          21.095034479657876\n        ],\n        [\n          33.52267549887833,\n          21.876332052978537\n        ],\n        [\n          0,\n          0\n        ]\n      ],\n      \"lastCommittedPoint\": null,\n      \"startArrowhead\": null,\n      \"endArrowhead\": null\n    }\n  ],\n  \"appState\": {\n    \"viewBackgroundColor\": \"#fff\",\n    \"gridSize\": null\n  }\n}"
  },
  {
    "path": "packages/francoisbest.com/public/img/posts/2020/mobile-device-frames-for-excalidraw/apple-device-frames.excalidrawlib",
    "content": "{\n  \"type\": \"excalidrawlib\",\n  \"version\": 1,\n  \"library\": [\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 400,\n        \"versionNonce\": 18212393,\n        \"isDeleted\": false,\n        \"id\": \"D-5H9qieIohqWqvHmNdzM\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3470.4931849526038,\n        \"y\": -977.9153972114891,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 373.2520963004604,\n        \"height\": 809.9016393442624,\n        \"seed\": 845376771,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 426,\n        \"versionNonce\": 809610471,\n        \"isDeleted\": false,\n        \"id\": \"HHC9KRksLXnuGtSxSF9i5\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3483.1882678175134,\n        \"y\": -971.8126096015658,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 36,\n        \"height\": 21,\n        \"seed\": 1846858915,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 16,\n        \"fontFamily\": 1,\n        \"text\": \"10:47\",\n        \"baseline\": 15,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 320,\n        \"versionNonce\": 799619337,\n        \"isDeleted\": false,\n        \"id\": \"CU4fziUMv05fGvpVpZ0hh\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3798.582371725428,\n        \"y\": -969.510089139445,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 33.92857142857156,\n        \"height\": 15.476190476190823,\n        \"seed\": 1238006541,\n        \"groupIds\": [\n          \"LUq7uDz5gX-AEy58SaBdH\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 424,\n        \"versionNonce\": 1701410823,\n        \"isDeleted\": false,\n        \"id\": \"j6VtvJsF4vQs0VEOg82U0\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3800.0704669635234,\n        \"y\": -964.7481843775422,\n        \"strokeColor\": \"transparent\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 20.83333333333344,\n        \"height\": 8.33333333333394,\n        \"seed\": 1786267715,\n        \"groupIds\": [\n          \"LUq7uDz5gX-AEy58SaBdH\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 228,\n        \"versionNonce\": 437202921,\n        \"isDeleted\": false,\n        \"id\": \"fG8tK6vkG-_xvjKo5o7WR\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3561.4634953979185,\n        \"y\": -588.0483727982337,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 180,\n        \"height\": 46,\n        \"seed\": 1312919917,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 36,\n        \"fontFamily\": 1,\n        \"text\": \"iPhone 11/X\",\n        \"baseline\": 32,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      },\n      {\n        \"type\": \"draw\",\n        \"version\": 400,\n        \"versionNonce\": 900390695,\n        \"isDeleted\": false,\n        \"id\": \"P08tftuUudssxoKZtHzw7\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3570.4296875,\n        \"y\": -973.6966911764703,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 168.75,\n        \"height\": 21.875,\n        \"seed\": 619189581,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"round\",\n        \"boundElementIds\": [],\n        \"points\": [\n          [\n            0,\n            0\n          ],\n          [\n            168.75,\n            0.78125\n          ],\n          [\n            139.0625,\n            21.09375\n          ],\n          [\n            27.34375,\n            21.875\n          ],\n          [\n            0,\n            0\n          ]\n        ],\n        \"lastCommittedPoint\": null,\n        \"startArrowhead\": null,\n        \"endArrowhead\": null\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 434,\n        \"versionNonce\": 1415953359,\n        \"isDeleted\": false,\n        \"id\": \"T9DoeKgSOdIrHtoZNikg9\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3908.7338797416887,\n        \"y\": -977.2226596581258,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 413.2520963004611,\n        \"height\": 734.5206869633104,\n        \"seed\": 1480897837,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 292,\n        \"versionNonce\": 1522527393,\n        \"isDeleted\": false,\n        \"id\": \"JZCFKTWgk1-GaxhS19gCn\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3908.6158656659027,\n        \"y\": -977.1780802148842,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 411.1173225672806,\n        \"height\": 30.750525035923605,\n        \"seed\": 1131298851,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 439,\n        \"versionNonce\": 586901999,\n        \"isDeleted\": false,\n        \"id\": \"CEmw892MdK8BZpINe3BY1\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4099.596967193749,\n        \"y\": -970.665962043835,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 36,\n        \"height\": 21,\n        \"seed\": 565151629,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 16,\n        \"fontFamily\": 1,\n        \"text\": \"10:47\",\n        \"baseline\": 15,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 331,\n        \"versionNonce\": 742109313,\n        \"isDeleted\": false,\n        \"id\": \"iSiGFITO5CLNf5_nOz59N\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4274.823066514513,\n        \"y\": -970.198303967034,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 33.92857142857156,\n        \"height\": 15.476190476190823,\n        \"seed\": 949502915,\n        \"groupIds\": [\n          \"XSHbgwCu4WwRPj6D4DhWw\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 435,\n        \"versionNonce\": 183915535,\n        \"isDeleted\": false,\n        \"id\": \"NQNttSdvTemuFz-z0N8QD\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4276.311161752608,\n        \"y\": -965.4363992051312,\n        \"strokeColor\": \"transparent\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 20.83333333333344,\n        \"height\": 8.33333333333394,\n        \"seed\": 1859194349,\n        \"groupIds\": [\n          \"XSHbgwCu4WwRPj6D4DhWw\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 206,\n        \"versionNonce\": 89389153,\n        \"isDeleted\": false,\n        \"id\": \"l-177MAwPPPhFDOY2MTET\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4028.2041901870034,\n        \"y\": -628.7365876258227,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 171,\n        \"height\": 46,\n        \"seed\": 1828770659,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 36,\n        \"fontFamily\": 1,\n        \"text\": \"iPhone 8+\",\n        \"baseline\": 32,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 386,\n        \"versionNonce\": 1255385263,\n        \"isDeleted\": false,\n        \"id\": \"lGFcln3rVyD8dJDiAL6QT\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4385.660379509671,\n        \"y\": -974.769992136763,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 373.2520963004604,\n        \"height\": 668.5206869633105,\n        \"seed\": 204525261,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 264,\n        \"versionNonce\": 1008451009,\n        \"isDeleted\": false,\n        \"id\": \"mSnQoc1Qg6JMFAQFIKEe9\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4385.542365433885,\n        \"y\": -974.7254126935213,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 373.50264366819783,\n        \"height\": 28.91566265060237,\n        \"seed\": 995053101,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 409,\n        \"versionNonce\": 1926806223,\n        \"isDeleted\": false,\n        \"id\": \"S2lXGGXTahLjEXEcaVGME\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4549.91796237458,\n        \"y\": -970.048156907792,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 36,\n        \"height\": 21,\n        \"seed\": 1047130093,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 16,\n        \"fontFamily\": 1,\n        \"text\": \"10:47\",\n        \"baseline\": 15,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 332,\n        \"versionNonce\": 1346395553,\n        \"isDeleted\": false,\n        \"id\": \"RBE16_O88hYvJgq3VlXKj\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4713.749566282495,\n        \"y\": -967.7456364456712,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 33.92857142857156,\n        \"height\": 15.476190476190823,\n        \"seed\": 1528698371,\n        \"groupIds\": [\n          \"YPuDD5bttLUHLq5PDbIOi\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 436,\n        \"versionNonce\": 414615791,\n        \"isDeleted\": false,\n        \"id\": \"T8yoZLMVYK5fMtzzPh-41\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4715.23766152059,\n        \"y\": -962.9837316837684,\n        \"strokeColor\": \"transparent\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 20.83333333333344,\n        \"height\": 8.33333333333394,\n        \"seed\": 465472045,\n        \"groupIds\": [\n          \"YPuDD5bttLUHLq5PDbIOi\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 206,\n        \"versionNonce\": 1518731649,\n        \"isDeleted\": false,\n        \"id\": \"gFkoms6ys2Gc2cZ93adKV\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4498.630689954985,\n        \"y\": -650.2839201044599,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 148,\n        \"height\": 46,\n        \"seed\": 365660835,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 36,\n        \"fontFamily\": 1,\n        \"text\": \"iPhone 8\",\n        \"baseline\": 32,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 473,\n        \"versionNonce\": 2046137103,\n        \"isDeleted\": false,\n        \"id\": \"RugguxXr0qbN7MvBLqwAD\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4817.310815032502,\n        \"y\": -972.534925731331,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 321.2520963004604,\n        \"height\": 563.6159250585482,\n        \"seed\": 970403619,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 346,\n        \"versionNonce\": 2016160097,\n        \"isDeleted\": false,\n        \"id\": \"NAYr1abX8fUXKPcRI9XOs\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4817.192800956716,\n        \"y\": -973.395108192849,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 322.50264366819783,\n        \"height\": 27.915662650602368,\n        \"seed\": 1439762573,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 463,\n        \"versionNonce\": 252598575,\n        \"isDeleted\": false,\n        \"id\": \"pxPNVGMe0h6yTVGTbTilc\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4956.568397897416,\n        \"y\": -969.7178524071214,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 36,\n        \"height\": 21,\n        \"seed\": 1913344707,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 16,\n        \"fontFamily\": 1,\n        \"text\": \"10:47\",\n        \"baseline\": 15,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 392,\n        \"versionNonce\": 1921144129,\n        \"isDeleted\": false,\n        \"id\": \"opALne5Ykns5VLyArlwAz\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5090.40000180533,\n        \"y\": -967.4153319449988,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 33.92857142857156,\n        \"height\": 15.476190476190823,\n        \"seed\": 85618413,\n        \"groupIds\": [\n          \"Ib0XKgs9oUSeEHokxAZW5\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 496,\n        \"versionNonce\": 761879375,\n        \"isDeleted\": false,\n        \"id\": \"6IBAjMK02vr3vPu8MrbW1\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5091.888097043426,\n        \"y\": -962.653427183096,\n        \"strokeColor\": \"transparent\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 20.83333333333344,\n        \"height\": 8.33333333333394,\n        \"seed\": 1127875171,\n        \"groupIds\": [\n          \"Ib0XKgs9oUSeEHokxAZW5\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 285,\n        \"versionNonce\": 1021838625,\n        \"isDeleted\": false,\n        \"id\": \"QhQOBAHxUvFdgn-5iLRxl\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4896.281125477821,\n        \"y\": -703.9536156037893,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 170,\n        \"height\": 46,\n        \"seed\": 656935245,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 36,\n        \"fontFamily\": 1,\n        \"text\": \"iPhone SE\",\n        \"baseline\": 32,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 234,\n        \"versionNonce\": 93645487,\n        \"isDeleted\": false,\n        \"id\": \"9zWlzfnRRmXSKM9IwzlfI\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5255.094460227272,\n        \"y\": -973.5532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 135,\n        \"height\": 170,\n        \"seed\": 1216894797,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 221,\n        \"versionNonce\": 2055707585,\n        \"isDeleted\": false,\n        \"id\": \"ncCiOBiAtYOVAFmzhF6Aa\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5292.094460227272,\n        \"y\": -930.0532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 57,\n        \"height\": 77,\n        \"seed\": 457695843,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 20,\n        \"fontFamily\": 1,\n        \"text\": \"Apple\\nWatch\\n38\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 269,\n        \"versionNonce\": 136428751,\n        \"isDeleted\": false,\n        \"id\": \"etdsd9xoXr-KLOr7uM-qB\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5432.094460227272,\n        \"y\": -975.5532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 164,\n        \"height\": 197,\n        \"seed\": 1752129283,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 260,\n        \"versionNonce\": 1367374753,\n        \"isDeleted\": false,\n        \"id\": \"Woo7Exhlr9SAq2zI4anRa\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5484.594460227272,\n        \"y\": -917.0532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 57,\n        \"height\": 77,\n        \"seed\": 1917738669,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 20,\n        \"fontFamily\": 1,\n        \"text\": \"Apple\\nWatch\\n40\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 368,\n        \"versionNonce\": 1636780783,\n        \"isDeleted\": false,\n        \"id\": \"BhCjNDjuAASDRJHyxEuuX\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5636.094460227272,\n        \"y\": -976.5532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 154,\n        \"height\": 196,\n        \"seed\": 14970371,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 298,\n        \"versionNonce\": 974282625,\n        \"isDeleted\": false,\n        \"id\": \"Y_R_hKcU7WZAya0yOGnat\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5682.594460227272,\n        \"y\": -915.0532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 57,\n        \"height\": 77,\n        \"seed\": 1341972237,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 20,\n        \"fontFamily\": 1,\n        \"text\": \"Apple\\nWatch\\n42\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 275,\n        \"versionNonce\": 1852183823,\n        \"isDeleted\": false,\n        \"id\": \"mSmm7t6tya07tz7h-cNA3\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5827.094460227272,\n        \"y\": -976.5532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 184.99999999999997,\n        \"height\": 226.99999999999997,\n        \"seed\": 198811085,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 351,\n        \"versionNonce\": 578602849,\n        \"isDeleted\": false,\n        \"id\": \"0IeZjoR6yYD75KIN1xzO4\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5894.594460227272,\n        \"y\": -902.0532252673775,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 57,\n        \"height\": 77,\n        \"seed\": 879106435,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 20,\n        \"fontFamily\": 1,\n        \"text\": \"Apple\\nWatch\\n44\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 174,\n        \"versionNonce\": 609155631,\n        \"isDeleted\": false,\n        \"id\": \"jRQ1ky1J-QfskQUKg2Ru5\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3470.2005208333358,\n        \"y\": -56.795649509805116,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 1024,\n        \"height\": 768,\n        \"seed\": 1085359875,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 404,\n        \"versionNonce\": 1205355585,\n        \"isDeleted\": false,\n        \"id\": \"DWM3uqF3CAS-g1JIzoxGU\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3744.5598958333358,\n        \"y\": 270.5077189966905,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 452,\n        \"height\": 98,\n        \"seed\": 1669118307,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 76.77786988042662,\n        \"fontFamily\": 1,\n        \"text\": \"iPad Mini/Air\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 474,\n        \"versionNonce\": 1303083631,\n        \"isDeleted\": false,\n        \"id\": \"p0yXWbn3weBY89QiQ4C4U\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4986.077594739818,\n        \"y\": -260.10711255656406,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 1114.0000000000014,\n        \"height\": 832.0000000000001,\n        \"seed\": 1833933923,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 611,\n        \"versionNonce\": 1353902081,\n        \"isDeleted\": false,\n        \"id\": \"10FklxD_zHLPG0EVkDR30\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5296.249469739818,\n        \"y\": 102.95538744343594,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 521,\n        \"height\": 98,\n        \"seed\": 105975629,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 76.77786988042662,\n        \"fontFamily\": 1,\n        \"text\": \"iPad Pro 10.5\\\"\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 332,\n        \"versionNonce\": 1944968271,\n        \"isDeleted\": false,\n        \"id\": \"CkvSW0b5LhhZNJOL1La-T\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3472.3671875,\n        \"y\": 820.0376838235293,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 1194.0000000000014,\n        \"height\": 832.0000000000001,\n        \"seed\": 1279104877,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 442,\n        \"versionNonce\": 1873286177,\n        \"isDeleted\": false,\n        \"id\": \"oIGA2wVovrd_Y0uLapA0F\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3845.8515625,\n        \"y\": 1210.1626838235293,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 417,\n        \"height\": 98,\n        \"seed\": 1388596525,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 76.77786988042662,\n        \"fontFamily\": 1,\n        \"text\": \"iPad Pro 11\\\"\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 205,\n        \"versionNonce\": 1984976015,\n        \"isDeleted\": false,\n        \"id\": \"eLX5QkWIPVspUBUwczxS3\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 4750.190716911766,\n        \"y\": 633.4494485294117,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 1364,\n        \"height\": 1022,\n        \"seed\": 1597755203,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 553,\n        \"versionNonce\": 351813601,\n        \"isDeleted\": false,\n        \"id\": \"Vy7UbdRxBLTI8sHZdWytX\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 5176.940716911766,\n        \"y\": 1096.1681985294117,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 499,\n        \"height\": 98,\n        \"seed\": 911403587,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 76.77786988042662,\n        \"fontFamily\": 1,\n        \"text\": \"iPad Pro 12.9\\\"\",\n        \"baseline\": 69,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      }\n    ],\n    [\n      {\n        \"type\": \"rectangle\",\n        \"version\": 596,\n        \"versionNonce\": 1194759457,\n        \"isDeleted\": false,\n        \"id\": \"f6zJK0ez4xwCUWTzzRtvH\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 2979.0814202467213,\n        \"y\": -981.7977501526657,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 417.2520963004604,\n        \"height\": 895.9016393442625,\n        \"seed\": 1782849967,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 543,\n        \"versionNonce\": 1560381807,\n        \"isDeleted\": false,\n        \"id\": \"WEQ2Ykjd8NQBsCviYeYCF\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 2995.4128667479945,\n        \"y\": -975.6949625427424,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 36,\n        \"height\": 21,\n        \"seed\": 1691047105,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 16,\n        \"fontFamily\": 1,\n        \"text\": \"10:47\",\n        \"baseline\": 15,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 469,\n        \"versionNonce\": 348735745,\n        \"isDeleted\": false,\n        \"id\": \"y2SsDP9_A2OSYipnnpClQ\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3341.1706070195455,\n        \"y\": -973.3924420806215,\n        \"strokeColor\": \"#000000\",\n        \"backgroundColor\": \"transparent\",\n        \"width\": 33.92857142857156,\n        \"height\": 15.476190476190823,\n        \"seed\": 533861327,\n        \"groupIds\": [\n          \"xoYxtbiFHqqbzopg_Gu-L\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"rectangle\",\n        \"version\": 573,\n        \"versionNonce\": 1846313871,\n        \"isDeleted\": false,\n        \"id\": \"myB-E4pt4y3dnemdsOyaa\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3342.658702257641,\n        \"y\": -968.6305373187188,\n        \"strokeColor\": \"transparent\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 20.83333333333344,\n        \"height\": 8.33333333333394,\n        \"seed\": 216339617,\n        \"groupIds\": [\n          \"xoYxtbiFHqqbzopg_Gu-L\"\n        ],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": []\n      },\n      {\n        \"type\": \"text\",\n        \"version\": 366,\n        \"versionNonce\": 697991393,\n        \"isDeleted\": false,\n        \"id\": \"6YzyvGTZvGP4QduAYAfZ8\",\n        \"fillStyle\": \"solid\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3040.3699125102175,\n        \"y\": -591.0216348303195,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 303,\n        \"height\": 46,\n        \"seed\": 1864155631,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"sharp\",\n        \"boundElementIds\": [],\n        \"fontSize\": 36,\n        \"fontFamily\": 1,\n        \"text\": \"iPhone 11 Pro Max\",\n        \"baseline\": 32,\n        \"textAlign\": \"center\",\n        \"verticalAlign\": \"top\"\n      },\n      {\n        \"type\": \"draw\",\n        \"version\": 614,\n        \"versionNonce\": 871578031,\n        \"isDeleted\": false,\n        \"id\": \"xA7HXLu-lADpFs0fYt_RW\",\n        \"fillStyle\": \"cross-hatch\",\n        \"strokeWidth\": 2,\n        \"strokeStyle\": \"solid\",\n        \"roughness\": 1,\n        \"opacity\": 100,\n        \"angle\": 0,\n        \"x\": 3081.7451955213905,\n        \"y\": -979.3971665643244,\n        \"strokeColor\": \"#000\",\n        \"backgroundColor\": \"#000\",\n        \"width\": 206.88279736450622,\n        \"height\": 21.876332052978537,\n        \"seed\": 796645505,\n        \"groupIds\": [],\n        \"strokeSharpness\": \"round\",\n        \"boundElementIds\": [],\n        \"points\": [\n          [\n            0,\n            0\n          ],\n          [\n            206.88279736450622,\n            0.7812975733206621\n          ],\n          [\n            170.48674968000978,\n            21.095034479657876\n          ],\n          [\n            33.52267549887833,\n            21.876332052978537\n          ],\n          [\n            0,\n            0\n          ]\n        ],\n        \"lastCommittedPoint\": null,\n        \"startArrowhead\": null,\n        \"endArrowhead\": null\n      }\n    ]\n  ]\n}"
  },
  {
    "path": "packages/francoisbest.com/public/manifest.webmanifest",
    "content": "{\n  \"name\": \"François Best\",\n  \"short_name\": \"François Best\",\n  \"description\": \"Freelance developer\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#fff\",\n  \"theme_color\": \"#fff\",\n  \"icons\": [\n    {\n      \"src\": \"./favicons/android-icon-36x36.png\",\n      \"sizes\": \"36x36\",\n      \"type\": \"image/png\",\n      \"density\": \"0.75\"\n    },\n    {\n      \"src\": \"./favicons/android-icon-48x48.png\",\n      \"sizes\": \"48x48\",\n      \"type\": \"image/png\",\n      \"density\": \"1.0\"\n    },\n    {\n      \"src\": \"./favicons/android-icon-72x72.png\",\n      \"sizes\": \"72x72\",\n      \"type\": \"image/png\",\n      \"density\": \"1.5\"\n    },\n    {\n      \"src\": \"./favicons/android-icon-96x96.png\",\n      \"sizes\": \"96x96\",\n      \"type\": \"image/png\",\n      \"density\": \"2.0\"\n    },\n    {\n      \"src\": \"./favicons/android-icon-144x144.png\",\n      \"sizes\": \"144x144\",\n      \"type\": \"image/png\",\n      \"density\": \"3.0\"\n    },\n    {\n      \"src\": \"./favicons/android-icon-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"density\": \"4.0\"\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/francoisbest.com/scripts/isr.mjs",
    "content": "const url = tag =>\n  `http://localhost:3000/api/isr?token=${process.env.ISR_TOKEN}&tag=${tag}`\n\nawait Promise.all([fetch(url('npm')), fetch(url('github'))])\n"
  },
  {
    "path": "packages/francoisbest.com/source.config.ts",
    "content": "import {\n  defineCollections,\n  defineConfig,\n  applyMdxPreset,\n  frontmatterSchema\n} from 'fumadocs-mdx/config'\nimport { z } from 'zod'\nimport { fromHtml } from 'hast-util-from-html'\nimport fs from 'node:fs'\nimport rehypeAutolinkHeadings from 'rehype-autolink-headings'\nimport rehypePrettyCode, {\n  type Options as PrettyCodeOptions\n} from 'rehype-pretty-code'\nimport rehypeSlug from 'rehype-slug'\nimport remarkGfm from 'remark-gfm'\nimport remarkSmartypants from 'remark-smartypants'\n\nconst codeHighlightingOptions: PrettyCodeOptions = {\n  theme: JSON.parse(\n    fs.readFileSync('./src/ui/theme/moonlight-ii.json', 'utf-8')\n  ),\n  onVisitTitle(element) {\n    element.tagName = 'figcaption'\n    if (!element.properties) {\n      element.properties = {}\n    }\n    element.properties.className = ['font-mono']\n    const fileIcon = fromHtml(\n      `<svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        height=\"1em\"\n        width=\"1em\"\n        stroke=\"currentColor\"\n        fill=\"none\"\n        stroke-width=\"2\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        class=\"inline-block -mt-[2px] mr-2\"\n        aria-label=\"File name\"\n        role=\"presentation\"\n      >\n        <path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path>\n        <polyline points=\"14 2 14 8 20 8\"></polyline>\n        <line x1=\"16\" y1=\"13\" x2=\"8\" y2=\"13\"></line>\n        <line x1=\"16\" y1=\"17\" x2=\"8\" y2=\"17\"></line>\n        <polyline points=\"10 9 9 9 8 9\"></polyline>\n      </svg>`,\n      { fragment: true, space: 'svg' }\n    )\n    // @ts-expect-error - hast types don't expose children array\n    element.children.unshift(fileIcon.children[0])\n  },\n  onVisitCaption(element) {\n    element.tagName = 'figcaption'\n    if (!element.properties) {\n      element.properties = {}\n    }\n    element.properties.style = 'text-align:center;'\n  }\n}\n\nexport const blog = defineCollections({\n  type: 'doc',\n  dir: './content/blog',\n  schema: frontmatterSchema.extend({\n    publicationDate: z.coerce.date().optional(),\n    tags: z.array(z.string()).optional(),\n    canonical: z.string().optional()\n  }),\n  mdxOptions: applyMdxPreset({\n    rehypeCodeOptions: false,\n    remarkPlugins: [remarkGfm, remarkSmartypants],\n    rehypePlugins: (v: any[]) => [\n      [rehypePrettyCode, codeHighlightingOptions],\n      rehypeSlug,\n      [rehypeAutolinkHeadings, { behavior: 'append' }],\n      ...v\n    ]\n  })\n})\n\nexport default defineConfig({})\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(dashboards)/layout.tsx",
    "content": "import { Footer } from 'ui/layouts/footer'\n\nexport default function DashboardLayout({\n  children\n}: {\n  children: React.ReactNode\n}) {\n  return (\n    <>\n      {children}\n      <Footer />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(dashboards)/sandbox/.gitignore",
    "content": "*\n!.gitignore\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/hashvatar/demo.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { Input } from 'ui/components/forms/inputs'\nimport { useHash } from 'ui/components/hashvatar.client'\nimport { SHA256Avatar, Variants } from 'ui/components/hashvatar.server'\n\nexport default function HashvatarDemoPage() {\n  const [text, setText] = React.useState('Hello, world!')\n  const hash = useHash(\n    text,\n    '315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3'\n  )\n  const hashText = React.useMemo(() => {\n    return [hash.slice(0, hash.length / 2), hash.slice(hash.length / 2)].join(\n      '<wbr>'\n    )\n  }, [hash])\n  const [variant, setVariant] = React.useState<Variants>('stagger')\n\n  return (\n    <>\n      <SHA256Avatar\n        hash={hash}\n        width=\"16rem\"\n        height=\"16rem\"\n        variant={variant}\n        className=\"mx-auto my-8 block\"\n      />\n      <p\n        className=\"text-center font-mono text-sm text-gray-500\"\n        dangerouslySetInnerHTML={{ __html: hashText }}\n      />\n      <Input\n        value={text}\n        onChange={e => setText(e.target.value)}\n        className=\"mx-auto mt-4 max-w-xs\"\n      />\n      <nav className=\"mx-auto my-12 flex max-w-xl flex-wrap justify-between\">\n        <VariantButton variant=\"normal\" hash={hash} onClick={setVariant} />\n        <VariantButton variant=\"stagger\" hash={hash} onClick={setVariant} />\n        <VariantButton variant=\"spider\" hash={hash} onClick={setVariant} />\n        <VariantButton variant=\"flower\" hash={hash} onClick={setVariant} />\n        <VariantButton variant=\"gem\" hash={hash} onClick={setVariant} />\n      </nav>\n    </>\n  )\n}\n\n// --\n\ntype VariantButtonProps = {\n  variant: Variants\n  onClick: (variant: Variants) => void\n  hash: string\n}\n\nconst VariantButton: React.FC<VariantButtonProps> = ({\n  variant,\n  onClick,\n  hash\n}) => {\n  return (\n    <button\n      onClick={() => onClick(variant)}\n      className=\"rounded-sm p-2 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-800\"\n    >\n      <SHA256Avatar width=\"4rem\" height=\"4rem\" hash={hash} variant={variant} />\n      <span className=\"mt-1 block\">\n        {variant[0].toUpperCase() + variant.slice(1)}\n      </span>\n    </button>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/hashvatar/page.tsx",
    "content": "import { BlogPostEmbed } from 'ui/embeds/blog-post-embed'\nimport HashvatarDemoPage from './demo'\n\nexport const metadata = {\n  title: 'Hashvatar',\n  description: 'Generate your own SHA-256 based avatar'\n}\n\nexport default function HashvatarPage() {\n  return (\n    <>\n      <HashvatarDemoPage />\n      <BlogPostEmbed slug={['2021', 'hashvatars']} />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/page.tsx",
    "content": "import { gitHubUrl, resolve } from 'lib/paths'\nimport { Note } from 'ui/components/note'\nimport { HorcruxRecompose } from './recompose'\nimport { HorcruxSplit } from './split'\n\nexport const metadata = {\n  title: 'Horcrux',\n  description: 'Split and recompose secrets with Shamir Secret Sharing'\n}\n\nexport default function HorcruxPage() {\n  return (\n    <>\n      <hgroup className=\"prose dark:prose-invert md:prose-lg\">\n        <h1 className=\"!mb-1 font-bold\">Horcrux</h1>\n        <figcaption>📓 💍 📿 👑 🏆 🐍 ⚡</figcaption>\n      </hgroup>\n      <Note status=\"info\" title=\"About\">\n        Split and recompose secrets with{' '}\n        <a\n          href=\"https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing\"\n          className=\"underline\"\n        >\n          Shamir Secret Sharing\n        </a>\n        .\n      </Note>\n      <HorcruxSplit\n        gitHubSourceUrl={gitHubUrl(resolve(import.meta.url, './split.tsx'))}\n      />\n      <HorcruxRecompose />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/recompose.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { FiPlusCircle, FiTrash2 } from 'react-icons/fi'\nimport { Button } from 'ui/components/buttons/button'\nimport { Textarea } from 'ui/components/forms/inputs'\nimport vegemite from 'vegemite'\nimport { assembleSecret, cleanupShard } from './tss'\n\ntype ShardStateMap = {\n  set: { index: number; shard: string }\n  add: null\n  remove: null\n}\n\ntype ShardState = {\n  shards: string[]\n}\n\nconst store = vegemite<ShardStateMap, ShardState>({ shards: Array(2).fill('') })\n\nstore.on('add', state => {\n  state.shards.push('')\n})\n\nstore.on('remove', state => {\n  state.shards.length = Math.max(state.shards.length - 1, 2)\n})\n\nstore.on('set', (state, { index, shard }) => {\n  state.shards[index] = cleanupShard(shard)\n})\n\nfunction useShards() {\n  const [state, setState] = React.useState(store.state)\n  React.useEffect(() => store.listen(setState), [])\n  return state.shards\n}\n\nfunction useSecret(shards: string[]) {\n  const [secret, setSecret] = React.useState('')\n  const [error, setError] = React.useState<Error | undefined>()\n  React.useEffect(() => {\n    try {\n      const validShards = shards.filter(Boolean)\n      if (validShards.length < 2) {\n        // Not enough data, no need to display an error\n        setError(new Error('Not enough data to recompose the secret.'))\n        return\n      }\n      const secret = assembleSecret(validShards)\n      setSecret(secret)\n      setError(undefined)\n    } catch (error) {\n      setError(error as any)\n    }\n  }, [shards])\n  return { secret, error }\n}\n\nexport const HorcruxRecompose: React.FC = () => {\n  const shards = useShards()\n  const { secret, error } = useSecret(shards)\n  return (\n    <section className=\"mt-24\">\n      <h2 id=\"recompose\" className=\"my-4 text-3xl font-bold\">\n        Recompose\n        <a href=\"#recompose\" aria-hidden tabIndex={-1}>\n          <span className=\"icon icon-link font-medium\" />\n        </a>\n      </h2>\n      {!error && (\n        <>\n          <h3 className=\"text-md my-4 mb-2 font-semibold\">Your secret:</h3>\n          <code\n            className=\"relative my-4 block break-all rounded-sm border border-gray-200 bg-gray-50/30 px-4 py-4 text-sm dark:border-gray-800 dark:bg-gray-900 dark:shadow-inner\"\n            style={{ overflowWrap: 'anywhere' }}\n          >\n            {secret}\n          </code>\n        </>\n      )}\n      {error && (\n        <p className=\"mt-2 text-sm text-red-600 dark:text-red-300\">\n          {error.message}\n        </p>\n      )}\n      <div className=\"container mx-auto\">\n        <div className=\"mb-2 flex justify-end\">\n          <Button\n            size=\"xs\"\n            variant=\"ghost\"\n            color=\"green\"\n            leftIcon={<FiPlusCircle />}\n            onClick={() => store.dispatch('add', null)}\n            disabled={shards.length >= 8}\n          >\n            Add Shard\n          </Button>\n          <Button\n            size=\"xs\"\n            variant=\"ghost\"\n            color=\"red\"\n            leftIcon={<FiTrash2 />}\n            onClick={() => store.dispatch('remove', null)}\n            disabled={shards.length <= 2}\n          >\n            Remove\n          </Button>\n        </div>\n        <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n          {shards.map((shard, i) => (\n            <div key={i}>\n              <label className=\"text-sm\">Shard {i + 1}</label>\n              <Textarea\n                value={shard}\n                className=\"min-h-[6rem] font-mono text-sm\"\n                onChange={e =>\n                  store.dispatch('set', { index: i, shard: e.target.value })\n                }\n              />\n            </div>\n          ))}\n        </div>\n      </div>\n    </section>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/split.tsx",
    "content": "'use client'\n\nimport type { Encoding } from '@47ng/codec'\nimport React from 'react'\nimport { FiCheck, FiCopy } from 'react-icons/fi'\nimport { IconButton } from 'ui/components/buttons/icon-button'\nimport { NumberInput, Textarea } from 'ui/components/forms/inputs'\nimport { Radio, RadioGroup } from 'ui/components/forms/radio'\nimport {\n  FormControl,\n  FormHelperText,\n  FormLabel\n} from 'ui/components/forms/structure'\nimport { useClipboard } from 'ui/hooks/useClipboard'\nimport { useHydration } from 'ui/hooks/useHydration'\nimport { WideContainer } from 'ui/layouts/wide-container'\nimport { splitSecret } from './tss'\n\ntype HorcruxSplitProps = {\n  gitHubSourceUrl: string\n}\n\nexport const HorcruxSplit: React.FC<HorcruxSplitProps> = ({\n  gitHubSourceUrl\n}) => {\n  const hydrated = useHydration()\n  const [secret, setSecret] = React.useState('')\n  const [numShards, setNumShards] = React.useState(2)\n  const [threshold, setThreshold] = React.useState(2)\n  const [encoding, setEncoding] = React.useState<Encoding>('base64')\n\n  const shards = React.useMemo(\n    () => splitSecret(secret, numShards, threshold, encoding),\n    [secret, numShards, threshold, encoding]\n  )\n\n  return (\n    <section>\n      <h2 id=\"split\" className=\"my-4 text-3xl font-bold\">\n        Split\n        <a href=\"#split\" aria-hidden tabIndex={-1}>\n          <span className=\"icon icon-link font-medium\" />\n        </a>\n      </h2>\n      <FormControl name=\"secret\">\n        <FormLabel>Enter your secret:</FormLabel>\n        <Textarea\n          value={secret}\n          className=\"min-h-[6rem]\"\n          onChange={e => setSecret(e.target.value)}\n        />\n        <FormHelperText>\n          It will not be stored or sent anywhere:{' '}\n          <a href={gitHubSourceUrl} className=\"underline\">\n            check the source code\n          </a>\n          .\n        </FormHelperText>\n      </FormControl>\n      <div className=\"mt-8 grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2 md:gap-y-8\">\n        <FormControl name=\"number-of-shards\">\n          <FormLabel>Number of shards</FormLabel>\n          <NumberInput\n            min={2}\n            max={8}\n            value={numShards}\n            onChange={e => {\n              const num = e.target.valueAsNumber\n              if (!Number.isNaN(num)) {\n                setNumShards(num)\n                setThreshold(Math.min(threshold, num))\n              }\n            }}\n          />\n          <FormHelperText>to split the secret into</FormHelperText>\n        </FormControl>\n        <FormControl name=\"number-needed\">\n          <FormLabel>Number needed</FormLabel>\n          <NumberInput\n            min={2}\n            max={numShards}\n            value={threshold}\n            onChange={e => {\n              const num = e.target.valueAsNumber\n              if (!Number.isNaN(num)) {\n                setThreshold(Math.max(2, Math.min(numShards, num)))\n              }\n            }}\n          />\n          <FormHelperText>to recompose the secret</FormHelperText>\n        </FormControl>\n        <FormControl name=\"output-encoding\">\n          <FormLabel>Output encoding</FormLabel>\n          <RadioGroup\n            value={encoding}\n            onChange={e => setEncoding(e as Encoding)}\n          >\n            <div className=\"flex space-x-8\">\n              <Radio value=\"base64\" checked={encoding === 'base64'}>\n                Base 64\n              </Radio>\n              <Radio value=\"hex\" checked={encoding === 'hex'}>\n                Hexadecimal\n              </Radio>\n            </div>\n          </RadioGroup>\n        </FormControl>\n      </div>\n      <hr className=\"mt-8 border-gray-200 dark:border-gray-800\" />\n      <h2 className=\"mb-2 mt-8 text-2xl font-bold\">Horcrux Shards</h2>\n      <p>\n        <strong className=\"font-medium\">Individually</strong>, these can be\n        shared safely. Only <strong className=\"font-medium\">united</strong> can\n        they reveal their secret.\n      </p>\n      {hydrated && (\n        <WideContainer>\n          <div className=\"my-8 grid grid-cols-1 gap-4 lg:grid-cols-2\">\n            {shards.map((shard, i) => (\n              <ReadOnlyCodeBlock key={shard} text={shard} index={i + 1} />\n            ))}\n          </div>\n        </WideContainer>\n      )}\n    </section>\n  )\n}\n\n// --\n\ntype ReadOnlyCodeBlock = {\n  index: number\n  text: string\n}\n\nconst ReadOnlyCodeBlock: React.FC<ReadOnlyCodeBlock> = ({ text, index }) => {\n  const { onCopy, hasCopied } = useClipboard(text, 2000)\n  return (\n    <code\n      className=\"relative my-0 break-all rounded-sm border border-gray-200 bg-gray-50/30 px-4 pb-4 pt-10 text-sm dark:border-gray-800 dark:bg-gray-900 dark:shadow-inner\"\n      style={{ overflowWrap: 'anywhere' }}\n    >\n      <span className=\"absolute left-4 top-2 select-none font-sans font-medium text-gray-500\">\n        Shard {index}\n      </span>\n      <IconButton\n        aria-label=\"Copy\"\n        title={hasCopied ? 'Copied' : 'Copy'}\n        icon={hasCopied ? <FiCheck className=\"text-green-500\" /> : <FiCopy />}\n        className=\"absolute right-2 top-2\"\n        size=\"xs\"\n        variant=\"ghost\"\n        onClick={onCopy}\n      />\n      {text}\n    </code>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/horcrux/tss.ts",
    "content": "import { Encoding, decoders, detectEncoding, encoders, utf8 } from '@47ng/codec'\nimport { combine, split } from '@stablelib/tss'\n\nexport function generateRandomBytes(length: number) {\n  return crypto.getRandomValues(new Uint8Array(length))\n}\n\n/**\n * Split a secret into a given amount of shards.\n * Will throw if anything goes wrong.\n *\n * @param secret The secret to split\n * @param numShards How many pieces to split into\n * @param threshold How many pieces are needed (min) to re-assemble the secret\n * @param outputEncoding Output encoding for the shards\n */\nexport function splitSecret(\n  secret: string,\n  numShards: number,\n  threshold: number,\n  outputEncoding: Encoding = 'base64'\n) {\n  const encode = encoders[outputEncoding]\n  const identifier = generateRandomBytes(16)\n  const shards = split(utf8.encode(secret), threshold, numShards, identifier)\n  return shards.map(shard => encode(shard))\n}\n\n/**\n * Try to re-assemble the original secret from shards.\n * Will throw if anything goes wrong.\n *\n * @param shards Any number of shards\n */\nexport function assembleSecret(shards: string[]) {\n  const secret = combine(\n    shards.map(shard => {\n      const decode = decoders[detectEncoding(shard)]\n      return decode(shard)\n    })\n  )\n  return utf8.decode(secret)\n}\n\n/**\n * Remove all whitespace in a block of text\n * @param shard Text to cleanup\n */\nexport function cleanupShard(shard: string) {\n  return shard.replace(/\\s/g, '')\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/layout.tsx",
    "content": "import { Footer } from 'ui/layouts/footer'\nimport { NavHeader } from 'ui/layouts/nav-header'\n\nexport default function PageLayout({\n  children\n}: {\n  children: React.ReactNode\n}) {\n  return (\n    <>\n      <NavHeader />\n      <main className=\"mx-auto max-w-2xl px-2\">{children}</main>\n      <Footer />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/components/dovetail-svg.tsx",
    "content": "import React from 'react'\nimport { DovetailData, getDovetailPath } from './dovetails'\n\ntype DovetailSVGProps = React.ComponentProps<'section'> & {\n  data: DovetailData\n}\n\nexport const DovetailSVG: React.FC<DovetailSVGProps> = ({ data, ...props }) => {\n  const path = getDovetailPath(data)\n  return (\n    <figure {...props}>\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox={`0 0 ${data.w} ${3 * data.tp}`}\n      >\n        <defs>\n          <clipPath id=\"tails\">\n            <path\n              // Tail board\n              d={path}\n            />\n          </clipPath>\n        </defs>\n        <rect\n          // Pins board endgrain\n          x={0}\n          y={0}\n          width={data.w}\n          height={data.tp}\n          fill=\"saddlebrown\"\n        />\n        <g clipPath=\"url(#tails)\">\n          <rect\n            // Summer rings\n            x={0}\n            y={0}\n            width={data.w}\n            height={3 * data.tp}\n            fill=\"#f0d8b4\"\n          />\n          <path\n            // Winter rings\n            // Source: https://freesvg.org/light-wood-texture\n            d=\"M2.25323 170.388C2.91022 126.034 7.27813 81.6589 3.73745 37.3192C3.08124 21.6796 2.44155 6.10595 2.28102 -9.54414C6.9094 2.98232 3.8952 16.3273 5.43395 29.2311C5.67064 63.3515 8.94288 97.5552 5.12778 131.602C4.21142 144.789 3.62418 157.998 3.6297 171.214C2.58636 174.438 1.59242 171.916 2.25321 170.39L2.25323 170.388ZM16.0025 161.36C14.7581 116.234 12.4749 71.0146 16.1391 25.9355C16.1752 14.0864 15.5915 2.21514 16.623 -9.61235C16.7796 31.9881 17.9055 68.8858 15.5182 108.339C18.1177 132.022 15.6351 144.9 18.7859 164.199C19.2531 172.176 13.8863 171.196 16.0089 163.991L16.0305 162.674L16.0025 161.36ZM20.1119 173.027C18.2611 109.402 18.5649 53.4382 20.0394 -5.10694C25.8277 -6.5206 20.462 6.76076 22.0995 10.7467C17.1638 66.8901 24.3333 119.433 22.7901 171.53C22.4231 172.466 21.333 173.52 20.1119 173.028L20.1119 173.027ZM24.826 170.604C26.6289 156.463 25.7239 142.229 23.9883 128.138C21.5594 96.033 21.3829 63.8167 23.2641 31.6865C23.838 19.406 23.2543 6.71437 25.4401 -5.27574C27.5022 1.44969 25.055 9.74538 25.1101 17.0994C24.5034 26.0086 24.0333 34.9462 24.5058 43.8709C26.6 26.9442 25.2084 9.77769 28.2323 -7.07073C27.6428 17.0603 24.8463 41.1174 24.9313 65.2778C24.4072 91.7441 26.5357 118.183 29.8634 144.447C30.3643 152.304 30.0556 166.057 28.8894 170.238C28.1202 157.63 30.2587 144.918 27.5072 132.418C22.3097 1.39873 22.7826 72.8928 27.6407 154.941C27.4723 158.854 28.7287 171.117 24.826 170.603V170.604ZM22.5259 83.1056C21.3155 83.7576 23.797 83.8656 22.5259 83.1056V83.1056ZM35.7917 173.282C37.5239 145.363 36.8784 117.375 38.304 89.4419C29.3428 67.8877 32.778 44.4417 34.1097 22.008C37.5235 13.8049 30.8467 -4.75771 38.8865 -6.40263C40.6601 7.9583 36.01 22.8965 41.7909 36.8708C46.4779 46.0101 47.1829 56.0401 46.7981 66.0044C44.8902 86.2619 48.2434 111.769 51.2136 134.494C52.445 144.562 52.2077 154.677 53.2344 164.746C53.6744 169.688 48.5755 173.782 49.6902 166.213C48.9349 155.615 50.8244 144.737 48.4847 134.31C40.4121 133.51 44.2134 147.444 42.0925 152.939C40.6186 159.709 41.8083 168.869 37.3636 174L36.4677 173.747L35.7927 173.284L35.7917 173.282ZM40.8476 170.783C43.4232 164.335 41.0617 146.771 47.4781 147.015C48.7699 153.703 49.2521 166.339 47.1699 169.517C46.645 165.291 50.6052 151.687 44.3333 153.742C43.4959 158.847 44.5815 165.452 40.8477 170.783H40.8476ZM53.4527 171.77C56.1847 144.31 51.7095 116.901 48.5742 89.6501C47.239 74.1723 47.3044 58.6156 48.1985 43.117C47.6995 37.1278 34.9534 28.3644 46.9048 28.2279C50.6276 19.9445 46.2729 10.719 47.369 2.02337C50.4193 -11.8425 44.057 14.1535 51.28 10.3101C51.6593 6.06244 53.8011 -9.73232 52.7291 2.26928C48.0389 41.1127 45.9396 80.6777 53.7112 119.313C55.1431 133.317 55.1474 147.398 56.1154 161.43C53.053 164.104 56.7057 169.612 53.4528 171.773L53.4527 171.77ZM55.657 172.219C58.7555 163.195 55.7215 153.499 59.5823 144.795C60.7934 139.782 56.1683 125.153 58.9193 125.382C61.3629 140.68 60.5876 156.376 57.1407 171.424C56.9594 172.127 56.2535 172.067 55.6569 172.218L55.657 172.219ZM59.6089 173.206C62.6134 163.836 61.3533 153.871 64.0576 144.629C62.142 112.928 54.2916 81.2126 54.941 51.5343C56.1995 40.9533 51.5653 29.9267 56.605 19.7194C57.758 11.6642 54.831 1.52903 58.707 -4.85895C64.6494 -13.8536 60.2335 3.43135 59.9787 6.71078C56.6834 32.1335 59.249 49.0149 58.6598 83.3924C65.6974 107.05 67.2409 131.283 65.9699 153.914C64.9685 158.413 64.0961 174.699 59.6086 173.205L59.6089 173.206ZM74.5858 170.873C76.8856 141.225 77.821 111.337 74.0293 81.7557C72.8934 89.4235 66.617 88.6772 67.6155 81.2147C64.273 65.1748 60.8237 48.452 65.8792 32.3661C68.4387 21.6964 72.3838 11.2151 72.9987 0.226952C71.3539 3.19226 67.2551 16.8377 69.7444 6.70791C68.3972 2.55504 75.9992 -12.064 75.3353 -1.1218C74.5073 2.21712 71.6217 15.3644 74.5665 13.1465C75.5907 6.03659 74.2801 -11.901 87.1016 -6.66326C88.3363 6.13998 88.9001 19.0032 92.5183 31.4621C92.3926 40.2109 93.3443 52.1644 87.5506 58.7469C84.3247 52.3977 87.9772 42.3756 82.4599 37.7891C75.1455 49.4267 80.772 55.4542 79.6506 75.9504C73.9877 110.155 89.0565 139.83 82.018 171.727C80.4816 164.593 81.33 175.491 79.4882 168.612C78.2548 158.642 81.3073 148.513 77.6738 138.7C76.9194 149.399 80.6367 160.739 76.4039 171.123C74.1721 173.901 74.5323 173.622 74.5857 170.87L74.5858 170.873ZM75.3709 171.778C74.1604 172.43 76.642 172.538 75.3709 171.778V171.778ZM78.1229 105.952C78.2416 91.4425 76.3478 72.9129 75.1095 56.3714C72.6271 59.3859 72.2288 74.1823 68.558 63.8087C63.438 52.2016 69.2518 39.9897 73.1124 28.8698C75.0215 24.3547 70.2737 20.2933 70.7673 27.0169C67.5987 39.0132 64.1841 51.4621 66.5276 63.8571C64.1667 71.6068 74.7314 75.974 74.843 68.1504C77.4102 75.8566 75.9265 84.4061 77.1965 92.4455C77.7162 101.633 77.9754 110.841 77.5986 120.038C78.2205 115.363 77.8992 110.644 78.1228 105.951L78.1229 105.952ZM70.5724 73.4804C69.4725 70.7442 72.0642 74.9633 70.5724 73.4804V73.4804ZM83.6754 170.783C80.7569 166.523 88.8174 157.2 87.7104 166.741C88.4123 169.458 85.635 178.305 83.6754 170.783ZM88.8268 173.745C90.1295 160.788 85.7328 148.22 83.3067 135.659C80.9206 108.996 79.1155 82.0138 83.3568 55.4193C85.9761 57.2524 85.3344 73.0511 89.0843 65.8396C88.582 58.7379 97.831 57.8749 94.8823 65.9659C97.0735 97.3088 96.0621 128.744 97.6246 160.109C95.0997 128.696 96.7469 97.1124 93.4965 65.7361C87.9192 63.3338 89.787 81.6005 84.8613 71.8473C79.9245 85.7957 82.2284 100.709 82.2707 115.092C83.6179 134.259 90.3958 153.063 89.0908 172.356L88.8268 173.745ZM98.8343 164.231C97.1827 140.693 96.5731 91.6385 96.867 55.2219C99.6149 33.8264 101.091 12.1854 99.1075 -9.32565C103.909 -6.38454 99.9137 10.7857 101.2 -1.78027C100.622 -7.95788 102.718 -8.40949 102.195 -2.07375C101.737 20.0426 103.007 42.2013 100.658 64.2616C99.8553 73.8231 101.963 83.2432 100.191 92.7803C99.6978 119.18 102.202 145.56 101.934 171.968C97.2494 176.284 99.5864 165.99 98.8343 164.231L98.8343 164.231ZM101.198 161.359C100.521 156.16 101.203 149.284 99.8985 145.125C100.293 150.532 99.1993 156.126 101.198 161.359ZM106.404 173.206C104.665 145.165 105.643 116.395 106.215 81.8379C108.295 56.9063 110.71 31.801 107.177 6.87585C106.075 2.77504 107.679 -14.0561 108.702 -2.59483C109.641 11.5442 110.293 25.7099 110.626 39.8744C110.695 48.7355 110.549 57.5959 110.427 66.456C115.285 56.7868 111.806 45.6636 112.83 35.4049C111.671 22.8716 111.427 10.2928 109.914 -2.18199C113.562 -12.2852 114.316 4.01321 114.266 7.54251C117.871 67.5739 109.065 119.004 112.593 173.205C103.953 138.782 112.717 100.652 111.875 68.1554C106.674 80.7078 110.381 91.8679 107.378 112.208C106.932 126.591 107.247 141.004 108.606 155.341C104.098 161.769 114.034 167.199 106.404 173.21L106.404 173.206ZM122.526 173.386C121.789 158.205 117.689 143.25 118.682 127.983C117.675 84.0816 122.269 40.2876 122.475 -3.59966C126.306 -8.41596 123.137 7.35978 123.426 10.3624C122.501 48.0642 118.651 94.3604 120.353 136.603C119.075 147.034 125.878 153.896 124.539 164.17C124.234 167.189 127.435 172.209 122.526 173.388L122.526 173.386ZM126.603 172.488C128.675 156.603 124.66 140.891 121.842 125.345C119.574 104.428 121.385 83.4103 121.924 62.4593C124.341 74.206 121.885 86.2388 122.747 98.1137C122.698 112.067 125.485 125.82 127.889 139.513C128.82 150.609 128.748 161.795 127.313 172.852L126.844 172.895L126.603 172.488ZM129.689 173.648C130.825 151.041 130.396 128.199 125.309 105.998C123.326 73.3837 123.88 40.5893 128.326 8.15402C128.046 4.09916 127.344 -9.45152 133.964 -5.45661C133.475 10.3762 129.666 25.9075 127.89 41.6114C126.281 95.7732 126.97 93.9064 131.517 110.348C132.497 123.305 133.815 136.397 133.83 149.317C133.249 157.553 136.872 168.494 130.032 173.339L129.91 173.641L129.689 173.652L129.689 173.648ZM135.948 148.884C136.567 104.9 129.595 60.7721 135.974 16.9315C135.978 11.0299 135.475 -4.64599 137.537 -4.2852C135.734 35.6854 133.381 75.7494 136.572 115.713C137.292 135.063 137.838 154.423 137.579 173.788C133.246 165.94 137.45 157.129 135.948 148.882V148.884ZM150.536 172.488C150.503 159.376 151.404 146.269 152.998 133.243C164.419 137.504 154.538 150.862 156.722 159.178C156.543 163.059 158.649 175.362 150.536 172.49V172.488ZM159.426 172.488C160.306 167.92 161.422 152.713 161.41 164.838C161.349 167.469 160.94 170.197 159.426 172.488ZM171.777 171.411C171.046 125.284 179.933 79.1132 173.376 33.0684C170.855 19.2721 171.267 5.31293 171.861 -8.60752C176.133 8.08007 172.301 26.1405 175.425 30.979C178.609 67.9556 176.452 105.061 175.869 142.085C173.728 151.823 174.961 164.86 172.042 172.753L171.777 171.411ZM174.706 169.616C176.317 157.619 180.469 145.772 177.854 133.616C177.146 104.476 177.862 75.3294 177.99 46.1897C178.536 85.9521 178.525 125.829 178.923 165.527C178.986 168.097 173.738 177.375 174.706 169.619L174.706 169.616ZM177.925 124.39C176.715 125.042 179.197 125.15 177.925 124.39V124.39ZM184.426 165.858C186.518 163.018 182.365 156.697 182.874 163.475C184.459 139.106 187.292 114.494 182.703 90.2505C181.485 69.0094 183.71 47.7489 183.256 26.4748C184.151 15.0674 181.644 2.73942 185.198 -8.10729C194.713 -14.4651 203.783 -3.4127 198.382 4.47697C200.478 55.5586 199.843 106.687 199.355 157.794C200.926 165.022 196.708 174.937 188.041 173.785C184.708 172.516 183.362 168.816 184.427 165.856L184.426 165.858ZM193.876 72.9564C195.041 50.0464 194.232 27.0803 192.177 4.23019C187.288 13.0514 191.232 23.6781 189.416 33.1994C189.548 47.3884 190.995 61.5468 193.24 75.5789C193.623 74.7315 193.816 73.8693 193.876 72.9549V72.9564ZM192.199 69.5759C191.689 67.4643 193.092 70.2413 192.199 69.5759V69.5759ZM199.059 35.0002C197.849 35.6523 200.33 35.7602 199.059 35.0002V35.0002ZM199.059 33.5642C197.849 34.2163 200.33 34.3242 199.059 33.5642V33.5642ZM124.87 171.889C124.36 169.778 125.762 172.554 124.87 171.889V171.889ZM83.2559 166.02C81.2205 162.074 85.2599 165.907 83.2559 166.02V166.02ZM144.293 160.484C139.194 161.511 146.483 152.953 140.74 156.082C139.867 120.814 137.69 85.543 138.201 50.2608C138.625 41.0569 139.893 31.7621 139.655 22.6332C145.768 15.8022 143.608 31.0003 145.033 34.8854C145.569 43.9543 147.15 52.8765 149.34 61.6963C154.87 82.2043 156.855 40.7941 158.285 33.7965C160.19 26.6636 157.761 16.3515 163.502 11.2354C166.655 18.9876 166.025 27.797 167.888 35.9422C171.672 63.2906 172.963 91.4862 166.983 118.558C161.396 108.332 161.315 95.7973 157.852 84.6821C160.288 91.9116 153.738 78.4244 153.642 87.5359C151.446 102.073 149.387 116.643 147.187 131.19C145.808 140.906 145.428 150.842 144.688 160.515L144.293 160.483L144.293 160.484ZM142.304 129.417C141.094 130.069 143.575 130.177 142.304 129.417V129.417ZM147.379 80.2779C145.47 68.7771 144.341 57.169 141.965 45.7342C137.995 57.4307 140.189 69.9737 140.207 82.0252C140.766 92.4188 140.354 102.891 142.132 113.202C150.307 117.988 147.616 83.2899 147.379 80.2772L147.379 80.2779ZM167.145 98.2827C171.159 74.4159 168.7 50.0731 163.961 26.4547C156.046 36.4893 160.495 49.3913 158.413 60.7439C159.143 73.5428 160.777 86.6796 167.145 98.2805L167.145 98.2827ZM59.0763 160.217C58.4642 157.466 60.4885 161.287 59.0763 160.217V160.217ZM134.475 155.259C134.978 150.444 135.188 157.725 134.475 155.259V155.259ZM169.202 146.73C168.221 141.57 170.888 139.946 170.052 146.47C170.192 150.979 168.539 154.131 169.202 146.73ZM161.197 152.588C159.347 148.207 162.098 138.391 161.669 148.686C161.688 149.995 161.656 151.332 161.197 152.588ZM45.7658 149.985C45.2736 147.802 47.0145 151.01 45.7658 149.985V149.985ZM108.862 148.168C108.817 145.415 109.411 149.668 108.862 148.168V148.168ZM108.892 145.476C109.188 142.559 109.188 148.393 108.892 145.476V145.476ZM52.0591 144.967C51.5493 142.856 52.9517 145.633 52.0591 144.967V144.967ZM108.876 142.605C109.092 140.132 109.092 145.079 108.876 142.605V142.605ZM2.02925 130.22C5.70827 87.4059 1.56496 93.4083 2.66467 128.881C2.54262 132.044 1.85075 136.614 2.02925 130.22ZM90.2283 133.046C90.2935 126.272 85.4739 108.778 90.4231 108.263C92.5325 116.312 94.3422 124.408 90.2283 133.046ZM16.6612 126.9C15.5932 118.211 18.1805 123.008 16.9711 128.849L16.6612 126.9ZM18.2329 118.314C15.5588 116.839 19.1239 115.921 18.2329 118.314V118.314ZM55.1585 112.85C52.0285 94.4682 52.1203 75.7623 51.6071 57.2022C51.2732 37.6918 49.7215 33.2115 55.3577 -4.74981C57.7968 -0.204562 52.5186 13.7109 53.1326 21.6648C52.1768 41.2204 52.6026 60.8313 52.9567 80.3894C58.3993 90.0503 45.1753 106.829 55.1585 112.847L55.1585 112.85ZM15.6231 88.8764C4.92394 145.706 10.2733 117.291 15.6231 88.8764V88.8764ZM73.7088 83.5812C70.9368 81.593 74.7813 80.9756 73.7088 83.5812V83.5812ZM109.989 72.811C109.48 70.6994 110.882 73.4763 109.989 72.811V72.811ZM110.381 67.426C109.871 65.3144 111.273 68.0914 110.381 67.426V67.426ZM91.9831 65.6311C91.4734 63.5194 92.8757 66.2964 91.9831 65.6311V65.6311ZM170.631 60.5548C172.122 59.2622 170.336 63.038 170.631 60.5548V60.5548ZM122.335 48.909C122.736 30.3834 123.588 11.8175 126.484 -6.53797C129.652 3.44535 123.742 13.34 124.309 23.4264C123.574 33.7411 123.884 44.1272 122.304 54.3841C122.157 52.56 122.349 50.7345 122.335 48.9094L122.335 48.909ZM199.625 45.8874C199.115 43.7758 200.518 46.5528 199.625 45.8874V45.8874ZM177.538 45.408C176.892 31.4926 173.78 23.9146 174.539 -5.90331C179.797 -4.55104 175.628 8.35639 177.151 14.0418C177.513 24.489 178.849 34.9735 177.538 45.4095V45.408ZM176.736 11.6706C175.525 12.3227 178.007 12.4306 176.736 11.6706V11.6706ZM199.705 42.8089C200.001 39.8916 200.001 45.7263 199.705 42.8089V42.8089ZM83.5261 39.5418C82.2523 34.8483 85.0539 41.3064 83.5261 39.5418V39.5418ZM95.0853 38.892C92.9676 24.376 91.0417 9.83592 89.2019 -4.71169C93.6416 2.24925 91.4078 11.8416 92.9909 19.8182C92.5879 24.6531 98.2307 30.205 98.3904 22.0075C99.6949 14.9952 96.957 0.633551 98.4307 -1.95015C99.0501 11.5345 100.135 25.3102 96.3737 38.5195C97.3539 35.0291 95.9116 38.8757 95.0852 38.8925L95.0853 38.892ZM132.924 38.6198C133.193 31.0023 134.404 14.3047 135.341 12.5513C135.122 21.2767 134.147 29.9698 132.924 38.6198ZM62.305 36.6023C62.5533 32.1791 66.2526 22.1638 64.3614 32.4591C64.1123 33.9669 63.578 35.539 62.305 36.6023ZM199.207 31.5009C199.708 28.4493 199.806 34.9939 199.207 31.5009V31.5009ZM95.5303 27.7638C94.3886 26.0699 97.7046 28.4525 95.5303 27.7638V27.7638ZM199.305 23.962C199.416 20.6033 199.693 26.1574 199.305 23.962V23.962ZM42.6312 21.6461C40.1378 6.85406 43.9465 -16.6444 45.4887 9.96332C44.9681 13.8627 44.839 18.1685 42.6312 21.6461ZM151.678 16.8819C152.019 10.1202 145.963 -6.85135 155.096 -6.10715C157.456 1.73226 152.293 9.1843 151.678 16.8819ZM164.789 16.8121C164.279 14.7005 165.682 17.4775 164.789 16.8121V16.8121ZM89.0731 14.987C89.8647 13.4233 89.3823 17.5908 89.0731 14.987V14.987ZM45.4042 9.9908C44.8944 7.87919 46.2968 10.6562 45.4042 9.9908V9.9908ZM127.998 0.656897C127.488 -1.45472 128.89 1.32226 127.998 0.656897V0.656897Z\"\n            fill=\"#eccba1\"\n            transform={`scale(${data.w / 200} 1)`}\n          />\n        </g>\n      </svg>\n    </figure>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/components/dovetails.ts",
    "content": "export type DovetailParameters = {\n  jointWidth: number\n  numTails: number\n  pinsBoardThickness: number\n  angleRatio: number\n  pinToTailRatio: number\n  halfPinRatio: number\n}\n\nexport function getDovetailData({\n  numTails,\n  pinToTailRatio,\n  pinsBoardThickness,\n  jointWidth,\n  halfPinRatio,\n  angleRatio\n}: DovetailParameters) {\n  const n = numTails\n  const b =\n    jointWidth /\n    (2 * (pinToTailRatio * halfPinRatio) + n + (n - 1) * pinToTailRatio)\n  const c = pinToTailRatio * b\n  const a = halfPinRatio * c\n  const rake = pinsBoardThickness / angleRatio\n  const a_ = a + rake\n  const b_ = b - 2 * rake\n  const c_ = c + 2 * rake\n\n  return {\n    a,\n    b,\n    c,\n    a_,\n    b_,\n    c_,\n    n,\n    w: jointWidth,\n    tp: pinsBoardThickness,\n    angleDegrees: 90 - (Math.atan(1 / angleRatio) * 180) / Math.PI\n  }\n}\n\nexport type DovetailData = ReturnType<typeof getDovetailData>\n\n// --\n\nexport function getDovetailPath({\n  n,\n  a,\n  b,\n  c,\n  a_,\n  b_,\n  c_,\n  w,\n  tp\n}: DovetailData) {\n  const points = [[0, tp]]\n  Array(n)\n    .fill(undefined)\n    .forEach((_, index) => {\n      points.push(\n        [a_ + index * (b_ + c_), tp],\n        [a + index * (b + c), 0],\n        [a + index * (b + c) + b, 0],\n        [a_ + index * (b_ + c_) + b_, tp]\n      )\n    })\n  points.push([w, tp], [w, 3 * tp], [0, 3 * tp])\n  return [\n    `M ${points[0][0]} ${points[0][1]}`,\n    ...points.slice(1).map(([x, y]) => `L ${x} ${y}`),\n    'Z' // close the path\n  ].join(' ')\n}\n\nexport function getDovetailMeasurements({ a, b, c, c_, b_ }: DovetailData) {\n  return {\n    dividersLength: a + b,\n    pinNarrowWidth: c,\n    pinWideWidth: c_,\n    halfPinNarrowWidth: a,\n    distanceBetweenPins: b_\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/page.client.tsx",
    "content": "'use client'\n\nimport { parseAsFloat, parseAsInteger, useQueryState } from 'nuqs'\nimport { ChangeEvent } from 'react'\nimport { NumberInput } from 'ui/components/forms/inputs'\nimport { Slider } from 'ui/components/forms/slider'\nimport {\n  FormControl,\n  FormHelperText,\n  FormLabel\n} from 'ui/components/forms/structure'\nimport { Note } from 'ui/components/note'\nimport { Stat, StatHelpText, StatLabel, StatNumber } from 'ui/components/stat'\nimport { useHydration } from 'ui/hooks/useHydration'\nimport { WideContainer } from 'ui/layouts/wide-container'\nimport { DovetailSVG } from './components/dovetail-svg'\nimport {\n  getDovetailData,\n  getDovetailMeasurements\n} from './components/dovetails'\n\nexport interface DovetailDesignerProps {}\n\nconst safeParseInt =\n  (callback: (value: number) => void) => (e: ChangeEvent<HTMLInputElement>) => {\n    if (Number.isSafeInteger(e.target.valueAsNumber)) {\n      callback(e.target.valueAsNumber)\n    }\n  }\n\nconst useIntParameter = (key: string, defaultValue: number) => {\n  return useQueryState(\n    key,\n    parseAsInteger.withOptions({ scroll: false }).withDefault(defaultValue)\n  )\n}\n\nconst useFloatParameter = (key: string, defaultValue: number) =>\n  useQueryState(\n    key,\n    parseAsFloat.withOptions({ scroll: false }).withDefault(defaultValue)\n  )\n\nexport const DovetailDesigner: React.FC = () => {\n  const hydrated = useHydration()\n  const [numTails, setNumTails] = useIntParameter('numTails', 3)\n  const [angleRatio, setAngleRatio] = useIntParameter('angleRatio', 5)\n  const [jointWidth, setJointWidth] = useIntParameter('jointWidth', 200)\n  const [pinsBoardThickness, setPinsBoardThickness] = useIntParameter(\n    'pinsBoardThickness',\n    18\n  )\n  const [pinToTailRatio, setPinToTailRatio] = useFloatParameter(\n    'pinToTailRatio',\n    0.14\n  )\n  const [halfPinRatio, setHalfPinRatio] = useFloatParameter('halfPinRatio', 1)\n\n  const dovetailData = getDovetailData({\n    jointWidth,\n    pinsBoardThickness,\n    angleRatio,\n    numTails,\n    pinToTailRatio,\n    halfPinRatio\n  })\n  const {\n    dividersLength,\n    halfPinNarrowWidth,\n    pinNarrowWidth,\n    pinWideWidth,\n    distanceBetweenPins\n  } = getDovetailMeasurements(dovetailData)\n\n  if (!hydrated) {\n    return (\n      <section className=\"py-16 text-center text-sm text-gray-500\">\n        Loading editor...\n      </section>\n    )\n  }\n\n  return (\n    <>\n      <WideContainer>\n        <DovetailSVG className=\"my-12\" data={dovetailData} />\n      </WideContainer>\n      <section className=\"grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2\">\n        <FormControl>\n          <FormLabel>Joint Width</FormLabel>\n          <NumberInput\n            value={jointWidth}\n            onChange={safeParseInt(setJointWidth)}\n          >\n            {/* <InputGroup>\n              <NumberInputField />\n              <InputRightElement fontSize=\"sm\" color=\"gray.600\">\n                mm\n              </InputRightElement>\n            </InputGroup> */}\n          </NumberInput>\n        </FormControl>\n        <FormControl>\n          <FormLabel>Pins Board Thickness</FormLabel>\n          <NumberInput\n            value={pinsBoardThickness}\n            onChange={safeParseInt(setPinsBoardThickness)}\n          >\n            {/* <InputGroup>\n              <NumberInputField />\n              <InputRightElement fontSize=\"sm\" color=\"gray.600\">\n                mm\n              </InputRightElement>\n            </InputGroup> */}\n          </NumberInput>\n        </FormControl>\n\n        <FormControl>\n          <FormLabel>Number of Tails</FormLabel>\n          <NumberInput\n            value={numTails}\n            onChange={safeParseInt(setNumTails)}\n            min={1}\n            max={10}\n          >\n            {/* <NumberInputField />\n            <NumberInputStepper>\n              <NumberIncrementStepper />\n              <NumberDecrementStepper />\n            </NumberInputStepper> */}\n          </NumberInput>\n        </FormControl>\n        <FormControl>\n          <div className=\"flex items-baseline justify-between\">\n            <FormLabel>Angle</FormLabel>\n            <span className=\"text-sm\">\n              {dovetailData.angleDegrees.toFixed(1)}°\n            </span>\n          </div>\n          <NumberInput\n            value={angleRatio}\n            onChange={safeParseInt(setAngleRatio)}\n            min={3}\n            max={10}\n            step={1}\n          >\n            {/* <InputGroup>\n              <InputLeftElement>1:</InputLeftElement>\n              <NumberInputField pl=\"1.54rem\" />\n            </InputGroup>\n            <NumberInputStepper>\n              <NumberIncrementStepper />\n              <NumberDecrementStepper />\n            </NumberInputStepper> */}\n          </NumberInput>\n        </FormControl>\n        <FormControl>\n          <div className=\"flex items-baseline justify-between\">\n            <FormLabel>Pin to Tail Ratio</FormLabel>\n            <span className=\"text-sm\">{pinToTailRatio}</span>\n          </div>\n          <Slider\n            aria-label=\"slider-pin-to-tail-ratio\"\n            value={pinToTailRatio}\n            onChange={setPinToTailRatio}\n            min={0.01}\n            max={1}\n            step={0.01}\n          >\n            {/* <SliderTrack>\n              <SliderFilledTrack />\n            </SliderTrack>\n            <SliderThumb /> */}\n          </Slider>\n          <FormHelperText className=\"flex justify-between\">\n            <span>Larger tails</span>\n            <span>Larger pins</span>\n          </FormHelperText>\n        </FormControl>\n        <FormControl>\n          <div className=\"flex items-baseline justify-between\">\n            <FormLabel>Half Pin Ratio</FormLabel>\n            <span className=\"text-sm\">{halfPinRatio}</span>\n          </div>\n          <Slider\n            aria-label=\"slider-half-pin-ratio\"\n            value={halfPinRatio}\n            onChange={setHalfPinRatio}\n            min={0.1}\n            max={2}\n            step={0.1}\n          >\n            {/* <SliderTrack>\n              <SliderFilledTrack />\n            </SliderTrack>\n            <SliderThumb /> */}\n          </Slider>\n        </FormControl>\n      </section>\n      <Note status=\"info\" outerClass=\"mt-8\">\n        To layout this joint, set your dividers to{' '}\n        <strong>{dividersLength.toFixed(1)}</strong> mm.\n      </Note>\n      <h3 className=\"mb-4 mt-8 text-xl font-semibold\">Measurements</h3>\n      <section className=\"space-y-8\">\n        <div className=\"flex items-center\">\n          <Stat>\n            <StatLabel>Pin thickness</StatLabel>\n            <StatNumber>{pinNarrowWidth.toFixed(1)}mm</StatNumber>\n            <StatHelpText>At the narrowest point</StatHelpText>\n          </Stat>\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            className=\"w-[120px] sm:w-[200px]\"\n            fill=\"none\"\n            viewBox=\"0 0 100 46\"\n          >\n            <path\n              d=\"M6 10H1V36H11L6 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M56 10H44L39 36H61L56 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M99 10H94L89 36H99V10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M43.8232 4.82322C43.7256 4.92085 43.7256 5.07915 43.8232 5.17678L45.4142 6.76777C45.5118 6.8654 45.6701 6.8654 45.7678 6.76777C45.8654 6.67014 45.8654 6.51184 45.7678 6.41421L44.3536 5L45.7678 3.58579C45.8654 3.48816 45.8654 3.32986 45.7678 3.23223C45.6701 3.1346 45.5118 3.1346 45.4142 3.23223L43.8232 4.82322ZM56.1768 5.17678C56.2744 5.07915 56.2744 4.92085 56.1768 4.82322L54.5858 3.23223C54.4882 3.1346 54.3299 3.1346 54.2322 3.23223C54.1346 3.32986 54.1346 3.48816 54.2322 3.58579L55.6464 5L54.2322 6.41421C54.1346 6.51184 54.1346 6.67014 54.2322 6.76777C54.3299 6.8654 54.4882 6.8654 54.5858 6.76777L56.1768 5.17678ZM44 5.25H56V4.75H44V5.25Z\"\n              fill=\"currentColor\"\n            />\n          </svg>\n        </div>\n        <div className=\"flex items-center\">\n          <Stat>\n            <StatLabel>Pin thickness</StatLabel>\n            <StatNumber>{pinWideWidth.toFixed(1)}mm</StatNumber>\n            <StatHelpText>At the widest point</StatHelpText>\n          </Stat>\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            className=\"w-[120px] sm:w-[200px]\"\n            fill=\"none\"\n            viewBox=\"0 0 100 46\"\n          >\n            <path\n              d=\"M6 10H1V36H11L6 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M56 10H44L39 36H61L56 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M99 10H94L89 36H99V10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M38.8232 40.8232C38.7256 40.9209 38.7256 41.0791 38.8232 41.1768L40.4142 42.7678C40.5118 42.8654 40.6701 42.8654 40.7678 42.7678C40.8654 42.6701 40.8654 42.5118 40.7678 42.4142L39.3536 41L40.7678 39.5858C40.8654 39.4882 40.8654 39.3299 40.7678 39.2322C40.6701 39.1346 40.5118 39.1346 40.4142 39.2322L38.8232 40.8232ZM61.1768 41.1768C61.2744 41.0791 61.2744 40.9209 61.1768 40.8232L59.5858 39.2322C59.4882 39.1346 59.3299 39.1346 59.2322 39.2322C59.1346 39.3299 59.1346 39.4882 59.2322 39.5858L60.6464 41L59.2322 42.4142C59.1346 42.5118 59.1346 42.6701 59.2322 42.7678C59.3299 42.8654 59.4882 42.8654 59.5858 42.7678L61.1768 41.1768ZM39 41.25L61 41.25V40.75L39 40.75V41.25Z\"\n              fill=\"currentColor\"\n            />\n          </svg>\n        </div>\n        <div className=\"flex items-center\">\n          <Stat>\n            <StatLabel>Half pin thickness</StatLabel>\n            <StatNumber>{halfPinNarrowWidth.toFixed(1)}mm</StatNumber>\n            <StatHelpText>At the narrowest point</StatHelpText>\n          </Stat>\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            className=\"w-[120px] sm:w-[200px]\"\n            fill=\"none\"\n            viewBox=\"0 0 100 46\"\n          >\n            <path\n              d=\"M6 10H1V36H11L6 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M56 10H44L39 36H61L56 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M99 10H94L89 36H99V10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M0.823223 3.82322C0.725592 3.92085 0.725592 4.07915 0.823223 4.17678L2.41421 5.76777C2.51184 5.8654 2.67014 5.8654 2.76777 5.76777C2.8654 5.67014 2.8654 5.51184 2.76777 5.41421L1.35355 4L2.76777 2.58579C2.8654 2.48816 2.8654 2.32986 2.76777 2.23223C2.67014 2.1346 2.51184 2.1346 2.41421 2.23223L0.823223 3.82322ZM6.17678 4.17678C6.27441 4.07915 6.27441 3.92085 6.17678 3.82322L4.58579 2.23223C4.48816 2.1346 4.32986 2.1346 4.23223 2.23223C4.1346 2.32986 4.1346 2.48816 4.23223 2.58579L5.64645 4L4.23223 5.41421C4.1346 5.51184 4.1346 5.67014 4.23223 5.76777C4.32986 5.8654 4.48816 5.8654 4.58579 5.76777L6.17678 4.17678ZM1 4.25H6V3.75H1V4.25Z\"\n              fill=\"currentColor\"\n            />\n            <path\n              d=\"M93.8232 3.82322C93.7256 3.92085 93.7256 4.07915 93.8232 4.17678L95.4142 5.76777C95.5118 5.8654 95.6701 5.8654 95.7678 5.76777C95.8654 5.67014 95.8654 5.51184 95.7678 5.41421L94.3536 4L95.7678 2.58579C95.8654 2.48816 95.8654 2.32986 95.7678 2.23223C95.6701 2.1346 95.5118 2.1346 95.4142 2.23223L93.8232 3.82322ZM99.1768 4.17678C99.2744 4.07915 99.2744 3.92085 99.1768 3.82322L97.5858 2.23223C97.4882 2.1346 97.3299 2.1346 97.2322 2.23223C97.1346 2.32986 97.1346 2.48816 97.2322 2.58579L98.6464 4L97.2322 5.41421C97.1346 5.51184 97.1346 5.67014 97.2322 5.76777C97.3299 5.8654 97.4882 5.8654 97.5858 5.76777L99.1768 4.17678ZM94 4.25H99V3.75H94V4.25Z\"\n              fill=\"currentColor\"\n            />\n          </svg>\n        </div>\n        <div className=\"flex items-center\">\n          <Stat>\n            <StatLabel>Distance between pins</StatLabel>\n            <StatNumber>{distanceBetweenPins.toFixed(1)}mm</StatNumber>\n          </Stat>\n          <svg\n            xmlns=\"http://www.w3.org/2000/svg\"\n            className=\"w-[120px] sm:w-[200px]\"\n            fill=\"none\"\n            viewBox=\"0 0 100 46\"\n          >\n            <path\n              d=\"M6 10H1V36H11L6 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M56 10H44L39 36H61L56 10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M99 10H94L89 36H99V10Z\"\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n            />\n            <path\n              d=\"M10.8232 40.8232C10.7256 40.9209 10.7256 41.0791 10.8232 41.1768L12.4142 42.7678C12.5118 42.8654 12.6701 42.8654 12.7678 42.7678C12.8654 42.6701 12.8654 42.5118 12.7678 42.4142L11.3536 41L12.7678 39.5858C12.8654 39.4882 12.8654 39.3299 12.7678 39.2322C12.6701 39.1346 12.5118 39.1346 12.4142 39.2322L10.8232 40.8232ZM39.1768 41.1768C39.2744 41.0791 39.2744 40.9209 39.1768 40.8232L37.5858 39.2322C37.4882 39.1346 37.3299 39.1346 37.2322 39.2322C37.1346 39.3299 37.1346 39.4882 37.2322 39.5858L38.6464 41L37.2322 42.4142C37.1346 42.5118 37.1346 42.6701 37.2322 42.7678C37.3299 42.8654 37.4882 42.8654 37.5858 42.7678L39.1768 41.1768ZM11 41.25L39 41.25V40.75L11 40.75V41.25Z\"\n              fill=\"currentColor\"\n            />\n            <path\n              d=\"M60.8232 40.8232C60.7256 40.9209 60.7256 41.0791 60.8232 41.1768L62.4142 42.7678C62.5118 42.8654 62.6701 42.8654 62.7678 42.7678C62.8654 42.6701 62.8654 42.5118 62.7678 42.4142L61.3536 41L62.7678 39.5858C62.8654 39.4882 62.8654 39.3299 62.7678 39.2322C62.6701 39.1346 62.5118 39.1346 62.4142 39.2322L60.8232 40.8232ZM89.1768 41.1768C89.2744 41.0791 89.2744 40.9209 89.1768 40.8232L87.5858 39.2322C87.4882 39.1346 87.3299 39.1346 87.2322 39.2322C87.1346 39.3299 87.1346 39.4882 87.2322 39.5858L88.6464 41L87.2322 42.4142C87.1346 42.5118 87.1346 42.6701 87.2322 42.7678C87.3299 42.8654 87.4882 42.8654 87.5858 42.7678L89.1768 41.1768ZM61 41.25L89 41.25V40.75L61 40.75V41.25Z\"\n              fill=\"currentColor\"\n            />\n          </svg>\n        </div>\n      </section>\n    </>\n  )\n}\n\nexport default DovetailDesigner\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(not-prose)/woodworking/dovetail-designer/page.tsx",
    "content": "import type { Metadata } from 'next'\nimport { Suspense } from 'react'\nimport DovetailDesigner from './page.client'\n\nexport const metadata: Metadata = {\n  title: 'Dovetail Designer',\n  description: 'Design perfect-looking dovetail joints',\n  alternates: {\n    canonical: '/woodworking/dovetail-designer'\n  }\n}\n\nexport default function DovtailDesignerPage() {\n  return (\n    <>\n      <h1 className=\"text-4xl font-bold md:text-5xl\">Dovetail Designer</h1>\n      <p className=\"text-sm text-gray-500\">\n        Design perfect-looking dovetail joints\n      </p>\n      <Suspense>\n        <DovetailDesigner />\n      </Suspense>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/about-me.tsx",
    "content": "import Image from 'next/image'\nimport { BrowserWindowFrame } from 'ui/components/browser-window-frame'\nimport { WideContainer } from 'ui/layouts/wide-container'\nimport grenobleImg from './grenoble-640@2x.png'\nimport balkansTourLightImg from './balkans-tour-2019-light.jpg'\nimport balkansTourDarkImg from './balkans-tour-2019-dark.jpg'\n\nexport function AboutMe() {\n  return (\n    <>\n      <WideContainer>\n        <Image\n          src={grenobleImg}\n          alt=\"A view of Grenoble surrounded by mountains, taken from the Bastille.\"\n        />\n      </WideContainer>\n\n      <p>\n        I live in Grenoble, in the French Alps, with my wife and our sons.\n      </p>\n      <p>\n        My hobbies vary a lot based on my current interests, but playing music\n        (piano/synths & guitar) is always a constant. I&apos;m currently learning\n        woodworking to build toys for my kids, and take sunrise hikes to enjoy\n        the surrounding mountains.\n      </p>\n      <p>\n        I also love cycling, both for transportation and tourism. With my\n        friends, I&apos;ve toured across 7 countries on my bike, along the French\n        Atlantic coast, in the Alps and in the Balkans:\n      </p>\n\n      <BrowserWindowFrame className=\"block dark:hidden\" url=\"https://stravels.io\">\n        <Image src={balkansTourLightImg} alt=\"\" />\n      </BrowserWindowFrame>\n\n      <BrowserWindowFrame className=\"hidden dark:block\" url=\"https://stravels.io\">\n        <Image src={balkansTourDarkImg} alt=\"\" />\n      </BrowserWindowFrame>\n\n      <p>\n        This is a screenshot of a progressive web app I made for us to track our\n        journeys, it&apos;s called <a href=\"https://stravels.io\">Stravels</a> and\n        allowed us to stitch together our Strava activities onto a map.\n      </p>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/career.tsx",
    "content": "import { Logo } from 'ui/components/logo'\nimport { Note } from 'ui/components/note'\nimport { Client, Experience } from './experience'\nimport ArturiaLogo from './icons/arturia'\nimport GaelLogo from './icons/gael'\nimport HeronLogo from './icons/heron'\nimport AcquereurLogo from './icons/lacquereur'\nimport MarianneLogo from './icons/marianne'\nimport PulsarLogo from './icons/pulsar'\nimport SlateLogo from './icons/slate-digital'\n\nexport type CareerProps = React.ComponentProps<'div'> & {}\n\nexport const Career: React.FC = () => {\n  return (\n    <>\n      <Experience\n        title=\"47ng\"\n        url=\"https://47ng.com\"\n        years=\"2018 - present\"\n        Icon={Logo}\n        description={\n          <>\n            <p className=\"mb-4\">\n              I'm doing freelance software development for startups, businesses\n              and public institutions:\n            </p>\n            <ul>\n              <Client\n                title=\"La Fabrique Numérique des Ministères Sociaux\"\n                url=\"https://github.com/SocialGouv/e2esdk\"\n                Icon={MarianneLogo}\n                description={\n                  <>\n                    Designing an open-source{' '}\n                    <a\n                      href=\"https://github.com/SocialGouv/e2esdk\"\n                      className=\"underline\"\n                    >\n                      end-to-end encryption SDK\n                    </a>{' '}\n                    for web applications.\n                  </>\n                }\n                tags={[\n                  'Chakra-UI',\n                  'Docker',\n                  'Fastify',\n                  'Full-Stack',\n                  'Libsodium',\n                  'Node.js',\n                  'PostgreSQL',\n                  'Redis',\n                  'Turborepo',\n                  'TypeScript'\n                ]}\n              />\n              <Client\n                title=\"Heron\"\n                // url=\"https://heron.app\" -> dead link\n                Icon={HeronLogo}\n                description=\"Bootstrapping the MVP of a compensation market analysis app for a young international startup.\"\n                tags={[\n                  'Chakra-UI',\n                  'Full-Stack',\n                  'GraphQL',\n                  'Next.js',\n                  'PlanetScale',\n                  'Prisma',\n                  'Recharts',\n                  'TypeScript'\n                ]}\n              />\n              <Client\n                title=\"myNUMEA\"\n                url=\"https://www.mynumea.com/\"\n                description=\"Reporting dashboard &amp; analytics for a micro-nutrition IoT device.\"\n                tags={[\n                  'React',\n                  'Express',\n                  'Redux',\n                  'Chakra-UI',\n                  'VisX',\n                  'Full-Stack'\n                ]}\n              />\n              <Client\n                title=\"L'Acquéreur\"\n                // url=\"https://lacquereur.com\" -> dead link\n                Icon={AcquereurLogo}\n                description=\"A French startup helping real-estate buyers and sellers meet.\"\n                tags={['Meteor.js', 'Styled-Components', 'Web Design']}\n              />\n              <Client\n                title=\"Pulsar Audio\"\n                url=\"https://pulsar.audio\"\n                Icon={PulsarLogo}\n                description={\n                  <>\n                    Embedded UI for{' '}\n                    <a href=\"https://www.digigram.com/\">\n                      Digigram's Iqoya Talk\n                    </a>\n                    , a mobile broadcast recording studio.\n                  </>\n                }\n                tags={['C++', 'Qt', 'QML', 'Embedded Linux', 'UI Design']}\n              />\n              <Client\n                title=\"Grenoble Applied Economics Laboratory\"\n                url=\"https://grenoble.inra.fr\"\n                Icon={GaelLogo}\n                description=\"I built a Next.js webapp for an economics experiment ran by my wife's team.\"\n                tags={['Next.js', 'TypeScript', 'Jamstack', 'a11y']}\n              />\n            </ul>\n            <Note status=\"success\" title=\"Add your business here\">\n              <p className=\"my-4\">\n                You have a project you want to take further ?\n              </p>\n              <a href=\"mailto:freelance@francoisbest.com\" className=\"underline\">\n                Send me an email\n              </a>\n              , I'd love to discuss your needs and see how I can help.\n            </Note>\n          </>\n        }\n      />\n      <Experience\n        title=\"Slate Digital\"\n        url=\"https://slatedigital.com\"\n        years=\"2011 - 2018\"\n        Icon={SlateLogo}\n        description={\n          <p className=\"mb-0\">\n            I wore many hats when working at Slate Digital, from C++ programming\n            for real-time pro audio applications to DSP algorithm design and\n            tooling in Python and Ruby.\n          </p>\n        }\n        tags={[\n          'C++',\n          'Python',\n          'Ruby',\n          'Digital Signal Processing',\n          'Algorithm Design',\n          'Real-Time Audio'\n        ]}\n      />\n      <Experience\n        title=\"Arturia\"\n        years=\"2010\"\n        url=\"https://arturia.com\"\n        Icon={ArturiaLogo}\n        description={\n          <>\n            <p>\n              I helped design the{' '}\n              <a href=\"https://en.wikipedia.org/wiki/Arturia_MiniBrute\">\n                MiniBrute\n              </a>{' '}\n              analog synthesizer when I was an intern at Arturia. It was my\n              first professional experience and a lot of fun.\n            </p>\n            <p className=\"mb-0\">\n              I had the privilege of working with a great team led by{' '}\n              <a href=\"https://yusynth.net/index_en.php\">Yves Usson</a>, from\n              whom I had learned analog synthesis before joining Arturia.\n            </p>\n          </>\n        }\n        tags={[\n          'C++',\n          'Analog Electronics',\n          'Synthesizer Design',\n          'Hardware Design'\n        ]}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/experience.tsx",
    "content": "import React from 'react'\nimport { StaticTag } from 'ui/components/tag'\n\ntype ExperienceProps = {\n  title: string\n  url: string\n  description: React.ReactNode\n  Icon: React.ElementType\n  years?: string\n  tags?: string[]\n}\n\nconst tagClassName =\n  'bg-gray-50 text-gray-800 dark:bg-gray-800 dark:text-gray-300 flex-shrink-0'\n\nexport const Experience: React.FC<\n  React.ComponentProps<'section'> & ExperienceProps\n> = ({ title, url, description, Icon, years, tags = [], ...props }) => {\n  return (\n    <section className=\"not-prose mb-12 flex flex-col space-y-4\" {...props}>\n      <div className=\"flex items-center\">\n        <Icon aria-label={title} className=\"mr-2 h-8 w-8\" />\n        <a href={url} className=\"mr-auto\">\n          <h3 className=\"my-0 text-xl font-bold\">{title}</h3>\n        </a>\n        {years && <span className=\"text-sm text-gray-500\">{years}</span>}\n      </div>\n      <div className=\"my-0\">{description}</div>\n      <div className=\"flex flex-wrap gap-2 empty:hidden\">\n        {tags.map(tag => (\n          <StaticTag key={tag} className={tagClassName}>\n            {tag}\n          </StaticTag>\n        ))}\n      </div>\n    </section>\n  )\n}\n\ntype ClientProps = React.ComponentProps<'li'> & {\n  title: string\n  url?: string\n  description: React.ReactNode\n  Icon?: React.ElementType\n  tags?: string[]\n}\n\nexport const Client: React.FC<ClientProps> = ({\n  title,\n  url,\n  description,\n  Icon = null,\n  tags = [],\n  ...props\n}) => {\n  return (\n    <li className=\"ml-4\" {...props}>\n      <div className=\"mb-8 flex flex-col space-y-2\">\n        <div className=\"flex items-center\">\n          {Icon && <Icon aria-label={title} className=\"mr-2 h-6 w-6\" />}\n          {url ? (\n            <a href={url}>\n              <h4 className=\"my-0 font-bold\">{title}</h4>\n            </a>\n          ) : (\n            <h4 className=\"my-0 font-bold\">{title}</h4>\n          )}\n        </div>\n        <div className=\"my-0\">{description}</div>\n        <div className=\"flex flex-wrap gap-2 empty:hidden\">\n          {tags.map(tag => (\n            <StaticTag key={tag} className={tagClassName}>\n              {tag}\n            </StaticTag>\n          ))}\n        </div>\n      </div>\n    </li>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/arturia.tsx",
    "content": "export default function ArturiaLogo(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" viewBox=\"0 0 999 999\" {...props}>\n      <path d=\"M499.8,8.4C228.7,8.4,8.1,228.6,8.1,499.7S228.7,991,499.8,991s491.3-220.2,491.3-491.3 C991.1,227,772.5,8.4,499.8,8.4 M499.8,931.7c-239.1,0-432.4-192.9-432.4-432s193.3-432,432.4-432s432,193.3,432,432 C933.4,736.9,740.5,931.7,499.8,931.7\"/>\n      <path d=\"M645.7,396.1C604.9,277.6,571,155.4,499.8,155.4S394.7,277.6,354,396.1C305,540,257.5,687.4,228.7,687.4 v79.4c71.1,0,134-177.9,183-328.9c37.2-113.4,60.9-211.9,86.6-211.9c25.7,0,49,93.3,84.6,201.6c51,154.1,113.4,339.1,186.2,339.1 V689C740.5,689.4,694.7,540.4,645.7,396.1\"/>\n      <path d=\"M499.8,513.1c-79.8,0-157.7-115-251-115V461c64.4,0,155.7,117,251,117c93.3,0,184.6-117,251-117v-62.8 C657.1,398.1,579.3,513.1,499.8,513.1\"/>\n      <path d=\"M501.4,8.4C230.2,8.4,10.1,228.6,10.1,499.7S230.2,991,501.4,991s491.3-220.6,491.3-491.3 C991.1,227,772.5,8.4,501.4,8.4 M501.4,931.7c-239.1,0-432-193.3-432-432s192.9-432,432-432s432,193.3,432,432 C933.4,736.9,740.5,931.7,501.4,931.7\"/>\n      <path d=\"M647.2,396.1c-40.7-118.6-74.7-240.7-145.8-240.7S396.3,277.6,355.5,396.1 c-49,143.9-96.4,291.3-125.3,291.3v79.4c71.1,0,134-177.9,183-328.9c37.2-113.4,60.9-211.9,86.6-211.9s49,93.3,84.6,201.6 C635,581.9,697.8,766.9,770.6,766.9V689C740.5,689.4,694.7,540.4,647.2,396.1\"/>\n      <path d=\"M501.4,513.1c-79.4,0-157.7-115-251-115V461c64.4,0,155.7,117,251,117c93.3,0,184.6-117,251-117v-62.8 C657.1,398.1,579.3,513.1,501.4,513.1\"/>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/gael.tsx",
    "content": "export default function GaelLogo(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 288 203\" {...props}>\n      <path fill=\"#4A6EB4\" d=\"M286.879 97.59C286.691 95.012 286.603 92.404 286.283 89.82C285.044 79.445 282.019 69.215 277.705 59.476C276.506 57.103 275.323 54.71 274.065 52.353L273.121 50.582L271.953 48.779L269.582 45.187L267.158 41.626C266.957 41.343 266.739 41.009 266.552 40.771L266.007 40.111L264.918 38.789L260.551 33.508C259.428 32.233 260.137 33.053 259.84 32.725L259.661 32.549L259.304 32.197L258.587 31.496L257.151 30.098L254.271 27.313C252.411 25.393 250.292 23.756 248.189 22.106L245.04 19.629L243.462 18.397L241.781 17.309L234.975 13.108C232.664 11.797 230.227 10.712 227.859 9.526C226.66 8.963 225.49 8.337 224.267 7.837L220.554 6.448L216.828 5.126C215.596 4.667 214.349 4.257 213.064 3.97C210.523 3.335 207.987 2.702 205.455 2.07C202.906 1.547 200.322 1.211 197.762 0.851C196.478 0.699 195.208 0.466 193.921 0.388L190.064 0.218L186.227 0.07L184.317 0C183.68 0.008 183.043 0.057 182.407 0.084C179.863 0.203 177.334 0.361 174.825 0.549C164.817 1.785 155.12 4.045 146.247 7.889L144.569 8.539L142.952 9.336L139.749 10.943C137.644 12.056 135.49 13.009 133.552 14.361C131.587 15.627 129.631 16.888 127.686 18.14C125.755 19.417 123.88 21.054 122.027 22.486C118.021 25.461 115.556 28.32 112.59 31.175L111.213 32.525L110.158 33.781L108.072 36.275C106.725 37.963 105.254 39.504 104.067 41.267C102.884 43.021 101.709 44.761 100.542 46.49C99.979 47.361 99.356 48.181 98.855 49.084C98.366 49.99 97.878 50.895 97.393 51.795C96.435 53.584 95.485 55.361 94.542 57.125C93.725 58.947 93.003 60.797 92.237 62.592C90.572 66.121 89.593 69.85 88.46 73.397C87.838 75.155 87.524 76.989 87.144 78.772C86.759 80.547 86.377 82.307 85.999 84.047C84.279 94.231 84.277 103.85 85.252 112.578H88.51C88.469 111.361 88.541 110.129 88.599 108.883C88.674 107.395 88.751 105.885 88.829 104.356C88.835 102.831 89.182 101.301 89.379 99.752C89.616 98.211 89.858 96.65 90.102 95.07C90.797 91.955 91.667 88.806 92.541 85.627C94.705 79.361 97.395 73.027 101.179 67.106C102.124 65.626 103.051 64.129 104.037 62.663C105.138 61.274 106.247 59.874 107.364 58.464L109.027 56.355C109.59 55.658 110.273 55.044 110.889 54.384C112.143 53.091 113.407 51.788 114.678 50.476C115.932 49.163 117.455 48.083 118.827 46.871L120.936 45.096L121.989 44.205C122.306 43.932 121.704 44.453 121.825 44.359L121.98 44.252L122.292 44.037L122.915 43.605C126.205 41.388 129.727 38.652 132.781 37.318C134.39 36.519 135.814 35.609 137.534 34.88C139.26 34.165 140.994 33.446 142.737 32.724C144.425 31.913 146.296 31.501 148.085 30.927L150.779 30.089L152.122 29.665L153.515 29.382C155.356 28.999 157.204 28.614 159.061 28.23C160.909 27.857 162.832 27.804 164.698 27.539L167.509 27.193L170.359 27.129C172.258 27.123 174.144 26.971 176.041 27.059C183.643 27.286 191.154 28.68 198.45 30.696C200.24 31.309 202.033 31.926 203.829 32.544C204.739 32.808 205.608 33.187 206.461 33.607L209.045 34.789L211.649 35.908C212.505 36.306 213.307 36.822 214.14 37.267C215.776 38.218 217.483 39.038 219.074 40.058C222.161 42.257 225.365 44.257 228.203 46.783C229.675 47.971 231.179 49.123 232.453 50.545L234.461 52.561L235.471 53.565L235.977 54.063L236.231 54.313L236.353 54.434L240.345 59.213L241.446 60.526L241.997 61.182C242.163 61.391 242.238 61.549 242.364 61.733C242.817 62.452 243.287 63.155 243.764 63.854L245.214 65.94L245.956 66.975L246.637 68.213C247.533 69.873 248.504 71.494 249.455 73.131C250.339 74.801 251.016 76.569 251.827 78.276C252.202 79.143 252.65 79.985 252.975 80.87L253.867 83.559C254.16 84.457 254.491 85.344 254.814 86.235C255.163 87.12 255.467 88.016 255.655 88.944C256.121 90.784 256.587 92.62 257.051 94.452C258.404 101.888 259.231 109.479 258.786 117.063C258.717 118.958 258.411 120.825 258.249 122.715C258.145 123.656 258.073 124.606 257.95 125.543L257.374 128.313C256.945 130.149 256.73 132.055 256.211 133.868C255.675 135.681 255.14 137.489 254.607 139.294C252.078 146.345 249.132 153.384 244.828 159.478C242.933 162.728 240.418 165.455 238.133 168.38L237.697 168.923L237.479 169.193L237.371 169.33C237.272 169.435 237.864 168.912 237.549 169.197L236.479 170.26L234.346 172.381L232.228 174.483C231.526 175.174 230.813 175.936 230.153 176.417C228.803 177.526 227.463 178.628 226.131 179.722C225.446 180.265 224.827 180.867 224.089 181.367L221.852 182.851C220.356 183.847 218.871 184.839 217.397 185.82C215.857 186.683 214.287 187.476 212.74 188.304C206.53 191.589 199.998 193.738 193.582 195.39C190.342 195.999 187.134 196.609 183.972 197.042C182.379 197.159 180.804 197.272 179.249 197.382C177.689 197.452 176.138 197.671 174.616 197.55C173.086 197.503 171.577 197.456 170.087 197.405C168.595 197.354 167.108 197.366 165.676 197.12C164.233 196.925 162.81 196.729 161.408 196.542C160.012 196.312 158.603 196.218 157.284 195.831C154.621 195.163 152.013 194.581 149.523 193.905C147.093 193.057 144.738 192.241 142.495 191.401C140.249 190.565 138.249 189.413 136.248 188.503C135.257 188.023 134.289 187.558 133.345 187.101C132.449 186.554 131.575 186.023 130.725 185.507C129.029 184.456 127.422 183.464 125.911 182.53C124.443 181.53 123.179 180.44 121.94 179.507C120.718 178.55 119.586 177.663 118.55 176.855C117.526 176.031 116.729 175.128 115.944 174.402C112.876 171.414 111.239 169.82 111.239 169.82C111.239 169.82 112.862 171.429 115.905 174.445C116.234 174.754 116.572 175.105 116.922 175.457C117.263 175.801 117.643 176.145 118.033 176.494C118.035 176.496 118.038 176.5 118.041 176.5C118.196 176.641 118.327 176.781 118.494 176.918C119.008 177.34 119.545 177.781 120.105 178.242C120.656 178.695 121.229 179.164 121.825 179.652C121.939 179.744 122.061 179.849 122.177 179.943C123.283 180.843 124.419 181.845 125.729 182.789C127.206 183.789 128.774 184.855 130.431 185.98C137.138 190.316 145.826 195.046 156.692 198.191C167.504 201.355 180.406 203.316 194.628 202.121C196.395 201.894 198.18 201.66 199.983 201.426C201.794 201.196 203.648 201.039 205.452 200.563C209.083 199.727 212.884 199.063 216.542 197.7C218.399 197.098 220.3 196.524 222.188 195.864C224.024 195.071 225.876 194.27 227.741 193.466C228.679 193.056 229.621 192.646 230.566 192.236C231.51 191.81 232.427 191.228 233.368 190.72C235.249 189.654 237.142 188.575 239.05 187.493C240.071 186.946 240.821 186.38 241.645 185.817L244.102 184.118L246.575 182.411L247.817 181.552L249.366 180.236C252.653 177.349 256.213 174.572 259.198 171.142C265.626 164.685 271.039 156.888 275.601 148.4C280.177 139.869 283.213 130.377 285.29 120.502C285.691 118.018 286.022 115.502 286.369 112.979C286.449 112.346 286.551 111.713 286.611 111.081L286.703 109.171L286.873 105.332L287.024 101.471C287.051 100.17 286.925 98.885 286.879 97.59Z\"/>\n      <path fill=\"#4A6EB4\" d=\"M108.197 166.218C110.179 168.562 111.237 169.812 111.237 169.812C111.237 169.812 110.18 168.519 108.197 166.093V166.218Z\"/>\n      <path fill=\"currentColor\" d=\"M52.08 136.476C52.08 145.956 49.856 153.05 45.41 157.761C40.179 163.054 33.607 165.702 25.696 165.702C18.242 165.702 11.965 163.155 6.865 158.054C3.856 155.046 1.961 151.55 1.177 147.558C0.392 143.636 0 137.851 0 130.199C0 122.551 0.392 116.761 1.177 112.84C1.961 108.854 3.857 105.355 6.865 102.345C11.965 97.2449 18.242 94.6949 25.696 94.6949C33.215 94.6949 39.361 96.8859 44.135 101.267C48.384 105.255 51 110.519 51.981 117.059H38.251C36.55 110.258 32.366 106.858 25.696 106.858C22.034 106.858 19.125 108.102 16.967 110.587C14.743 113.396 13.632 119.935 13.632 130.2C13.632 140.727 14.743 147.302 16.967 149.915C18.992 152.333 21.903 153.544 25.696 153.544C29.75 153.544 32.986 152.235 35.406 149.622C37.498 147.204 38.545 144.095 38.545 140.302V137.654H25.696V126.279H52.08V136.476ZM92.782 95.1849L118.283 165.117H104.061L99.941 152.758H99.843L87.681 115.781L78.854 141.281H87.485L91.31 152.758H75.029L70.812 165.117H56.591L81.993 95.1849H92.782V95.1849ZM126.422 165.117V95.2839H172.421V107.447H140.055V123.824H167.616V135.988H140.055V152.953H172.421V165.117H126.422ZM185.76 165.117V95.2839H199.393V152.952H231.073V165.116H185.76V165.117Z\"/>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/heron.tsx",
    "content": "export default function HeronLogo(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 32 32\" {...props}>\n      <g clipPath=\"url(#clip0_349_26)\">\n        <path fill=\"#127B75\" d=\"M19.027 31.9999H32V23.7837C32 20.2013 29.0958 17.2972 25.5135 17.2972C21.9311 17.2972 19.027 20.2013 19.027 23.7837V31.9999Z\"/>\n        <path fill=\"#127B75\" d=\"M32 6.48648C32 2.90409 29.0958 0 25.5135 0C21.9311 0 19.027 2.90409 19.027 6.48648C19.027 10.0689 21.9311 12.973 25.5135 12.973C29.0958 12.973 32 10.0689 32 6.48648Z\"/>\n        <path fill=\"#127B75\" d=\"M0 0H12.973V8.2162C12.973 11.7986 10.0689 14.7026 6.48648 14.7026C2.90409 14.7026 0 11.7986 0 8.2162V0Z\"/>\n        <path fill=\"#127B75\" d=\"M12.973 25.5135C12.973 29.0959 10.0689 32 6.48648 32C2.90409 32 0 29.0959 0 25.5135C0 21.9311 2.90409 19.027 6.48648 19.027C10.0689 19.027 12.973 21.9311 12.973 25.5135Z\"/>\n      </g>\n      <defs>\n        <clipPath id=\"clip0_349_26\">\n          <rect width=\"32\" height=\"32\" fill=\"#fff\"/>\n        </clipPath>\n      </defs>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/lacquereur.tsx",
    "content": "export default function AcquereurLogo(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 537 540\" {...props}>\n      <path fill=\"#ff585b\" d=\"M435.2 155.5C396.6 41.5 272.9-19.7 159 18.8 45.1 57.4-15.9 181.1 22.7 295.2s162.2 175.2 276.2 136.7c113.8-38.6 174.9-162.3 136.3-276.4zM279.9 376.8C196.5 405 106 360.2 77.8 276.8s16.4-174 99.8-202.2c83.4-28.2 173.9 16.6 202.1 100 28.3 83.4-16.4 174-99.8 202.2z\"/>\n      <circle cx=\"229.9\" cy=\"161\" r=\"72\" fill=\"currentColor\"/>\n      <path fill=\"currentColor\" d=\"M104.9 293.9s37 73.5 125 73.5 123.8-73.5 123.8-73.5-.2-15.3-23.7-34.4-42-21.5-47.4-20.7c-2.6.4-6.5 1.5-10.6 4.1-5.4 3.4-22.6 12.8-42 13.2-22.1.5-45-14.3-46.7-15.7-1.7-1.4-7.4-3.8-14.3-1.6-6.9 2.2-29.5 9.8-45.1 25.6s-18.2 27.7-19 29.5z\"/>\n      <path fill=\"#ff585b\" d=\"M339.5 413.3l100.7 108.8s39.2 25.9 69.5-3.1c28.4-27.1 13.3-60.4 13.3-60.4L414.3 339.9s-11.1 16-34.1 39.4c-16.8 17.1-40.7 34-40.7 34z\"/>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/marianne.tsx",
    "content": "export default function MarianneLogo(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 245 130\" {...props}><defs><rect id=\"marianne2-a\" width=\"245.808\" height=\"130\"/><rect id=\"marianne2-c\" width=\"690.267\" height=\"130\"/></defs><rect width=\"245\" height=\"130\" fill=\"#fff\"/><g fill=\"none\" fillRule=\"evenodd\"><mask id=\"marianne2-b\" fill=\"#fff\"><use xlinkHref=\"#marianne2-a\"/></mask><g mask=\"url(#marianne2-b)\"><mask id=\"marianne2-d\" fill=\"#fff\"><use xlinkHref=\"#marianne2-c\"/></mask><g mask=\"url(#marianne2-d)\"><rect width=\"244.558\" height=\"74.613\" fill=\"#FFF\" fillRule=\"nonzero\"/><path fill=\"#000\" d=\"M195.292,129.916 C195.158,129.867 195.149,129.733 195.263,129.441 C195.405,129.07 195.473,129.04 196.025,129.106 C196.523,129.168 196.671,129.125 196.873,128.854 C197.277,128.337 196.967,127.963 196.227,128.086 C195.661,128.181 195.661,128.175 195.769,127.788 C196.106,126.556 196.133,126.625 195.123,126.442 C193.897,126.224 192.739,125.61 191.85,124.714 C190.504,123.368 190.046,122.007 190.18,119.818 C190.248,118.704 190.342,118.269 190.733,117.493 C191.918,115.121 194.638,113.913 198.301,114.128 C199.095,114.175 200.092,114.298 200.536,114.399 L201.33,114.582 L201.411,117.221 L200.926,117.221 C200.644,117.221 200.442,117.142 200.442,117.038 C200.442,116.659 199.459,115.797 198.772,115.562 C197.883,115.248 196.159,115.326 195.284,115.705 C193.305,116.568 192.402,118.802 193.008,121.363 C193.291,122.538 193.749,123.335 194.624,124.158 C195.459,124.929 196.308,125.243 197.573,125.243 C198.907,125.243 199.836,124.759 200.428,123.753 C200.832,123.074 200.967,122.983 201.357,123.205 C201.532,123.309 201.532,123.544 201.371,124.629 L201.169,125.935 L200.442,126.157 C200.038,126.275 199.122,126.419 198.408,126.471 C197.156,126.549 197.089,126.576 197.089,126.889 C197.089,127.137 197.183,127.229 197.452,127.229 C197.977,127.229 198.476,127.595 198.597,128.078 C198.731,128.64 198.287,129.463 197.708,129.737 C197.291,129.933 195.648,130.012 195.23,129.855 L195.292,129.916 Z M100.687,129.313 C99.314,128.947 98.129,128.394 96.701,127.457 L95.476,126.645 L93.577,126.556 C91.463,126.458 90.723,126.245 89.362,125.338 C86.346,123.342 86.238,118.259 89.16,115.788 C91.369,113.923 95.234,113.54 98.008,114.906 C101.765,116.75 102.169,122.192 98.748,125.044 C98.223,125.486 97.711,125.848 97.604,125.848 C97.281,125.848 101.024,127.694 102.115,128.072 C103.327,128.49 104.727,128.545 105.764,128.215 C106.518,127.976 106.653,128.006 106.774,128.44 C106.855,128.695 106.72,128.802 105.966,129.119 C104.768,129.621 102.263,129.714 100.755,129.313 L100.687,129.313 Z M96.028,124.878 C98.694,123.624 99.206,119.604 97.025,117.037 C95.005,114.659 91.463,115.018 90.13,117.738 C88.932,120.194 90.305,123.994 92.756,125 C93.752,125.405 95.018,125.361 96.042,124.878 L96.028,124.878 Z M12.456,126.365 C11.729,126.078 10.926,125.183 9.661,123.243 C7.715,120.258 7.35,119.819 6.806,119.819 L6.334,119.819 L6.338,121.436 C6.345,124.382 6.675,125.389 7.703,125.623 C7.977,125.685 8.085,125.809 8.085,126.065 L8.085,126.421 L1.567,126.421 L1.567,126.095 C1.567,125.847 1.689,125.729 2.066,125.624 C2.342,125.559 2.717,125.311 2.901,125.089 C3.228,124.697 3.235,124.553 3.235,119.118 C3.235,113.683 3.228,113.539 2.901,113.147 C2.717,112.925 2.342,112.69 2.066,112.611 C1.702,112.52 1.567,112.402 1.567,112.167 C1.567,111.854 1.584,111.854 5.605,111.762 C9.884,111.671 10.827,111.736 12.033,112.272 C13.086,112.729 13.966,114.127 13.967,115.342 C13.97,116.74 12.822,118.308 11.307,118.987 L10.513,119.34 L12.268,121.705 C14.328,124.501 14.974,125.246 15.58,125.533 C16.146,125.794 16.2,126.382 15.675,126.5 C15.028,126.657 12.941,126.591 12.456,126.395 L12.456,126.365 Z M8.48,118.369 C10.611,117.834 11.33,115.163 9.718,113.77 C8.959,113.117 8.154,112.878 7.125,113.006 L6.344,113.104 L6.344,118.529 L7.098,118.529 C7.515,118.529 8.148,118.457 8.485,118.369 L8.48,118.369 Z M45.115,126.365 C44.244,126.086 43.423,125.585 42.988,125.072 C42.197,124.135 42.113,123.644 42.104,119.839 C42.094,115.862 42.01,115.423 41.202,115.086 C40.879,114.95 40.688,114.766 40.685,114.583 C40.682,114.309 40.787,114.295 43.385,114.295 C46.082,114.295 46.089,114.295 46.089,114.619 C46.089,114.836 45.995,114.942 45.798,114.942 C45.639,114.942 45.335,115.109 45.122,115.312 L44.737,115.683 L44.737,119.582 C44.737,123.25 44.756,123.514 45.067,124.047 C45.552,124.884 46.521,125.311 47.763,125.244 C49.096,125.171 49.823,124.744 50.348,123.708 C50.752,122.913 50.766,122.874 50.766,119.383 C50.766,116.261 50.739,115.819 50.483,115.477 C50.321,115.264 50.025,115.049 49.81,115.001 C49.554,114.945 49.406,114.809 49.406,114.639 C49.406,114.387 49.554,114.369 51.547,114.369 C53.54,114.369 53.688,114.387 53.688,114.639 C53.688,114.809 53.54,114.945 53.298,115 C52.557,115.156 52.503,115.47 52.422,119.612 L52.341,123.492 L51.857,124.328 C51.291,125.321 50.375,126.014 49.204,126.327 C48.207,126.589 45.878,126.602 45.083,126.34 L45.115,126.365 Z M107.13,126.365 C106.249,126.083 105.436,125.584 104.986,125.051 C104.236,124.162 104.128,123.509 104.119,119.762 C104.105,115.869 104.011,115.424 103.216,115.085 C102.88,114.95 102.691,114.766 102.691,114.583 C102.678,114.309 102.785,114.295 105.384,114.295 C108.051,114.295 108.078,114.299 108.078,114.609 C108.078,114.818 107.956,114.936 107.728,114.968 C107.539,114.994 107.229,115.196 107.054,115.419 C106.744,115.798 106.718,116.033 106.664,119.179 C106.596,123.043 106.718,123.857 107.445,124.527 C108.751,125.742 111.485,125.402 112.252,123.939 C112.737,123.011 112.858,121.862 112.804,118.817 C112.737,115.603 112.629,115.198 111.794,115.015 C111.538,114.963 111.39,114.832 111.39,114.663 C111.39,114.414 111.538,114.401 113.531,114.401 C115.524,114.401 115.686,114.427 115.686,114.676 C115.686,114.846 115.497,115.015 115.215,115.12 C114.959,115.224 114.689,115.407 114.622,115.538 C114.555,115.669 114.461,117.524 114.407,119.667 L114.312,123.56 L113.828,124.409 C113.262,125.402 112.346,126.095 111.161,126.408 C110.165,126.683 107.835,126.696 107.041,126.434 L107.13,126.365 Z M157.462,126.361 C156.826,126.11 156.071,125.261 154.901,123.48 C153.39,121.181 153.217,120.972 152.824,120.972 C152.474,120.969 152.467,120.998 152.467,122.767 C152.467,124.912 152.665,125.517 153.414,125.652 C153.791,125.72 153.899,125.815 153.899,126.08 L153.899,126.42 L148.491,126.42 L148.491,126.08 C148.491,125.815 148.598,125.72 148.976,125.652 C149.882,125.489 149.945,125.101 149.891,120.106 L149.843,115.683 L149.458,115.312 C149.246,115.109 148.942,114.942 148.782,114.942 C148.603,114.942 148.492,114.838 148.492,114.668 C148.492,114.433 148.654,114.38 149.664,114.294 C150.31,114.229 152.074,114.216 153.596,114.242 C156.074,114.281 156.437,114.32 157.03,114.608 C158.08,115.104 158.672,116.084 158.672,117.312 C158.672,118.488 157.932,119.52 156.572,120.213 C156.141,120.435 155.858,120.657 155.952,120.709 C156.047,120.762 156.747,121.65 157.514,122.695 C159.023,124.746 159.858,125.661 160.208,125.661 C160.517,125.661 160.49,126.327 160.181,126.432 C159.736,126.589 157.986,126.51 157.487,126.314 L157.462,126.361 Z M154.524,119.61 C155.156,119.316 155.776,118.607 155.892,118.047 C156.043,117.328 155.69,116.387 155.084,115.865 C154.522,115.394 153.98,115.251 153.04,115.355 L152.467,115.42 L152.467,119.836 L153.27,119.836 C153.701,119.836 154.266,119.745 154.522,119.627 L154.524,119.61 Z M225.301,126.336 C224.826,126.219 224.407,126.101 224.372,126.069 C224.339,126.043 224.221,125.402 224.116,124.645 L223.92,123.273 L224.368,123.273 C224.615,123.273 224.817,123.325 224.817,123.39 C224.817,123.769 225.86,124.867 226.465,125.128 C228.101,125.847 230.064,125.167 230.063,123.9 C230.063,123.064 229.613,122.567 228.081,121.705 C225.932,120.49 225.101,119.85 224.674,119.066 C224.243,118.295 224.169,117.08 224.491,116.204 C224.704,115.629 225.703,114.754 226.544,114.427 C227.39,114.088 230.04,114.101 231.136,114.441 L231.971,114.702 L231.971,117.263 L231.513,117.263 C231.155,117.263 231.031,117.184 230.953,116.91 C230.788,116.309 229.935,115.564 229.221,115.394 C227.323,114.937 225.788,116.701 226.986,117.981 C227.215,118.229 228.252,118.948 229.289,119.588 C231.309,120.816 232.049,121.496 232.305,122.293 C232.857,124.07 231.942,125.651 229.976,126.304 C228.925,126.67 226.649,126.696 225.316,126.369 L225.301,126.336 Z M16.99,126.036 C16.99,125.78 17.079,125.704 17.375,125.704 C17.604,125.704 17.896,125.547 18.092,125.325 C18.408,124.962 18.42,124.783 18.42,120.439 C18.42,117.285 18.353,115.833 18.232,115.615 C17.989,115.213 17.612,114.953 17.262,114.949 C17.074,114.944 16.979,114.827 16.979,114.583 L16.979,114.225 L26.527,114.225 L26.527,117.256 L26.109,117.212 C25.786,117.178 25.638,117.046 25.503,116.676 C25.18,115.822 24.655,115.602 22.77,115.539 L21.1,115.484 L21.1,119.571 L22.379,119.506 C23.766,119.431 24.467,119.127 24.561,118.552 C24.588,118.324 24.722,118.243 25.032,118.243 L25.45,118.243 L25.45,121.979 L25.019,121.979 C24.669,121.979 24.574,121.914 24.574,121.639 C24.574,120.999 24.036,120.764 22.487,120.712 L21.073,120.66 L21.073,122.685 C21.073,125.115 21.087,125.154 22.905,125.154 C24.695,125.154 25.625,124.749 26.096,123.756 C26.325,123.26 26.877,123.116 27.052,123.508 C27.16,123.769 26.715,125.455 26.379,126.043 L26.136,126.461 L21.531,126.434 L16.926,126.408 L16.926,126.082 L16.99,126.036 Z M29.234,126.08 C29.234,125.819 29.341,125.721 29.691,125.656 C30.526,125.504 30.567,125.29 30.621,120.682 C30.674,115.883 30.607,115.45 29.732,115.086 C29.368,114.934 29.22,114.772 29.207,114.535 L29.207,114.197 L32.816,114.258 C36.721,114.323 37.044,114.388 38.108,115.185 C39.266,116.074 39.576,117.837 38.781,119.144 C38.094,120.294 36.896,120.895 35.011,121.065 L34.014,121.156 L33.772,120.607 L33.516,120.059 L34.095,119.98 C34.835,119.889 35.643,119.458 36.074,118.935 C36.317,118.648 36.411,118.334 36.398,117.785 C36.371,116.387 35.509,115.538 33.987,115.42 L33.125,115.355 L33.166,120.15 C33.22,125.376 33.246,125.52 34.135,125.69 C34.458,125.755 34.566,125.86 34.566,126.121 L34.566,126.461 L29.166,126.461 L29.166,126.121 L29.234,126.08 Z M55.627,126.028 C55.627,125.806 55.721,125.704 55.923,125.704 C56.085,125.704 56.395,125.58 56.597,125.43 L56.974,125.155 L57.014,120.708 C57.068,115.73 57.001,115.354 56.085,115.043 C55.789,114.945 55.6,114.776 55.6,114.625 C55.6,114.392 55.816,114.363 58.024,114.332 C62.656,114.264 63.639,114.311 64.394,114.64 C65.363,115.068 65.821,115.686 65.902,116.692 C65.929,117.145 65.915,117.674 65.861,117.868 C65.7,118.429 64.959,119.119 64.138,119.462 L63.37,119.778 L64.017,119.951 C65.552,120.356 66.521,121.807 66.319,123.362 C66.144,124.668 65.525,125.413 64.07,126.014 C63.303,126.327 63.101,126.34 59.425,126.34 L55.615,126.34 L55.615,126.014 L55.627,126.028 Z M62.63,124.948 C63.437,124.459 63.761,123.794 63.666,122.792 C63.532,121.289 62.36,120.394 60.529,120.394 L59.559,120.394 L59.6,122.561 C59.64,124.635 59.667,124.737 60.017,125 C60.556,125.4 61.889,125.374 62.589,124.948 L62.63,124.948 Z M61.97,118.986 C62.939,118.646 63.451,117.958 63.357,117.122 C63.222,115.998 62.212,115.373 60.542,115.373 L59.586,115.373 L59.586,119.247 L60.421,119.239 C60.879,119.235 61.566,119.122 61.956,118.986 L61.97,118.986 Z M68.487,126.036 C68.487,125.805 68.595,125.709 68.905,125.678 C69.134,125.652 69.47,125.456 69.659,125.227 C69.982,124.835 69.995,124.665 69.995,120.419 C69.995,116.892 69.942,115.947 69.767,115.659 C69.484,115.211 69.12,114.954 68.757,114.947 C68.568,114.934 68.474,114.83 68.474,114.608 C68.474,114.281 68.474,114.281 71.329,114.281 C74.183,114.281 74.183,114.281 74.183,114.595 C74.183,114.817 74.089,114.908 73.874,114.908 C73.699,114.908 73.362,115.065 73.12,115.248 L72.716,115.627 L72.662,120.147 L72.621,124.668 L73.025,124.903 C73.349,125.086 73.685,125.125 74.668,125.073 C76.176,124.995 76.89,124.655 77.308,123.806 C77.577,123.244 77.887,123.113 78.304,123.348 C78.452,123.44 78.425,123.701 78.183,124.59 C78.008,125.204 77.752,125.87 77.617,126.053 L77.361,126.393 L72.918,126.34 L68.474,126.288 L68.474,125.975 L68.487,126.036 Z M79.61,126.08 C79.61,125.815 79.718,125.72 80.095,125.652 C80.97,125.495 81.038,125.103 81.038,120.292 C81.038,116.297 81.011,115.989 80.728,115.556 C80.553,115.302 80.23,115.052 80.014,115.002 C79.731,114.95 79.61,114.819 79.61,114.61 C79.61,114.31 79.651,114.31 82.317,114.31 C84.983,114.31 85.01,114.323 85.01,114.623 C85.01,114.832 84.876,114.963 84.62,115.028 C84.404,115.081 84.094,115.29 83.946,115.512 C83.691,115.865 83.664,116.335 83.664,120.281 C83.664,125.154 83.731,125.546 84.552,125.703 C84.889,125.768 84.997,125.873 84.997,126.134 L84.997,126.474 L79.597,126.474 L79.597,126.134 L79.61,126.08 Z M117.612,126.054 C117.612,125.819 117.733,125.717 118.096,125.652 C118.972,125.495 119.039,125.103 119.039,120.277 C119.039,116.049 119.026,115.982 118.675,115.501 C118.473,115.234 118.15,114.992 117.962,114.968 C117.706,114.936 117.612,114.825 117.612,114.574 L117.612,114.225 L126.984,114.225 L126.984,115.731 C126.984,117.221 126.971,117.234 126.594,117.234 C126.284,117.234 126.19,117.142 126.109,116.737 C125.92,115.888 125.274,115.601 123.375,115.535 L121.773,115.483 L121.665,116.163 C121.611,116.528 121.557,117.456 121.557,118.201 L121.557,119.559 L122.904,119.481 C124.385,119.39 124.978,119.154 125.139,118.58 C125.207,118.318 125.341,118.214 125.611,118.214 L125.988,118.214 L125.988,121.937 L125.597,121.937 C125.328,121.937 125.193,121.859 125.193,121.689 C125.18,120.971 124.412,120.644 122.81,120.644 L121.503,120.644 L121.557,122.656 C121.625,125.099 121.611,125.086 123.51,125.086 C125.193,125.086 126.068,124.72 126.526,123.832 C126.755,123.375 126.93,123.218 127.186,123.218 C127.375,123.218 127.59,123.283 127.644,123.375 C127.792,123.584 127.078,125.922 126.796,126.171 C126.607,126.34 125.799,126.367 122.055,126.34 L117.544,126.314 L117.544,126.001 L117.612,126.054 Z M135.104,126.088 C135.104,125.853 135.252,125.696 135.602,125.55 C135.885,125.438 136.249,125.163 136.437,124.94 C136.76,124.548 136.76,124.371 136.76,119.128 C136.76,113.04 136.747,112.975 135.643,112.648 C135.212,112.517 135.077,112.4 135.077,112.125 L135.077,111.773 L146.2,111.773 L146.2,115.366 L145.715,115.366 C145.325,115.366 145.231,115.3 145.231,115.039 C145.231,114.503 144.638,113.785 143.992,113.536 C143.615,113.393 142.753,113.275 141.622,113.236 L139.831,113.171 L139.831,118.083 L141.137,118.07 C142.888,118.057 143.48,117.861 143.857,117.182 C144.099,116.737 144.234,116.633 144.611,116.633 L145.082,116.633 L145.082,120.957 L144.571,120.905 C144.221,120.866 144.032,120.762 143.978,120.566 C143.722,119.716 142.753,119.337 140.814,119.337 L139.79,119.337 L139.858,121.885 C139.925,124.786 140.06,125.217 141.016,125.517 C141.433,125.648 141.568,125.779 141.568,126.027 L141.568,126.353 L135.05,126.353 L135.05,126.027 L135.104,126.088 Z M160.542,126.062 C160.542,125.801 160.622,125.704 160.851,125.704 C161.336,125.704 161.888,124.809 163.1,122.085 C164.716,118.432 166.251,114.72 166.251,114.455 C166.251,114.276 166.413,114.225 167.005,114.228 L167.759,114.23 L169.901,119.191 C172.23,124.578 172.661,125.415 173.2,125.632 C173.415,125.717 173.55,125.897 173.55,126.096 C173.55,126.423 173.55,126.423 170.843,126.423 L168.136,126.423 L168.136,126.08 C168.136,125.819 168.244,125.72 168.608,125.654 C169.295,125.532 169.335,125.114 168.81,123.695 L168.352,122.476 L164.595,122.476 L164.218,123.482 C163.733,124.749 163.76,125.611 164.285,125.69 C164.541,125.729 164.635,125.833 164.635,126.082 L164.635,126.434 L160.501,126.434 L160.501,126.082 L160.542,126.062 Z M167.854,121.038 C167.854,120.829 166.682,117.955 166.588,117.955 C166.521,117.955 166.359,118.229 166.224,118.569 L165.551,120.15 L165.147,121.117 L166.494,121.117 C167.234,121.117 167.84,121.091 167.84,121.052 L167.854,121.038 Z M174.694,126.082 C174.694,125.833 174.802,125.729 175.125,125.664 C175.987,125.507 176.014,125.311 176.081,120.62 C176.149,115.786 176.081,115.381 175.152,115.068 C174.87,114.976 174.668,114.806 174.668,114.65 C174.668,114.427 174.843,114.401 176.459,114.401 L178.236,114.401 L179.273,115.59 C179.839,116.244 181.306,117.837 182.532,119.144 C183.757,120.451 184.915,121.718 185.117,121.953 L185.481,122.384 L185.427,119.144 C185.373,115.616 185.279,115.251 184.43,115.055 C184.175,115.002 184.026,114.859 184.026,114.689 C184.026,114.441 184.175,114.414 186.168,114.414 C188.188,114.414 188.309,114.441 188.309,114.702 C188.309,114.885 188.174,115.002 187.959,115.028 C187.77,115.055 187.46,115.264 187.285,115.486 C186.962,115.878 186.949,116.074 186.895,121.195 L186.841,126.5 L186.141,126.5 C185.508,126.5 185.387,126.448 184.915,125.899 C184.484,125.415 177.751,117.877 177.495,117.615 C177.455,117.576 177.455,119.157 177.495,121.13 C177.563,125.141 177.67,125.611 178.425,125.755 C178.775,125.82 178.882,125.912 178.882,126.173 L178.882,126.513 L174.6,126.513 L174.6,126.173 L174.694,126.082 Z M202.515,126.069 C202.515,125.86 202.596,125.716 202.731,125.716 C203.041,125.716 203.633,125.05 204.051,124.2 C204.63,123.037 207.35,116.701 207.821,115.42 L208.252,114.231 L209.679,114.336 L211.78,119.222 C212.938,121.914 214.015,124.331 214.177,124.605 C214.527,125.246 215.052,125.755 215.322,125.755 C215.443,125.755 215.537,125.912 215.537,126.121 L215.537,126.487 L210.124,126.487 L210.124,126.121 C210.124,125.873 210.205,125.768 210.407,125.768 C211.174,125.768 211.241,125.167 210.662,123.586 L210.245,122.476 L206.515,122.476 L206.178,123.443 C205.545,125.272 205.572,125.781 206.326,125.781 C206.542,125.781 206.623,125.886 206.623,126.147 L206.623,126.513 L202.489,126.513 L202.489,126.147 L202.515,126.069 Z M209.262,119.654 C208.952,118.844 208.669,118.086 208.616,117.968 C208.562,117.837 208.239,118.386 207.821,119.34 C207.431,120.215 207.121,120.973 207.121,121.025 C207.121,121.091 207.713,121.143 208.454,121.143 L209.787,121.143 L209.235,119.68 L209.262,119.654 Z M216.507,126.082 C216.507,125.807 216.588,125.729 216.87,125.729 C217.072,125.729 217.382,125.598 217.544,125.428 C217.813,125.154 217.84,124.814 217.88,120.66 C217.907,118.203 217.894,116.048 217.84,115.878 C217.732,115.473 217.14,114.976 216.776,114.976 C216.574,114.976 216.48,114.872 216.48,114.663 C216.48,114.349 216.48,114.349 219.173,114.349 C221.772,114.349 221.88,114.362 221.88,114.636 C221.88,114.819 221.705,115.002 221.408,115.107 C221.153,115.211 220.883,115.473 220.789,115.682 C220.56,116.23 220.425,120.712 220.573,123.129 C220.668,124.814 220.735,125.219 220.991,125.468 C221.153,125.637 221.422,125.768 221.583,125.768 C221.785,125.768 221.866,125.873 221.866,126.134 L221.866,126.5 L216.466,126.5 L216.466,126.134 L216.507,126.082 Z M234.309,126.056 C234.309,125.807 234.403,125.729 234.699,125.729 C234.928,125.729 235.211,125.572 235.413,125.35 C235.723,124.984 235.736,124.814 235.736,120.464 C235.736,117.315 235.682,115.865 235.548,115.643 C235.305,115.251 234.928,114.989 234.578,114.976 C234.39,114.976 234.295,114.859 234.295,114.61 L234.295,114.258 L243.829,114.258 L243.829,117.289 L243.412,117.25 C243.102,117.223 242.954,117.093 242.819,116.714 C242.51,115.865 241.985,115.643 240.086,115.577 L238.416,115.525 L238.416,119.575 L239.413,119.575 C240.786,119.562 241.459,119.34 241.756,118.765 C241.931,118.425 242.092,118.308 242.389,118.308 L242.779,118.308 L242.779,122.031 L242.375,122.031 C242.012,122.031 241.931,121.966 241.931,121.692 C241.931,121.052 241.406,120.816 239.843,120.764 L238.43,120.712 L238.43,122.724 C238.43,125.154 238.456,125.193 240.274,125.193 C242.065,125.193 242.995,124.788 243.466,123.795 C243.695,123.299 244.247,123.155 244.422,123.547 C244.53,123.808 244.085,125.494 243.749,126.082 L243.506,126.5 L238.901,126.474 L234.295,126.448 L234.295,126.108 L234.309,126.056 Z M21.29,112.95 L20.903,112.679 L21.849,111.386 C22.369,110.667 22.895,110.079 23.02,110.079 C23.556,110.079 25.269,110.406 25.226,110.51 C25.14,110.717 22.002,113.241 21.839,113.237 C21.752,113.237 21.505,113.114 21.291,112.963 L21.29,112.95 Z M1.091,103.036 L243.265,103.036 L243.265,104.323 L1.088,104.323 L1.088,103.032 L1.091,103.036 Z M93.26,100.096 C91.988,99.686 91.272,98.62 91.504,97.47 C91.665,96.699 92.15,96.156 93.012,95.798 L93.766,95.484 L93.308,95.249 C93.052,95.112 92.85,94.887 92.85,94.726 C92.85,94.465 94.022,93.42 94.304,93.42 C94.372,93.42 94.278,93.181 94.089,92.889 C93.281,91.643 93.927,89.842 95.449,89.137 C96.271,88.754 96.553,88.738 99.435,88.904 C101.091,89.001 101.159,89.017 101.064,89.309 C101.024,89.479 100.984,89.665 100.97,89.725 C100.97,89.784 100.701,89.832 100.351,89.832 L99.731,89.832 L99.812,90.666 C100.001,92.486 98.56,93.732 96.244,93.766 C95.045,93.783 94.466,94.014 94.937,94.286 C95.072,94.362 95.853,94.619 96.661,94.858 C99.3,95.629 99.785,96.017 99.677,97.249 C99.57,98.536 98.425,99.653 96.769,100.104 C95.826,100.361 94.116,100.359 93.308,100.099 L93.26,100.096 Z M96.801,99.208 C98.091,98.629 98.65,97.522 97.946,96.947 C97.469,96.559 95.403,95.962 94.867,96.059 C93.777,96.255 92.753,97.457 92.942,98.319 C93.198,99.508 95.137,99.956 96.793,99.21 L96.801,99.208 Z M97.227,92.714 C98.196,92.184 98.573,90.669 97.927,89.922 C97.038,88.898 95.341,90.04 95.328,91.673 C95.328,92.18 95.409,92.359 95.772,92.64 C96.311,93.058 96.607,93.071 97.254,92.718 L97.227,92.714 Z M103.259,96.046 C102.559,95.915 102.196,95.497 102.115,94.752 C101.913,92.845 103.69,90.044 105.683,89.141 C106.72,88.67 108.767,88.623 110.127,89.036 C110.248,89.075 110.168,89.565 109.844,90.574 C109.238,92.547 108.875,94.522 109.077,94.863 C109.211,95.086 109.306,95.103 109.737,94.971 C110.127,94.84 110.262,94.853 110.37,95.01 C110.598,95.336 110.464,95.454 109.548,95.741 C108.336,96.12 107.771,96.042 107.582,95.48 L107.447,95.036 L106.801,95.363 C105.71,95.924 104.202,96.212 103.3,96.042 L103.259,96.046 Z M106.007,94.756 C106.478,94.558 106.949,94.253 107.084,94.077 C107.299,93.786 107.946,91.634 108.202,90.379 C108.309,89.866 108.296,89.848 107.717,89.704 C106.653,89.432 105.576,90.135 104.727,91.664 C103.838,93.258 103.61,94.684 104.189,95.054 C104.593,95.308 104.835,95.263 106.033,94.754 L106.007,94.756 Z M181.296,96.004 C180.582,95.847 180.339,95.625 180.137,94.972 C179.841,93.979 180.528,92.101 181.7,90.713 C183.1,89.053 184.649,88.491 186.938,88.818 C187.544,88.904 188.082,89.021 188.136,89.075 C188.204,89.128 188.002,90.046 187.692,91.114 C187.396,92.178 187.126,93.469 187.113,93.983 L187.086,94.921 L187.759,94.895 C188.365,94.869 188.433,94.908 188.433,95.195 C188.433,95.47 188.284,95.561 187.517,95.77 C186.265,96.123 185.672,96.045 185.605,95.535 C185.578,95.326 185.524,95.143 185.497,95.143 C185.47,95.143 185.053,95.3 184.581,95.483 C183.827,95.783 182.279,96.175 181.969,96.136 C181.915,96.136 181.632,96.071 181.349,96.018 L181.296,96.004 Z M183.962,94.776 C184.352,94.593 184.797,94.332 184.945,94.188 C185.295,93.822 185.928,91.954 186.265,90.308 C186.359,89.863 186.332,89.837 185.726,89.693 C185.214,89.563 184.999,89.576 184.541,89.772 C183.531,90.203 182.534,91.666 182.009,93.469 C181.511,95.197 182.13,95.604 183.989,94.768 L183.962,94.776 Z M11.515,95.677 C11.474,95.494 11.757,94.162 12.148,92.712 C12.929,89.85 12.929,89.615 12.067,89.837 C11.65,89.942 11.609,89.916 11.609,89.628 C11.609,89.354 11.757,89.262 12.525,89.056 C13.023,88.925 13.696,88.8 13.993,88.782 C14.518,88.751 14.558,88.778 14.599,89.231 C14.626,89.496 14.356,90.808 13.993,92.145 C13.629,93.485 13.373,94.679 13.427,94.802 C13.508,94.978 13.669,95.003 14.248,94.925 C14.935,94.829 14.976,94.841 14.922,95.153 C14.881,95.415 14.679,95.532 13.993,95.728 C13.521,95.859 12.781,95.977 12.363,95.977 C11.717,95.977 11.609,95.937 11.528,95.663 L11.515,95.677 Z M17.628,95.743 L17.265,95.468 L18.504,90.582 C19.177,87.894 19.716,85.513 19.689,85.285 C19.635,84.863 19.581,84.841 18.962,85.011 C18.665,85.093 18.612,85.055 18.612,84.75 C18.612,84.331 19.177,84.129 20.726,83.992 C21.614,83.913 21.641,83.917 21.641,84.253 C21.641,84.444 21.399,85.625 21.089,86.877 C20.78,88.131 20.537,89.235 20.537,89.337 C20.537,89.481 20.712,89.454 21.224,89.251 C23.001,88.545 24.537,88.584 25.102,89.342 C25.627,90.061 25.358,91.903 24.51,93.418 C24.079,94.215 22.773,95.444 22.059,95.731 C21.13,96.11 18.194,96.123 17.669,95.731 L17.628,95.743 Z M21.655,94.828 C22.463,94.319 23.352,92.594 23.594,91.078 C23.85,89.55 22.732,89.249 20.955,90.36 C20.578,90.595 20.281,90.935 20.12,91.314 C19.837,92.019 19.244,94.306 19.244,94.713 C19.244,95.314 20.793,95.385 21.668,94.818 L21.655,94.828 Z M27.728,95.56 C27.297,95.155 27.27,95.037 27.27,94.083 C27.297,91.658 28.819,89.491 30.92,88.896 C31.97,88.596 32.899,88.701 33.518,89.177 C33.882,89.454 33.949,89.627 33.949,90.213 C33.949,91.69 32.468,92.813 30.314,92.944 C29.694,92.983 29.129,93.088 29.075,93.175 C28.9,93.421 28.94,94.247 29.142,94.599 C29.546,95.279 30.112,95.298 31.472,94.665 C32.32,94.273 32.495,94.234 32.63,94.417 C32.913,94.818 32.778,94.944 31.552,95.468 C30.529,95.902 30.138,95.991 29.277,95.991 C28.334,95.991 28.199,95.954 27.768,95.552 L27.728,95.56 Z M30.583,92.111 C31.943,91.771 32.832,90.464 32.145,89.837 C31.714,89.443 31.31,89.456 30.744,89.883 C30.3,90.223 29.573,91.298 29.357,91.939 C29.277,92.201 29.317,92.253 29.627,92.253 C29.829,92.253 30.273,92.175 30.61,92.096 L30.583,92.111 Z M35.942,95.945 C35.942,95.924 36.225,94.809 36.575,93.476 C36.925,92.139 37.222,90.764 37.222,90.419 L37.222,89.791 L36.589,89.883 C36.023,89.961 35.956,89.94 35.956,89.687 C35.956,89.373 36.468,89.132 37.76,88.84 C38.649,88.644 38.986,88.779 38.986,89.337 L38.986,89.674 L39.551,89.329 C40.548,88.728 41.154,88.61 41.867,88.911 C42.218,89.055 42.473,89.237 42.447,89.329 C42.016,90.701 41.773,91.132 41.423,91.132 C41.113,91.132 41.06,91.067 41.06,90.649 C41.06,89.603 40.063,89.603 39.322,90.635 C38.878,91.289 38.407,92.752 38.03,94.699 C37.922,95.287 37.747,95.77 37.666,95.796 C37.383,95.875 35.983,96.005 35.983,95.966 L35.942,95.945 Z M43.928,95.809 C43.753,95.626 43.847,95.104 44.763,91.367 L45.14,89.825 L44.601,89.825 C44.305,89.825 44.062,89.786 44.062,89.747 C44.062,89.525 44.426,89.107 44.615,89.107 C45.005,89.107 45.746,88.427 46.069,87.787 C46.419,87.082 46.554,86.964 47.079,86.964 C47.429,86.964 47.456,87.016 47.362,87.434 C47.308,87.696 47.214,88.153 47.16,88.441 L47.065,88.976 L47.806,88.976 C48.56,88.976 48.668,89.094 48.452,89.616 C48.385,89.786 48.143,89.852 47.604,89.852 C46.715,89.852 46.863,89.564 46.163,92.713 C45.651,95.025 45.678,95.234 46.54,95.012 C47.267,94.829 47.321,94.843 47.267,95.208 C47.227,95.457 47.025,95.587 46.419,95.77 C45.449,96.071 44.224,96.11 43.982,95.849 L43.928,95.809 Z M50.351,95.626 C49.193,94.503 50.028,91.276 51.886,89.773 C52.64,89.159 53.825,88.689 54.593,88.689 C55.091,88.689 56.168,89.146 56.33,89.42 C56.586,89.852 56.505,90.884 56.182,91.367 C55.522,92.36 54.068,93 52.492,93 L51.563,93 L51.563,93.758 C51.563,95.234 52.25,95.483 54.027,94.699 C54.593,94.451 55.091,94.268 55.132,94.307 C55.172,94.346 55.226,94.503 55.266,94.66 C55.307,94.895 55.091,95.052 54.149,95.483 C53.125,95.953 52.829,96.018 51.873,96.018 C50.89,96.018 50.728,95.979 50.392,95.653 L50.351,95.626 Z M53.152,92.112 C54,91.864 54.579,91.419 54.835,90.871 C55.051,90.413 55.051,90.335 54.768,89.995 C54.337,89.473 53.906,89.46 53.246,89.982 C52.735,90.387 51.873,91.733 51.873,92.125 C51.873,92.334 52.479,92.321 53.152,92.112 Z M112.874,95.914 C112.659,95.718 112.793,94.869 113.561,91.942 C114.598,87.97 115.15,85.618 115.15,85.239 C115.15,84.965 115.083,84.939 114.598,85.017 C114.1,85.096 114.046,85.07 114.046,84.756 C114.046,84.403 114.45,84.233 116.025,83.959 C116.847,83.828 117.076,83.92 117.076,84.39 C117.076,84.612 116.551,86.873 115.891,89.433 C115.231,91.994 114.692,94.307 114.692,94.568 L114.692,95.052 L115.419,94.973 C116.106,94.895 116.147,94.908 116.093,95.208 C116.052,95.457 115.85,95.587 115.177,95.77 C114.288,96.031 113.09,96.11 112.901,95.94 L112.874,95.914 Z M118.867,95.835 C118.678,95.548 118.786,94.934 119.459,92.53 C119.823,91.289 120.079,90.152 120.052,90.021 C119.984,89.773 119.944,89.76 119.257,89.904 C118.894,89.982 118.813,89.956 118.813,89.708 C118.813,89.368 119.5,89.068 120.738,88.846 C121.627,88.702 122.004,88.78 122.004,89.107 C122.004,89.224 121.681,90.544 121.291,92.033 C120.9,93.523 120.617,94.816 120.685,94.895 C120.752,94.986 121.062,95.012 121.493,94.96 C122.166,94.882 122.206,94.895 122.152,95.195 C122.112,95.444 121.91,95.574 121.223,95.757 C120.2,96.058 119.069,96.097 118.907,95.849 L118.867,95.835 Z M124.738,95.77 C124.684,95.639 124.724,95.169 124.832,94.738 C125.196,93.301 125.963,89.982 125.963,89.917 C125.963,89.878 125.748,89.852 125.479,89.852 C124.792,89.852 124.926,89.447 125.734,89.068 C126.219,88.846 126.502,88.558 126.892,87.879 C127.35,87.108 127.485,86.99 127.876,86.99 L128.32,86.99 L128.104,87.892 C127.983,88.388 127.889,88.832 127.889,88.898 C127.889,88.95 128.239,89.002 128.683,89.002 C129.518,89.002 129.545,89.015 129.384,89.538 C129.303,89.825 129.182,89.865 128.468,89.865 L127.647,89.865 L127.108,92.282 C126.812,93.614 126.61,94.803 126.664,94.934 C126.744,95.117 126.866,95.143 127.283,95.025 C128.01,94.843 128.051,94.856 128.051,95.234 C128.051,95.522 127.916,95.613 127.121,95.809 C125.896,96.123 124.899,96.123 124.765,95.809 L124.738,95.77 Z M131.296,95.692 C130.905,95.391 130.825,95.221 130.771,94.411 C130.65,92.608 131.565,90.57 132.925,89.629 C134.433,88.597 136.076,88.467 136.992,89.29 C137.423,89.682 137.463,89.786 137.383,90.439 C137.234,91.864 135.726,92.896 133.652,92.987 C132.36,93.053 132.225,93.196 132.481,94.202 C132.764,95.3 133.262,95.417 134.851,94.738 C135.901,94.294 136.144,94.294 136.144,94.738 C136.144,95.195 134.245,95.94 132.871,96.005 C131.861,96.058 131.7,96.031 131.296,95.705 L131.296,95.692 Z M134.662,91.851 C135.659,91.393 136.09,90.387 135.524,89.825 C134.811,89.12 133.504,90.048 132.844,91.72 L132.616,92.334 L133.329,92.243 C133.72,92.19 134.326,92.02 134.676,91.864 L134.662,91.851 Z M172.677,95.966 C172.677,95.94 172.96,94.829 173.31,93.497 C173.66,92.164 173.957,90.792 173.957,90.439 L173.957,89.812 L173.324,89.904 C172.758,89.982 172.691,89.969 172.691,89.708 C172.691,89.368 172.973,89.224 174.266,88.924 C175.384,88.663 175.721,88.767 175.721,89.368 L175.721,89.708 L176.286,89.368 C177.283,88.767 177.889,88.65 178.602,88.937 C178.952,89.081 179.222,89.237 179.222,89.277 C179.222,89.329 179.074,89.773 178.872,90.257 C178.575,91.001 178.454,91.158 178.158,91.158 C177.862,91.158 177.794,91.08 177.794,90.675 C177.794,89.695 176.865,89.616 176.165,90.544 C175.64,91.25 175.37,92.02 174.926,94.032 C174.738,94.947 174.536,95.731 174.482,95.77 C174.387,95.862 172.718,96.071 172.718,95.992 L172.677,95.966 Z M191.018,95.796 C190.897,95.496 191.045,94.607 191.651,92.073 L192.176,89.917 L191.732,89.878 C191.207,89.825 191.112,89.355 191.611,89.211 C192.176,89.055 192.823,88.467 193.132,87.826 C193.482,87.121 193.617,87.003 194.142,87.003 C194.492,87.003 194.519,87.056 194.425,87.474 C194.371,87.735 194.277,88.192 194.223,88.48 L194.129,89.015 L194.869,89.015 C195.623,89.015 195.731,89.133 195.516,89.656 C195.448,89.825 195.206,89.891 194.667,89.891 C193.792,89.891 193.927,89.603 193.227,92.752 C192.715,95.065 192.742,95.274 193.604,95.052 C194.331,94.869 194.385,94.882 194.331,95.248 C194.29,95.496 194.088,95.626 193.482,95.809 C192.432,96.136 191.207,96.149 191.072,95.849 L191.018,95.796 Z M197.441,95.653 C196.243,94.568 197.078,91.315 198.95,89.799 C199.704,89.185 200.889,88.715 201.656,88.715 C202.155,88.715 203.232,89.172 203.393,89.447 C203.649,89.878 203.568,90.91 203.245,91.406 C202.572,92.412 201.131,93.039 199.542,93.039 L198.599,93.039 L198.68,93.928 C198.801,95.326 199.367,95.522 201.104,94.738 C201.67,94.49 202.168,94.307 202.208,94.346 C202.249,94.385 202.303,94.542 202.343,94.699 C202.397,94.934 202.168,95.091 201.225,95.522 C200.215,95.992 199.906,96.058 198.99,96.058 C198.061,96.058 197.872,96.018 197.509,95.692 L197.441,95.653 Z M200.781,91.916 C201.899,91.419 202.397,90.27 201.697,89.786 C201.239,89.473 200.902,89.525 200.296,90.008 C199.785,90.413 198.923,91.759 198.923,92.164 C198.923,92.399 200.013,92.256 200.768,91.916 L200.781,91.916 Z M205.615,95.914 C205.615,95.849 205.912,94.686 206.262,93.327 C206.625,91.968 206.895,90.649 206.868,90.4 C206.827,89.982 206.787,89.956 206.235,89.982 C205.79,89.995 205.642,89.943 205.575,89.734 C205.521,89.512 205.656,89.407 206.316,89.185 C206.773,89.028 207.406,88.872 207.73,88.806 C208.349,88.702 208.335,88.689 208.726,89.538 C208.739,89.551 209.157,89.368 209.669,89.133 C210.773,88.636 210.948,88.61 211.621,88.989 L212.133,89.264 L211.796,90.191 C211.513,90.975 211.392,91.132 211.096,91.132 C210.8,91.132 210.732,91.054 210.732,90.662 C210.732,89.407 209.413,89.656 208.699,91.041 C208.524,91.38 208.16,92.582 207.905,93.706 C207.649,94.829 207.406,95.757 207.379,95.783 C207.245,95.888 205.656,95.979 205.656,95.888 L205.615,95.914 Z M213.574,95.914 C213.574,95.849 213.83,94.895 214.153,93.797 C214.49,92.595 214.732,91.406 214.745,90.805 L214.772,89.799 L214.207,89.852 C213.762,89.891 213.614,89.838 213.56,89.629 C213.493,89.407 213.628,89.316 214.288,89.133 C215.728,88.728 216.065,88.702 216.348,88.963 C216.482,89.081 216.604,89.342 216.604,89.538 C216.604,89.721 216.631,89.878 216.671,89.878 C216.698,89.878 217.169,89.682 217.721,89.447 C219.001,88.898 220.697,88.61 221.209,88.859 C221.532,89.015 221.559,89.094 221.465,89.76 C221.398,90.152 221.142,91.263 220.886,92.216 C220.63,93.17 220.401,94.176 220.374,94.451 C220.334,94.947 220.334,94.947 220.994,94.934 C221.451,94.921 221.694,94.986 221.748,95.13 C221.869,95.43 221.721,95.522 220.724,95.783 C219.593,96.084 218.543,96.084 218.422,95.783 C218.368,95.653 218.61,94.411 218.96,93 C219.66,90.178 219.701,89.852 219.364,89.747 C219.014,89.629 217.802,90.165 217.075,90.753 C216.469,91.236 216.402,91.393 216.025,92.804 C215.809,93.654 215.58,94.647 215.513,95.025 C215.446,95.404 215.311,95.744 215.217,95.77 C214.732,95.901 213.52,95.966 213.52,95.862 L213.574,95.914 Z M224.481,95.809 C224.427,95.679 224.71,94.359 225.114,92.857 C225.518,91.367 225.814,90.061 225.761,89.943 C225.707,89.799 225.559,89.786 225.155,89.891 C224.872,89.969 224.616,90.034 224.589,90.034 C224.576,90.034 224.549,89.891 224.549,89.708 C224.549,89.433 224.697,89.342 225.384,89.159 C227.269,88.65 227.848,88.741 227.659,89.499 C227.619,89.695 227.282,90.936 226.932,92.269 C226.582,93.601 226.326,94.79 226.38,94.908 C226.447,95.065 226.649,95.104 227.202,95.025 C227.875,94.947 227.915,94.96 227.875,95.261 C227.767,95.875 224.724,96.41 224.495,95.862 L224.481,95.809 Z M230.487,95.809 C230.38,95.561 230.514,94.882 231.134,92.386 C231.82,89.629 231.82,89.878 231.241,89.878 C230.541,89.878 230.662,89.486 231.47,89.107 C231.942,88.885 232.224,88.597 232.601,87.957 C233.019,87.265 233.194,87.095 233.544,87.056 C234.029,87.003 234.029,87.043 233.692,88.584 L233.598,89.055 L234.433,89.055 C235.133,89.055 235.254,89.094 235.173,89.277 C235.12,89.407 235.079,89.603 235.079,89.708 C235.079,89.865 234.864,89.917 234.231,89.917 L233.382,89.917 L232.857,92.334 C232.574,93.667 232.386,94.856 232.44,94.986 C232.521,95.182 232.642,95.195 233.073,95.091 C233.8,94.908 233.84,94.921 233.84,95.3 C233.84,95.6 233.706,95.679 232.911,95.875 C231.699,96.188 230.689,96.188 230.555,95.875 L230.487,95.809 Z M237.032,95.705 C236.587,95.352 236.574,95.313 236.574,93.928 C236.574,92.582 236.601,92.465 237.18,91.419 C237.894,90.139 238.688,89.447 239.994,89.015 C242.257,88.258 243.873,89.551 242.876,91.315 C242.31,92.321 240.816,93.039 239.281,93.039 C238.365,93.039 238.298,93.066 238.203,93.418 C238.069,93.902 238.244,94.62 238.553,94.947 C238.917,95.326 239.536,95.261 240.654,94.738 C241.489,94.359 241.664,94.32 241.785,94.49 C242.068,94.895 241.933,95.052 240.87,95.509 C240.048,95.875 239.523,95.992 238.675,96.031 C237.651,96.084 237.503,96.058 237.086,95.731 L237.032,95.705 Z M240.506,91.837 C241.583,91.302 241.947,90.23 241.193,89.786 C240.91,89.616 240.695,89.59 240.358,89.695 C239.806,89.852 238.998,90.792 238.675,91.668 L238.419,92.347 L239.159,92.256 C239.563,92.203 240.177,92.02 240.526,91.837 L240.506,91.837 Z M0.013,95.612 C0.04,95.472 0.205,95.338 0.377,95.308 C0.848,95.229 1.252,94.85 1.576,94.202 C1.993,93.34 3.313,87.356 3.353,86.115 C3.38,85.057 3.38,85.044 2.868,84.756 C1.885,84.194 2.155,84.103 4.902,84.103 C7.083,84.103 7.447,84.142 7.447,84.325 C7.447,84.456 7.245,84.586 6.962,84.638 C5.952,84.821 5.562,85.723 4.605,90.165 C3.717,94.32 3.717,94.242 4.121,94.607 C4.43,94.882 4.592,94.908 5.831,94.856 C7.501,94.777 8.107,94.529 8.686,93.654 C9.143,92.974 9.17,92.961 9.494,93.144 C9.682,93.249 9.669,93.392 9.453,93.967 C9.319,94.359 9.036,94.934 8.834,95.248 L8.47,95.822 L4.215,95.862 C0.229,95.888 -0.04,95.875 0.013,95.639 L0.013,95.612 Z M80.438,95.655 C80.438,95.543 80.708,95.334 81.044,95.189 C81.462,95.006 81.745,94.731 81.987,94.279 C82.445,93.437 83.818,87.326 83.845,86.035 C83.872,85.061 83.859,85.029 83.361,84.744 C82.297,84.13 82.539,84.091 87.387,84.091 C91.831,84.091 91.925,84.099 91.831,84.375 C91.777,84.518 91.642,85.093 91.535,85.629 C91.373,86.452 91.292,86.609 90.996,86.648 C90.713,86.687 90.646,86.622 90.646,86.308 C90.646,85.354 90.026,85.08 87.791,85.08 L86.256,85.08 L85.825,86.909 C85.582,87.915 85.354,88.843 85.313,88.987 C85.246,89.209 85.381,89.235 86.646,89.235 C88.235,89.235 88.707,89.078 89.164,88.412 C89.582,87.811 90.121,87.772 89.999,88.333 L89.636,90.045 C89.42,91.09 89.299,91.365 89.097,91.365 C88.936,91.365 88.801,91.208 88.734,90.933 C88.68,90.698 88.478,90.424 88.276,90.332 C87.899,90.149 85.394,90.058 85.219,90.228 C85.111,90.319 84.64,92.384 84.438,93.677 C84.344,94.291 84.371,94.37 84.734,94.592 C85.057,94.788 85.421,94.814 86.539,94.775 C88.195,94.696 88.787,94.461 89.469,93.586 C89.765,93.207 90.048,92.985 90.173,93.037 C90.483,93.141 90.448,93.403 89.979,94.409 C89.279,95.911 89.669,95.807 84.795,95.807 C81.226,95.807 80.526,95.768 80.526,95.598 L80.438,95.655 Z M161.222,95.658 C161.222,95.544 161.464,95.378 161.747,95.292 C162.757,94.982 163.093,94.164 164.09,89.52 C164.952,85.522 164.938,84.882 163.928,84.591 C163.753,84.539 163.632,84.422 163.672,84.33 C163.726,84.213 164.965,84.154 168.157,84.126 C172.493,84.087 172.574,84.087 172.466,84.362 C172.412,84.518 172.277,85.08 172.17,85.616 C172.008,86.439 171.927,86.596 171.631,86.635 C171.335,86.674 171.281,86.609 171.281,86.23 C171.281,85.315 170.513,85.028 168.345,85.093 L167.093,85.132 L166.648,86.988 C166.406,88.007 166.204,88.934 166.204,89.026 C166.204,89.287 168.628,89.261 169.207,88.987 C169.449,88.869 169.746,88.582 169.853,88.333 C169.975,88.059 170.177,87.889 170.352,87.889 C170.729,87.889 170.729,87.785 170.311,89.718 C170.028,91.077 169.921,91.338 169.678,91.338 C169.503,91.338 169.396,91.234 169.396,91.077 C169.396,90.411 167.914,89.901 166.541,90.11 L165.935,90.202 L165.612,91.809 C165.046,94.631 165.073,94.892 165.935,95.206 C166.891,95.558 166.339,95.689 163.794,95.728 C161.706,95.768 161.302,95.741 161.302,95.558 L161.222,95.658 Z M67.942,92.572 C67.228,92.28 66.609,91.379 66.609,90.643 C66.609,89.503 67.726,88.484 68.979,88.488 C70.474,88.493 71.389,89.313 71.389,90.635 C71.403,92.321 69.692,93.288 67.955,92.582 L67.942,92.572 Z M148.887,92.64 C148.617,92.531 148.186,92.203 147.944,91.908 C147.58,91.473 147.5,91.236 147.513,90.628 C147.54,89.311 148.442,88.498 149.897,88.498 C150.624,88.498 150.866,88.57 151.351,88.929 C152.455,89.726 152.63,91.059 151.755,91.999 C151.028,92.789 149.924,93.036 148.914,92.64 L148.887,92.64 Z M53.811,87.538 C53.501,87.227 53.474,87.259 55.278,85.403 L55.887,84.776 L56.792,84.959 C57.29,85.061 57.698,85.186 57.698,85.238 C57.698,85.335 54.291,87.823 54.161,87.823 C54.12,87.823 53.959,87.696 53.797,87.539 L53.811,87.538 Z M134.704,87.538 L134.35,87.282 L135.548,86.046 C136.208,85.366 136.801,84.81 136.882,84.81 C137.272,84.81 138.632,85.162 138.605,85.254 C138.565,85.348 135.602,87.498 135.225,87.71 C135.131,87.757 134.902,87.68 134.714,87.54 L134.704,87.538 Z M240.408,87.54 L240.024,87.279 L241.268,86.044 C241.951,85.365 242.568,84.81 242.638,84.81 C242.961,84.81 244.388,85.155 244.375,85.228 C244.375,85.306 241.372,87.492 240.981,87.704 C240.887,87.756 240.631,87.684 240.425,87.54 L240.408,87.54 Z M13.477,86.803 C12.831,86.221 13.461,85.097 14.433,85.097 C15.112,85.097 15.464,85.417 15.464,86.031 C15.464,86.989 14.218,87.474 13.476,86.802 L13.477,86.803 Z M120.786,86.905 C120.395,86.639 120.418,85.752 120.826,85.383 C121.48,84.795 122.733,85.187 122.733,85.984 C122.733,86.847 121.534,87.421 120.794,86.925 L120.786,86.905 Z M226.392,86.803 C225.732,86.21 226.379,85.097 227.375,85.097 C228.372,85.097 228.803,86.103 228.075,86.754 C227.581,87.195 226.837,87.218 226.379,86.803 L226.392,86.803 Z M87.072,82.701 C86.829,82.458 86.883,82.37 88.033,81.242 L89.259,80.039 L90.168,80.235 C90.666,80.34 91.07,80.458 91.07,80.51 C91.056,80.654 87.865,82.862 87.582,82.927 C87.434,82.966 87.205,82.875 87.071,82.731 L87.072,82.701 Z\"/><path fill=\"#E2011C\" fillRule=\"nonzero\" d=\"M139.945,74.613 L140.317,73.749 C140.946,72.185 141.596,70.992 142.539,69.681 C143.535,68.294 145.73,66.25 146.642,65.86 C147.764,65.379 149.157,65.21 150.884,65.345 C152.365,65.463 156.912,66.133 158.748,66.508 C160.146,66.79 162.193,66.817 163.155,66.564 C165.01,66.078 166.108,65.136 166.63,63.588 C167.031,62.399 167.027,61.314 166.62,60.596 C166.073,59.629 166.166,59.368 167.495,58.231 C168.15,57.656 168.687,57.133 168.689,57.068 C168.689,56.676 167.773,55.147 167.544,55.134 C167.396,55.134 167.113,55.004 166.911,54.86 L166.534,54.586 L167.315,54.22 C168.392,53.723 169.187,52.952 169.187,52.404 C169.187,52.169 168.958,51.594 168.675,51.123 C168.136,50.222 168.002,49.359 168.298,48.85 C168.487,48.51 168.837,48.367 170.507,47.935 C171.907,47.57 172.419,47.191 172.621,46.381 C172.782,45.688 172.701,45.244 172.244,44.408 C171.867,43.728 169.416,40.344 168.716,39.521 C167.759,38.411 166.588,36.49 166.251,35.458 C165.915,34.439 165.901,34.374 166.157,33.067 C166.682,30.284 166.372,27.985 164.878,23.503 C163.76,20.158 163.464,19.348 163.127,18.721 C162.494,17.598 162.575,17.336 163.868,16.383 C164.487,15.925 165.053,15.442 165.134,15.298 C165.524,14.645 165.08,13.378 164.352,13.025 C163.881,12.803 163.625,12.959 163.154,13.743 C162.79,14.344 162.642,14.449 162.265,14.449 C161.632,14.449 161.525,14.214 161.929,13.73 C162.386,13.155 162.36,13.064 161.619,12.973 C161.067,12.907 160.784,12.711 159.585,11.614 C157.996,10.15 156.919,9.471 154.792,8.583 C153.809,8.178 153.472,7.982 153.822,8.034 C154.132,8.073 155.209,8.151 156.232,8.204 C159.182,8.334 159.895,7.982 160.582,6.061 C161.04,4.754 161.269,4.153 160.191,3.239 L156.342,-7.99360578e-15 L244.555,-7.99360578e-15 L244.555,74.613 L139.945,74.613 Z\"/><path fill=\"#003189\" fillRule=\"nonzero\" d=\"M64.722,74.613 C64.083,74.553 63.528,74.527 63.489,74.492 C63.421,74.432 64.144,74.012 67.456,72.192 C68.52,71.607 68.956,71.294 68.808,71.222 C68.687,71.164 67.761,71.073 66.747,71.018 C65.242,70.939 64.806,70.96 64.364,71.145 C64.064,71.269 62.872,71.594 61.714,71.868 C58.832,72.548 57.876,73.016 56.947,74.168 L56.597,74.612 L1.42108547e-14,74.612 L1.42108547e-14,1.42108547e-14 L112.315,1.42108547e-14 L110.139,1.28 C108.941,1.819 107.548,2.565 107.045,2.94 C105.337,4.207 103.036,7.097 102.462,8.695 C102.247,9.283 102.058,9.501 101.412,9.901 C100.415,10.51 99.23,11.67 98.247,12.992 C96.914,14.787 94.369,18.832 93.871,19.953 C93.09,21.694 93.157,23.02 94.302,28.221 C94.8,30.511 95.339,32.838 95.5,33.391 C95.662,33.944 95.837,34.881 95.891,35.472 C95.985,36.531 96.066,36.759 96.753,38.057 C96.941,38.412 97.089,38.814 97.089,38.953 C97.089,39.091 97.224,39.284 97.399,39.384 C97.574,39.485 97.709,39.717 97.709,39.924 C97.709,40.172 98.072,40.645 98.867,41.433 C100.685,43.236 102.004,44.926 102.004,45.461 C102.004,45.9 101.291,46.013 100.133,45.757 C98.786,45.457 97.682,44.811 95.985,43.322 C95.258,42.688 94.679,42.264 94.679,42.376 C94.679,42.488 95.164,43.078 95.77,43.683 C96.968,44.892 97.157,45.222 96.901,45.659 C96.672,46.044 96.12,46.156 95.366,45.969 C94.49,45.753 93.992,46.054 94.127,46.697 C94.208,47.102 94.194,47.141 93.938,47.063 C93.453,46.893 92.376,46.697 91.42,46.605 C90.531,46.527 90.464,46.488 89.939,45.821 C89.535,45.312 89.212,45.077 88.794,44.972 C87.219,44.58 83.287,43.953 80.661,43.692 C76.028,43.234 74.911,43.195 72.689,43.444 C70.979,43.626 70.521,43.744 69.591,44.162 C67.935,44.92 65.067,47.298 65.067,47.912 C65.067,48.199 65.7,48.108 66.413,47.703 C68.595,46.488 76.096,44.763 79.772,44.632 C81.59,44.567 81.496,44.646 79.301,45.011 C78.695,45.116 77.267,45.443 76.123,45.756 C74.978,46.07 72.998,46.54 71.733,46.814 C70.467,47.076 69.026,47.428 68.555,47.585 L67.679,47.873 L69.026,47.886 C75.921,47.951 83.758,49.924 86.963,52.393 C88.336,53.452 88.835,53.582 90.854,53.386 C93.628,53.125 97.21,51.936 98.786,50.747 C99.473,50.237 100.025,50.028 100.025,50.303 C100.025,50.773 99.123,53.295 98.732,53.909 C98.301,54.614 98.274,54.706 98.503,54.863 C98.638,54.954 98.719,55.072 98.665,55.111 C98.611,55.163 98.719,55.242 98.907,55.294 C99.432,55.451 99.298,55.594 98.463,55.777 C97.655,55.973 97.008,56.3 97.008,56.522 C97.008,56.6 97.116,56.666 97.251,56.666 C97.655,56.666 97.359,56.927 96.82,57.031 C96.456,57.11 96.389,57.162 96.551,57.254 C96.712,57.345 96.712,57.384 96.564,57.384 C96.456,57.384 96.362,57.528 96.362,57.711 C96.362,57.933 96.201,58.09 95.877,58.22 C95.083,58.547 95.298,58.717 96.389,58.625 C96.941,58.573 97.278,58.573 97.143,58.625 C97.008,58.665 96.12,59.03 95.164,59.435 C94.194,59.827 92.942,60.311 92.376,60.494 C91.73,60.703 91.326,60.912 91.285,61.069 C91.232,61.317 91.528,61.304 92.282,61.043 C92.417,60.99 92.538,61.016 92.538,61.082 C92.538,61.147 90.881,62.009 88.861,63.002 C86.828,63.995 84.943,65.014 84.647,65.263 C84.35,65.524 83.987,65.733 83.812,65.733 C83.65,65.733 83.435,65.903 83.327,66.099 C83.017,66.713 83.071,66.791 83.812,66.674 C84.902,66.504 85.414,66.517 85.482,66.713 C85.522,66.817 85.414,66.896 85.239,66.896 C85.064,66.896 84.849,66.987 84.741,67.105 C84.633,67.222 84.364,67.34 84.135,67.353 C83.852,67.392 83.596,67.562 83.381,67.863 C83.206,68.111 82.923,68.359 82.748,68.398 C82.573,68.437 82.819,68.451 83.3,68.411 C90.45,67.928 91.784,67.693 94.669,66.373 C97.128,65.25 99.679,63.564 101.497,61.892 C102.022,61.408 102.507,61.016 102.574,61.016 C102.82,61.016 102.686,61.813 102.322,62.532 C101.689,63.76 100.37,64.884 97.649,66.517 C96.276,67.34 94.795,68.124 94.356,68.242 C92.99,68.633 90.787,69.052 88.471,69.352 C80.939,70.332 78.056,71.077 75.201,72.788 C74.719,73.063 74.218,73.285 74.084,73.259 C73.949,73.246 73.586,73.415 73.289,73.651 C72.441,74.317 73.06,74.199 74.838,73.376 C76.669,72.527 77.518,72.396 76.077,73.18 C74.313,74.147 72.98,74.761 71.66,74.761 L64.637,74.761 L64.722,74.613 Z M55.366,66.291 C55.651,66.182 56.597,65.703 57.467,65.227 C58.338,64.752 59.371,64.229 59.765,64.068 L62.23,63.062 C64.245,62.242 67.205,61.468 67.374,61.717 C67.434,61.803 67.876,61.9 68.357,61.93 C69.026,61.971 69.143,62.011 68.868,62.099 C68.672,62.161 68.51,62.269 68.51,62.34 C68.51,62.599 69.998,62.523 70.624,62.229 C70.871,62.111 71.426,61.981 71.858,61.928 C72.396,61.863 72.644,61.772 72.644,61.628 C72.644,61.458 72.782,61.432 73.266,61.51 C73.936,61.615 75.372,61.367 79.245,60.491 C81.865,59.89 83.851,59.289 85.595,58.571 L86.856,58.048 L85.595,57.316 C82.518,55.539 79.427,54.442 72.644,52.73 C71.463,52.43 70.426,52.116 70.339,52.038 C70.234,51.946 70.285,51.933 70.498,51.999 C71.175,52.221 71.691,52.26 71.691,52.09 C71.691,51.881 71.179,51.711 70.172,51.581 C69.252,51.463 68.658,51.215 69.297,51.215 C70.286,51.215 68.586,50.64 66.681,50.352 C65.272,50.13 65.191,50.078 65.779,49.817 C66.291,49.595 66.843,48.745 66.699,48.406 C66.578,48.105 66.43,48.144 65.662,48.602 C64.194,49.49 62.848,50.953 62.094,52.469 C61.663,53.331 61.811,53.41 64.033,53.501 C65.864,53.567 65.891,53.58 65.42,53.802 C65.164,53.919 64.639,54.076 64.248,54.128 C62.955,54.298 62.242,54.86 62.888,55.187 C63.077,55.278 63.009,55.356 62.538,55.566 C62.013,55.788 61.986,55.84 62.228,55.957 C62.39,56.036 62.848,56.075 63.252,56.049 C63.817,56.01 64.073,56.062 64.342,56.271 L64.693,56.545 L63.992,56.937 C63.602,57.159 63.171,57.551 63.023,57.8 C62.875,58.048 62.349,58.649 61.838,59.132 C61.339,59.616 60.922,60.112 60.922,60.243 C60.922,60.361 60.195,61.001 59.293,61.641 C56.882,63.379 55.832,64.345 55.832,64.803 C55.832,65.051 55.697,65.26 55.455,65.404 C54.701,65.848 54.31,66.357 54.728,66.357 C54.822,66.357 55.132,66.266 55.414,66.162 L55.366,66.291 Z M73.967,64.613 C74.562,64.541 75.253,64.374 75.499,64.242 L75.949,64.002 L75.181,63.657 L74.414,63.312 L73.649,63.592 C73.227,63.746 72.644,63.953 72.353,64.053 C71.179,64.454 71.027,64.993 72.127,64.845 C72.545,64.792 73.37,64.688 73.967,64.622 L73.967,64.613 Z M47.044,58.083 C50.585,54.738 53.087,52.753 56.261,50.767 C57.339,50.092 58.101,49.702 58.139,49.804 C58.241,50.078 58.704,50 59.456,49.569 C60.32,49.072 60.752,48.563 61.831,46.812 C62.949,44.983 63.374,44.525 64.989,43.402 C65.756,42.879 66.861,42.082 67.467,41.651 C68.342,41.024 68.544,40.919 68.477,41.168 C68.423,41.35 68.463,41.481 68.584,41.481 C68.948,41.481 70.658,40.815 70.712,40.658 C70.806,40.397 69.082,40.135 67.803,40.214 C66.82,40.266 66.484,40.371 65.258,40.958 C62.403,42.356 60.33,43.859 56.384,47.426 C53.543,49.987 51.307,51.711 50.176,52.195 C49.812,52.351 49.53,52.691 49.085,53.501 C48.223,55.095 46.661,57.382 45.194,59.237 C43.807,60.988 43.51,61.589 44.588,60.465 C44.965,60.086 46.082,58.989 47.092,58.035 L47.044,58.083 Z M82.503,73.793 C86.758,72.993 87.229,72.933 90.215,72.816 C93.568,72.682 93.996,72.604 96.013,71.748 C96.66,71.474 96.913,71.424 97.003,71.552 C97.159,71.778 98.255,71.239 100.393,69.886 C101.268,69.334 102.503,68.626 103.14,68.315 C104.1,67.844 104.392,67.766 104.855,67.844 C105.348,67.927 105.495,67.87 106.124,67.374 C107.869,65.985 109.358,65.344 110.846,65.34 C111.291,65.34 111.443,65.397 111.443,65.568 C111.443,65.715 111.53,65.77 111.681,65.717 C112.126,65.561 111.924,65.73 110.784,66.501 C109.465,67.377 109.586,67.651 110.932,66.802 C112.225,65.992 112.239,65.992 112.239,66.318 C112.239,66.501 111.74,66.972 110.838,67.651 C108.778,69.193 107.324,70.473 105.115,72.681 L103.136,74.602 L77.969,74.602 L82.5,73.779 L82.503,73.793 Z M82.543,44.563 C82.697,44.527 82.947,44.527 83.099,44.563 C83.253,44.6 83.126,44.629 82.822,44.629 C82.515,44.629 82.391,44.6 82.543,44.563 Z\"/><path fill=\"#7F7F7F\" fillRule=\"nonzero\" d=\"M156.719,39.105 C156.024,39.008 156.382,38.775 157.224,38.775 C158.067,38.775 158.511,38.483 158.511,37.929 C158.511,37.667 158.457,37.626 158.188,37.703 C157.959,37.768 157.797,37.713 157.636,37.516 C157.501,37.333 157.151,37.202 156.639,37.141 C155.737,37.032 154.269,36.242 154.525,36.004 C154.619,35.923 155.091,35.826 155.562,35.791 C156.397,35.728 156.451,35.7 156.612,35.24 C156.747,34.896 156.922,34.73 157.218,34.665 C157.461,34.615 157.824,34.426 158.04,34.243 C158.269,34.06 158.982,33.59 159.629,33.211 C160.733,32.557 161.42,31.865 161.177,31.643 C160.921,31.408 158.538,31.159 157.407,31.238 C156.1,31.329 153.852,31.761 152.922,32.087 C151.966,32.414 152.236,32.126 153.569,31.421 C155.36,30.454 158.807,29.566 160.706,29.566 C161.447,29.566 163.924,30.088 164.221,30.31 C164.8,30.741 164.167,32.257 163.103,33.015 L162.658,33.328 L163.386,34.034 L163.009,34.308 C162.807,34.452 162.524,34.583 162.389,34.583 C162.254,34.583 161.904,34.779 161.622,35.027 C161.191,35.406 161.137,35.523 161.258,35.824 C161.352,36.046 161.352,36.268 161.231,36.464 C161.123,36.647 161.096,37.052 161.164,37.522 C161.298,38.476 161.11,38.816 160.329,39.012 C159.763,39.155 157.663,39.182 156.747,39.051 L156.719,39.105 Z\"/></g></g></g></svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/pulsar.tsx",
    "content": "export default function PulsarLogo(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 45 60\" {...props}>\n      <path fill=\"currentColor\" fillRule=\"evenodd\" d=\"M22.3991 14.9172C26.5214 14.9172 29.8627 18.2585 29.8627 22.3808C29.8627 26.5021 26.5214 29.8444 22.3991 29.8444C21.0388 29.8444 19.765 29.4795 18.6673 28.8431V26.5444C19.6578 27.4336 20.964 27.9785 22.3991 27.9785C25.4906 27.9785 27.9968 25.4723 27.9968 22.3808C27.9968 19.2883 25.4906 16.7821 22.3991 16.7821C19.3076 16.7821 16.8014 19.2883 16.8014 22.3808V44.7795H14.9355V22.3808C14.9355 18.2575 18.2768 14.9172 22.3991 14.9172ZM22.3991 11.1855C28.582 11.1855 33.5945 16.1979 33.5945 22.3809C33.5945 28.5639 28.582 33.5763 22.3991 33.5763C21.0899 33.5763 19.8348 33.3491 18.6673 32.936V30.9314C19.8102 31.4311 21.0722 31.7104 22.3991 31.7104C27.5522 31.7104 31.7286 27.533 31.7286 22.3809C31.7286 17.2288 27.5522 13.0504 22.3991 13.0504C17.247 13.0504 13.0696 17.2288 13.0696 22.3809V48.5025H11.2037V22.3809C11.2037 16.1979 16.2161 11.1855 22.3991 11.1855ZM22.3991 7.45182C30.6427 7.45182 37.3263 14.1354 37.3263 22.381C37.3263 30.6266 30.6427 37.3092 22.3991 37.3092C21.1106 37.3092 19.8594 37.1459 18.6673 36.839V34.8984C19.8506 35.2515 21.1017 35.4433 22.3991 35.4433C29.6129 35.4433 35.4614 29.5948 35.4614 22.381C35.4614 15.1672 29.6129 9.31772 22.3991 9.31772C15.1853 9.31772 9.33679 15.1672 9.33679 22.381H9.33777V52.2335H7.47187V22.381H7.47285C7.47285 14.1354 14.1555 7.45182 22.3991 7.45182ZM22.39 3.73162C32.6953 3.73162 41.0481 12.0854 41.0481 22.3906C41.0481 32.6949 32.6953 41.0477 22.39 41.0477C21.1153 41.0477 19.87 40.9198 18.6671 40.6769V38.7588C19.8651 39.0303 21.1104 39.1779 22.39 39.1779C31.6605 39.1779 39.1753 31.6621 39.1753 22.3906C39.1753 13.1182 31.6605 5.60146 22.39 5.60146C13.1225 5.60146 5.60971 13.1133 5.60479 22.3808H5.60676V55.9651H3.732V22.3808C3.73692 12.0805 12.0877 3.73162 22.39 3.73162ZM22.39 -0.000183105C34.7559 -0.000183105 44.7799 10.0238 44.7799 22.3906C44.7799 34.7556 34.7559 44.7795 22.39 44.7795C21.1212 44.7795 19.8789 44.6693 18.6671 44.4667V42.5654C19.875 42.7867 21.1182 42.9087 22.39 42.9087C33.7222 42.9087 42.9081 33.7218 42.9081 22.3906C42.9081 11.0575 33.7222 1.87064 22.39 1.87064C11.0609 1.87064 1.87692 11.0526 1.87102 22.3808H1.87495V59.6969H0.000198364V22.3808C0.0051164 10.0198 10.0271 -0.000183105 22.39 -0.000183105Z\" clipRule=\"evenodd\"/>\n      <path fill=\"#FF4713\" fillRule=\"evenodd\" d=\"M26.1314 22.3807C26.1314 24.4424 24.4602 26.1125 22.3996 26.1125C20.3379 26.1125 18.6678 24.4424 18.6678 22.3807C18.6678 20.3191 20.3379 18.6489 22.3996 18.6489C24.4602 18.6489 26.1314 20.3191 26.1314 22.3807Z\" clipRule=\"evenodd\"/>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/career/icons/slate-digital.tsx",
    "content": "export default function SlateLogo(props: React.ComponentProps<'svg'>) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" viewBox=\"0 0 148.5 200\" {...props}>\n      <path d=\"M141.696,21.74l-15.854-4.244c-17.048-4.512-30.68-7.729-51.178-7.729c-20.53,0-34.162,3.217-51.177,7.729L6.804,21.939\tv156.086l16.684,4.445c17.048,4.543,30.646,7.762,51.21,7.762c20.498,0,34.163-3.219,51.178-7.762l15.821-4.18 M74.896,150.299\tc-3.051,0-5.506-2.455-5.506-5.506l-0.1-26.768c0-2.686,0.996-3.648,4.81-4.809L31.58,94.443c2.223,1.229,8.358,5.871,8.358,10.283\tv48.025c0,5.273-10.614,8.027-10.614,8.027c13.897,3.715,24.743,6.301,39.636,6.898v11.244c-16.054-0.531-27.562-3.316-42.521-7.297\tl-8.358-2.189V30.598l8.358-2.223c14.958-3.98,26.467-6.734,42.521-7.297v11.277c-14.926,0.562-25.738,3.184-39.636,6.898\tc0,0,10.614,2.752,10.614,8.025v38.906c0,3.051,1.956,4.08,4.644,5.307l31.211,14.494c3.88,1.99,4.643,1.957,4.643,5.273v33.5\tc0,3.051-2.487,5.506-5.506,5.506 M131.314,169.402l-8.357,2.223c-15.092,3.98-26.7,6.766-42.953,7.297v-11.277\tc15.125-0.498,26.037-3.15,40.066-6.898c0,0-10.613-2.754-10.613-8.027v-50.912c0-3.051-1.957-4.045-4.643-5.24L73.57,82.039\tc-3.881-2.057-4.61-1.99-4.61-5.307V55.24c0-3.041,2.465-5.506,5.506-5.506c3.04,0,5.505,2.465,5.505,5.506l0.1,15.721\tc0,2.654-1.027,3.615-4.809,4.811l42.554,18.705c-2.222-1.193-8.358-5.836-8.358-10.281V47.279c0-5.307,10.613-8.025,10.613-8.025\tc-14.029-3.781-24.941-6.436-40.066-6.898V21.078c16.253,0.529,27.861,3.283,42.953,7.297l8.357,2.223V169.402z M141.696,21.74\tv156.518\"/>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/featured-posts.tsx",
    "content": "import Link from 'next/link'\nimport { BlogPostEmbed } from '../../../ui/embeds/blog-post-embed'\n\nconst featuredPosts = [\n  ['2023', 'storing-react-state-in-the-url-with-nextjs'],\n  ['2021', 'hashvatars'],\n  ['2020', 'password-reset-for-e2ee-apps']\n]\n\nexport const FeaturedPosts: React.FC = () => {\n  return (\n    <section>\n      {featuredPosts.map(slug => (\n        <BlogPostEmbed key={slug.join('/')} slug={slug} className=\"my-4\" />\n      ))}\n      <p className=\"text-center\">\n        <Link href=\"/posts\">All posts</Link>\n      </p>\n    </section>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/_landing-sections/music.tsx",
    "content": "import { SpotifyAlbum, SpotifyAlbumGrid } from 'ui/embeds/spotify-album'\nimport { SpotifyArtist, SpotifyArtistGrid } from 'ui/embeds/spotify-artist'\n\nexport const FavouriteArtists = () => (\n  <SpotifyArtistGrid>\n    <SpotifyArtist\n      url=\"https://open.spotify.com/artist/2SRIVGDkdqQnrQdaXxDkJt\"\n      aria-label=\"Haken\"\n    />\n    <SpotifyArtist\n      url=\"https://open.spotify.com/artist/6uejjWIOshliv2Ho0OJAQN\"\n      aria-label=\"Devin Townsend\"\n    />\n    <SpotifyArtist\n      url=\"https://open.spotify.com/artist/1l2oLiukA9i5jEtIyNWIEP\"\n      aria-label=\"Carpenter Brut\"\n    />\n    <SpotifyArtist\n      url=\"https://open.spotify.com/artist/43mWhBXSflupNLuNjM5vff\"\n      aria-label=\"Soulwax\"\n    />\n  </SpotifyArtistGrid>\n)\n\nexport const FavouriteAlbums = () => (\n  <SpotifyAlbumGrid>\n    <SpotifyAlbum\n      aria-label=\"The Further Side - Nova Collective\"\n      url=\"https://open.spotify.com/album/2opFAZPTe5dgHgNnDO2Ak4\"\n    />\n    <SpotifyAlbum\n      aria-label=\"Igneous - Polynation\"\n      url=\"https://open.spotify.com/album/5kU3Q43bmLdARkMOCOLNkB\"\n    />\n    <SpotifyAlbum\n      aria-label=\"Fauna - Haken\"\n      url=\"https://open.spotify.com/album/1KOHbC0QWnvLUKT5GS4JtE\"\n      title=\"Fauna\"\n    />\n    <SpotifyAlbum\n      aria-label=\"Kid Velo - Rival Consoles\"\n      url=\"https://open.spotify.com/album/6nj966rHe5ui3JKwqh8a32\"\n    />\n    <SpotifyAlbum\n      aria-label=\"Epicloud - Devin Townsend Project\"\n      url=\"https://open.spotify.com/album/4WA0COIl14e6amUlwz89pN\"\n    />\n    <SpotifyAlbum\n      aria-label=\"Them Crooked Vultures\"\n      url=\"https://open.spotify.com/album/0Z6IBizcq7DLXpenjSHqF3\"\n    />\n    <SpotifyAlbum\n      aria-label=\"TRILOGY - Carpenter Brut\"\n      url=\"https://open.spotify.com/album/0io5pe55YRCTVqEjwlOBdN\"\n    />\n    <SpotifyAlbum\n      aria-label=\"Much Against Everyone's Advice - Soulwax\"\n      url=\"https://open.spotify.com/album/1Zisq1gECqPQtxvny6AUXP\"\n    />\n    <SpotifyAlbum\n      aria-label=\"Superunknown - Soundgarden\"\n      url=\"https://open.spotify.com/album/4K8bxkPDa5HENw0TK7WxJh\"\n      title=\"Superunknown\"\n    />\n  </SpotifyAlbumGrid>\n)\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/business-card/page.tsx",
    "content": "import { decryptPhoneNumber, vcard } from 'app/vcard/vcard'\nimport { cookies } from 'next/headers'\nimport { SearchParams } from 'nuqs/server'\nimport { QRCode } from './qrcode'\n\nexport const metadata = {\n  title: 'Business card',\n  description: 'Why cut trees when you can send a link?'\n}\n\nexport const dynamic = 'force-dynamic' // SSR\n\ntype PageProps = {\n  searchParams: Promise<SearchParams>\n}\n\nexport default async function BusinessCardPage({ searchParams }: PageProps) {\n  const { loadKey } = await searchParams\n  let phoneNumber: string | undefined = undefined\n  const cookieStore = await cookies()\n  const key = cookieStore.get('phoneNumberKey')?.value\n  try {\n    phoneNumber = decryptPhoneNumber(key ?? '')\n  } catch (err) {\n    console.error(err)\n  }\n  const showLoadKey =\n    !key &&\n    Boolean(loadKey) &&\n    Boolean(process.env.ENCRYPTED_PHONE_NUMBER) &&\n    loadKey === process.env.ENCRYPTED_PHONE_NUMBER\n  return (\n    <>\n      <QRCode text={vcard(phoneNumber)} className=\"mx-auto max-w-xs\">\n        <a href=\"/vcard\" download=\"francois.best.vcf\">\n          Add contact\n        </a>\n      </QRCode>\n      {showLoadKey && <LoadKey />}\n    </>\n  )\n}\n\n// --\n\nasync function loadKey(form: FormData) {\n  'use server'\n  const cookiesStore = await cookies()\n  cookiesStore.set('phoneNumberKey', form.get('key') as string, {\n    httpOnly: true,\n    secure: true,\n    expires: new Date('2100-01-01'),\n    sameSite: 'strict'\n  })\n}\n\nfunction LoadKey() {\n  return (\n    <form action={loadKey}>\n      <input type=\"text\" name=\"key\" placeholder=\"Key\" />\n      <button type=\"submit\">Send</button>\n    </form>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/business-card/qrcode.tsx",
    "content": "import qr from 'qrcode'\nimport { twMerge } from 'tailwind-merge'\n\nexport type QRCodeProps = React.ComponentProps<'figure'> & {\n  text: string\n  children: React.ReactNode\n}\n\nexport const QRCode: React.FC<QRCodeProps> = async ({\n  text,\n  className,\n  children,\n  ...props\n}) => {\n  const svg = await qr.toString(text, {\n    type: 'svg',\n    errorCorrectionLevel: 'M'\n  })\n  return (\n    <figure\n      aria-label=\"QR code with contact information\"\n      className={twMerge('m-8', className)}\n      {...props}\n    >\n      <picture\n        dangerouslySetInnerHTML={{ __html: svg.trim() }}\n        className=\"block rounded-lg bg-white p-4\"\n      />\n      <figcaption className=\"text-center\">{children}</figcaption>\n    </figure>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/layout.tsx",
    "content": "import { Footer } from 'ui/layouts/footer'\nimport { NavHeader } from 'ui/layouts/nav-header'\n\nexport default function PageLayout({\n  children\n}: {\n  children: React.ReactNode\n}) {\n  return (\n    <>\n      <NavHeader />\n      <main className=\"prose dark:prose-invert md:prose-lg prose-h1:font-bold prose-img:rounded-sm prose-li:my-1 mx-auto px-2\">\n        {children}\n      </main>\n      <Footer />\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/links/page.tsx",
    "content": "import { HireMe } from 'ui/components/hire-me'\n\nexport default function Page() {\n  return (\n    <>\n      <h1>Links</h1>\n      <ul>\n        <li>\n          <a href=\"https://bsky.app/profile/francoisbest.com\">Bluesky</a>\n        </li>\n        <li>\n          <a href=\"https://x.com/nuqs47ng\">Twitter</a>\n        </li>\n        <li>\n          <a href=\"https://www.linkedin.com/in/francoisbest/\">LinkedIn</a>\n        </li>\n        <li>\n          <a href=\"https://www.youtube.com/@47ng-dev\">YouTube</a>\n        </li>\n        <li>\n          <a href=\"https://www.twitch.tv/francoisbest\">Twitch</a>\n        </li>\n      </ul>\n\n      <HireMe outerClass=\"mt-12\" />\n\n      <nav role=\"list\" className=\"!mt-12 flex flex-col items-center text-center text-sm\">\n        <a role=\"listitem\" href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/app/(pages)/links/page.tsx\" className=\"!text-gray-500\">\n          Edit this page on GitHub\n        </a>\n      </nav>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/music/page.tsx",
    "content": "import { Metadata } from 'next'\nimport { FiVolume2 } from 'react-icons/fi'\nimport { HireMe } from 'ui/components/hire-me'\nimport { Note } from 'ui/components/note'\nimport { SpotifyAlbum, SpotifyAlbumGrid } from 'ui/embeds/spotify-album'\n\nexport const metadata: Metadata = {\n  title: 'Music',\n  description: 'A few albums I like listening to, for work and relaxing.'\n}\n\nexport default function MusicPage() {\n  return (\n    <>\n      <h1>Music</h1>\n\n      <Note status=\"success\" icon={FiVolume2}>\n        Check out my{' '}\n        <a href=\"https://open.spotify.com/playlist/09JVRjAbg8ETlHWznfVOQf?si=F9Vksg9aQp2D7q5MbecMqw\">\n          Spotify playlist\n        </a>{' '}\n        for web development.\n      </Note>\n\n      <h3>Progressive Rock / Metal</h3>\n\n      <SpotifyAlbumGrid>\n        <SpotifyAlbum\n          aria-label=\"The Mountain - Haken\"\n          url=\"https://open.spotify.com/album/3RBULTZJ97bvVzZLpxcB0j\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Vector - Haken\"\n          url=\"https://open.spotify.com/album/1PhYHO7Pva9e1YQY5GQ8zx\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Fauna - Haken\"\n          url=\"https://open.spotify.com/album/1KOHbC0QWnvLUKT5GS4JtE\"\n          title=\"Fauna\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Epicloud - Devin Townsend Project\"\n          url=\"https://open.spotify.com/album/4WA0COIl14e6amUlwz89pN\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Casualties of Cool - Devin Townsend\"\n          url=\"https://open.spotify.com/album/37cVlKDwp4lcIqb3Bwv4et\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Empath - Devin Townsend\"\n          url=\"https://open.spotify.com/album/7MPJRyMFbWbgezRP2Pj4TZ\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Ziltoid Live - Devin Townsend Project\"\n          url=\"https://open.spotify.com/album/6gwHddBxh92zNI459I2PPD\"\n          title=\"Ziltoid Live\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Much Against Everyone's Advice - Soulwax\"\n          url=\"https://open.spotify.com/album/1Zisq1gECqPQtxvny6AUXP\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Any Minute Now - Soulwax\"\n          url=\"https://open.spotify.com/album/6oAU1ajSCbepPdsQVUfsbj\"\n        />\n        <SpotifyAlbum\n          aria-label=\"The Further Side - Nova Collective\"\n          url=\"https://open.spotify.com/album/2opFAZPTe5dgHgNnDO2Ak4\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Superunknown - Soundgarden\"\n          url=\"https://open.spotify.com/album/4K8bxkPDa5HENw0TK7WxJh\"\n          title=\"Superunknown\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Audioslave\"\n          url=\"https://open.spotify.com/album/78guAsers0klWl6RwzgDLd\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Insurgentes - Steven Wilson\"\n          url=\"https://open.spotify.com/album/3psPvfJX0dMn05RK7fqcIL\"\n          title=\"Insurgentes\"\n        />\n        <SpotifyAlbum\n          url=\"https://open.spotify.com/album/2xJFvV7JzoYYMere5rqjVf\"\n          title=\"The Raven That Refused to Sing\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Hand Cannot Erase\"\n          url=\"https://open.spotify.com/album/6P7vL4vGgyrD7q9VR9BcnV\"\n        />\n      </SpotifyAlbumGrid>\n\n      <br />\n\n      <h3>Electro / Synthwave</h3>\n\n      <SpotifyAlbumGrid>\n        <SpotifyAlbum\n          aria-label=\"Igneous - Polynation\"\n          url=\"https://open.spotify.com/album/5kU3Q43bmLdARkMOCOLNkB\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Trilogy - Carpenter Brut\"\n          url=\"https://open.spotify.com/album/0io5pe55YRCTVqEjwlOBdN\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Dangerous Days\"\n          url=\"https://open.spotify.com/album/0GzBfwarPFhAdfLNHfgaRT\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Kid Velo - Rival Consoles\"\n          url=\"https://open.spotify.com/album/6nj966rHe5ui3JKwqh8a32\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Nite Versions - Soulwax\"\n          url=\"https://open.spotify.com/album/5ftdUoPzoh1y5bIroXw68G\"\n        />\n      </SpotifyAlbumGrid>\n\n      <br />\n\n      <h3>Extreme Prog</h3>\n\n      <p>It gets wild. You&apos;ve been warned.</p>\n\n      <SpotifyAlbumGrid>\n        <SpotifyAlbum\n          aria-label=\"Rococo Holocaust - Pryapisme\"\n          url=\"https://open.spotify.com/album/2iuftfHy8BAyG4ePAB7xcY\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Hyperblast Super Collider - Pryapisme\"\n          url=\"https://open.spotify.com/album/7Glnx0Uhu8f7QdKRKx6nlN\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Brute Force - The Algorithm\"\n          url=\"https://open.spotify.com/album/3HNzOyPbz5vPvUie7lI97X\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Polymorphic Code - The Algorithm\"\n          url=\"https://open.spotify.com/album/2wPyt8oSnSAXsuYeQMzTzq\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Tea Time for Punks - Morglbl\"\n          url=\"https://open.spotify.com/album/6ceOYvxgsFYpTPgdgYgnie\"\n        />\n      </SpotifyAlbumGrid>\n\n      <br />\n\n      <h3>Calm / Meditation</h3>\n\n      <p>\n        If the previous section was too wild for your taste, check out those\n        albums & artists:\n      </p>\n\n      <SpotifyAlbumGrid>\n        <SpotifyAlbum\n          aria-label=\"Passage - Ulrich Schnauss\"\n          url=\"https://open.spotify.com/album/4Aumawi2PZuCxo10dQc3vn\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Contact Note - Jon Hopkins\"\n          url=\"https://open.spotify.com/album/582EKMkWdR5wmroAm9NqfE\"\n        />\n        <SpotifyAlbum\n          aria-label=\"Immunity - Jon Hopkins\"\n          url=\"https://open.spotify.com/album/1rxWlYQcH945S3jpIMYR35\"\n        />\n      </SpotifyAlbumGrid>\n\n      <HireMe outerClass=\"mt-12\" />\n\n      <nav role=\"list\" className=\"!mt-12 flex flex-col items-center text-center text-sm\">\n        <a role=\"listitem\" href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/app/(pages)/music/page.tsx\" className=\"!text-gray-500\">\n          Edit this page on GitHub\n        </a>\n      </nav>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/open-source/page.tsx",
    "content": "import { fetchAllNpmPackages, type NpmPackageStatsData } from 'lib/services/npm'\nimport { Metadata } from 'next'\nimport { HireMe } from 'ui/components/hire-me'\nimport { BlogPostEmbed } from 'ui/embeds/blog-post-embed'\nimport { GitHubRepo } from 'ui/embeds/github-repo'\nimport { NpmPackage } from 'ui/embeds/npm-package'\n\nexport const metadata: Metadata = {\n  title: 'Open-Source',\n  description:\n    'Some of the OSS packages and projects I published and contribute to.',\n}\n\nexport const revalidate = 86400\n\nconst npmPackages = [\n  'nuqs',\n  'fastify-micro',\n  'fastify-cron',\n  'session-keystore',\n  '@47ng/cloak',\n  'redact-env',\n  'env-alias',\n  '@47ng/check-env',\n  '@47ng/codec',\n] as const\n\nexport default async function OpenSourcePage() {\n  const npmData = await fetchAllNpmPackages([...npmPackages]).catch((error) => {\n    console.error('Failed to batch-fetch NPM data:', error)\n    return {} as Record<string, NpmPackageStatsData>\n  })\n  return (\n    <>\n      <h1>Open Source</h1>\n      <p>\n        This page lists some of the open source packages and repositories I have\n        published or contribute to.\n      </p>\n      <p>\n        Most of them are published under my company{' '}\n        <a href=\"https://github.com/47ng\">47ng</a>.\n      </p>\n\n      <h2>Frontend</h2>\n      <p>\n        My tech stack of choice for server-rendered React apps is{' '}\n        <a href=\"https://nextjs.org/\">Next.js</a> and{' '}\n        <a href=\"https://tailwindcss.com/\">TailwindCSS</a>.\n      </p>\n      <p>\n        For client-heavy apps, I like to use{' '}\n        <a href=\"https://chakra-ui.com/\">Chakra-UI</a> to quickly build\n        beautiful and accessible interfaces.\n      </p>\n      <p>\n        Because of its CSS-in-JS approach, it&apos;s a bit awkward to use with the\n        new Next.js app router and server components, so I&apos;m now letting\n        Tailwind deal with styling. ChatGPT makes a perfect companion for\n        refactoring one into the other anyway.\n      </p>\n\n      <NpmPackage\n        pkg=\"nuqs\"\n        repo=\"47ng/nuqs\"\n        accent=\"text-indigo-500 dark:text-indigo-400\"\n        versionRollout={6}\n        npmData={npmData['nuqs'] ?? null}\n      />\n\n      <h2>Backend</h2>\n      <p>\n        I like to use <a href=\"https://fastify.io\">Fastify</a> to build backend\n        services in Node.js.\n      </p>\n      <p>What I like about it:</p>\n      <ul>\n        <li>More opinionated and structured than Express.js</li>\n        <li>Damn fast</li>\n        <li>Easy to write plugins</li>\n        <li>Good defaults out of the box</li>\n      </ul>\n\n      <NpmPackage\n        pkg=\"fastify-micro\"\n        repo=\"47ng/fastify-micro\"\n        accent=\"text-amber-500\"\n        npmData={npmData['fastify-micro'] ?? null}\n      />\n\n      <NpmPackage\n        pkg=\"fastify-cron\"\n        repo=\"47ng/fastify-cron\"\n        accent=\"text-green-500\"\n        npmData={npmData['fastify-cron'] ?? null}\n      />\n\n      <GitHubRepo slug=\"47ng/actions-clever-cloud\" />\n\n      <h2>Security & Encryption</h2>\n\n      <NpmPackage\n        pkg=\"session-keystore\"\n        repo=\"47ng/session-keystore\"\n        npmData={npmData['session-keystore'] ?? null}\n      />\n\n      <p>\n        I wrote an article about how I came to build{' '}\n        <code>session-keystore</code>:\n      </p>\n\n      <BlogPostEmbed slug={['2019', 'how-to-store-e2ee-keys-in-the-browser']} />\n\n      <NpmPackage\n        pkg=\"@47ng/cloak\"\n        repo=\"47ng/cloak\"\n        npmData={npmData['@47ng/cloak'] ?? null}\n      />\n\n      <GitHubRepo slug=\"SocialGouv/e2esdk\" />\n\n      <h2>Environment Variables</h2>\n      <p>\n        The <a href=\"https://12factor.net/config\">Twelve Factor App</a> model\n        uses environment variables extensively for configuration and passing\n        runtime data to a web app.\n      </p>\n      <p>\n        However, there are things to look out for when working with environment\n        variables, so I built a few packages to make their management easier and\n        more secure:\n      </p>\n\n      <NpmPackage\n        pkg=\"redact-env\"\n        repo=\"47ng/redact-env\"\n        npmData={npmData['redact-env'] ?? null}\n      />\n\n      <NpmPackage\n        pkg=\"env-alias\"\n        repo=\"47ng/env-alias\"\n        npmData={npmData['env-alias'] ?? null}\n      />\n\n      <NpmPackage\n        pkg=\"@47ng/check-env\"\n        repo=\"47ng/check-env\"\n        npmData={npmData['@47ng/check-env'] ?? null}\n      />\n\n      <h2>Miscellaneous</h2>\n\n      <NpmPackage\n        pkg=\"@47ng/codec\"\n        repo=\"47ng/codec\"\n        npmData={npmData['@47ng/codec'] ?? null}\n      />\n\n      <p>\n        My longest-running open-source project is the Arduino MIDI Library. I\n        learned programming in C++ in 2008 with this project and discovered my\n        passion for open-source software.\n      </p>\n\n      <GitHubRepo slug=\"FortySevenEffects/arduino_midi_library\" />\n\n      <p>\n        The source code for this website! Made with{' '}\n        <a href=\"https://nextjs.org\" className=\"underline\">\n          Next.js\n        </a>\n        ,{' '}\n        <a href=\"https://tailwindcss.com\" className=\"underline\">\n          TailwindCSS\n        </a>{' '}\n        and{' '}\n        <a href=\"https://mdxjs.com/\" className=\"underline\">\n          MDX\n        </a>\n        .\n      </p>\n\n      <GitHubRepo slug=\"franky47/francoisbest.com\" />\n\n      <HireMe outerClass=\"mt-12\" />\n\n      <nav role=\"list\" className=\"!mt-12 flex flex-col items-center text-center text-sm\">\n        <a role=\"listitem\" href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/app/(pages)/open-source/page.tsx\" className=\"!text-gray-500\">\n          Edit this page on GitHub\n        </a>\n      </nav>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/page.tsx",
    "content": "import Link from 'next/link'\nimport { FiMap } from 'react-icons/fi'\nimport { Note } from 'ui/components/note'\nimport { HireMe } from 'ui/components/hire-me'\nimport { FavouriteAlbums, FavouriteArtists } from './_landing-sections/music'\nimport { FeaturedPosts } from './_landing-sections/featured-posts'\nimport { AboutMe } from './_landing-sections/about-me'\nimport { Career } from './_landing-sections/career/career'\n\nexport default function HomePage() {\n  return (\n    <>\n      <h1>Hi, I&apos;m François Best</h1>\n      <p>\n        I am a web developer and an{' '}\n        <Link href=\"/open-source\">open sourcerer</Link> from France.\n      </p>\n      <p>\n        This is my digital garden, where I write about the things I&apos;m working on\n        and share what I&apos;ve learned.\n      </p>\n\n      <HireMe />\n\n      <h2>Featured posts</h2>\n      <FeaturedPosts />\n\n      <h2>About Me</h2>\n      <AboutMe />\n\n      <h2>Career</h2>\n      <Career />\n\n      <h2>Music</h2>\n      <p>\n        I like listening to progressive metal, fusion jazz and synthwave when\n        working:\n        <wbr />\n        syncopated beats, meter changes and arpeggios do wonders for web\n        development. 🤘\n      </p>\n      <p>My favourite artists and albums of the moment:</p>\n\n      <FavouriteArtists />\n\n      <br />\n\n      <FavouriteAlbums />\n\n      <p className=\"text-center\">\n        <Link href=\"/music\">More albums</Link>\n      </p>\n\n      <hr />\n\n      <p>\n        This website is{' '}\n        <a href=\"https://github.com/franky47/francoisbest.com\">open-source</a>,\n        and was made with <a href=\"https://nextjs.org\">Next.js</a>,{' '}\n        <a href=\"https://tailwindcss.com\">TailwindCSS</a> and{' '}\n        <a href=\"https://mdxjs.com/\">MDX</a>.\n      </p>\n\n      <Note status=\"info\">\n        I used to style my apps and websites with CSS-in-JS (using{' '}\n        <a href=\"https://chakra-ui.com/\">Chakra-UI</a>), but the introduction of\n        React Server Components in Next.js 13+ helped me switch to a leaner HTML\n        + CSS static render, only shipping client components where interaction is\n        needed, while keeping external content (Spotify albums, GitHub repo stats\n        etc..).\n      </Note>\n\n      <p className=\"!mt-12 text-center text-sm\">\n        <FiMap className=\"-mt-0.5 mr-1.5 inline-block\" />\n        <Link href=\"/sitemap\">Site map</Link>\n      </p>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/[...slug]/page.tsx",
    "content": "import { getAllPosts, getPost } from 'lib/blog'\nimport { computeReadingTime } from 'lib/blog/reading-time'\nimport { getMdxComponents } from 'lib/mdx-components'\nimport { url } from 'lib/paths'\nimport { blogSource } from 'lib/source'\nimport type { Metadata } from 'next'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { HireMe } from 'ui/components/hire-me'\nimport { Logo } from 'ui/components/logo'\nimport { TagsNav } from 'ui/components/tag'\nimport { formatDate } from 'ui/format'\n\ntype PageProps = {\n  params: Promise<{ slug: string[] }>\n}\n\nexport async function generateStaticParams() {\n  const posts = await getAllPosts()\n  return posts.map(post => ({ slug: post.slug }))\n}\n\nexport async function generateMetadata({ params }: PageProps): Promise<Metadata> {\n  const { slug } = await params\n  const page = blogSource.getPage(slug)\n  if (!page) return {}\n  const post = await getPost(slug)\n  return {\n    title: page.data.title,\n    description: page.data.description,\n    ...(post?.ogImageExtension && {\n      openGraph: {\n        images: [{ url: url(`/posts/og/${slug.join('/')}`) }]\n      }\n    }),\n    ...(page.data.canonical && {\n      alternates: { canonical: page.data.canonical }\n    })\n  }\n}\n\nexport default async function BlogPost({ params }: PageProps) {\n  const { slug } = await params\n  const page = blogSource.getPage(slug)\n  if (!page) notFound()\n\n  const { body: Content, title, publicationDate, tags } = page.data\n  const readingTimeText = await computeReadingTime(slug)\n  const slugPath = slug.join('/')\n  const editUrl = `https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/content/blog/${slugPath}/index.mdx`\n  const hnUrl = `https://hn.algolia.com/?q=${encodeURIComponent(url(`/posts/${slugPath}`))}`\n  const separator = <>&nbsp;•&nbsp;</>\n\n  return (\n    <article>\n      {/* Post Header */}\n      <figure role=\"header\" className=\"not-prose\">\n        <h1 className=\"mb-4 mt-8 text-5xl font-bold leading-[1.15] text-gray-950 dark:text-gray-50\">\n          {title}\n        </h1>\n        <figcaption className=\"flex flex-wrap gap-2 text-sm text-gray-500\">\n          François Best{separator}\n          {publicationDate ? (\n            formatDate(publicationDate)\n          ) : (\n            <span className=\"font-semibold italic text-amber-600\">\n              Unpublished\n            </span>\n          )}\n          {separator}{readingTimeText}\n          {tags && Boolean(tags.length) && (\n            <TagsNav tags={tags} className=\"ml-auto\" />\n          )}\n        </figcaption>\n      </figure>\n\n      {/* Post Content */}\n      <Content components={getMdxComponents() as any} />\n\n      {/* Post Footer */}\n      <hr />\n      <div className=\"not-prose flex items-center\">\n        <Logo size={16} />\n        <div className=\"ml-4\">\n          <Link href=\"/\" className=\"text-xl font-bold\">\n            François Best\n          </Link>\n          <p>Freelance developer & founder</p>\n          <nav className=\"mt-1 text-sm font-medium\">\n            <a href=\"https://github.com/47ng\" className=\"underline\">\n              47ng\n            </a>\n            {separator}\n            <a href=\"https://chiffre.io\" className=\"underline\">\n              {' '}\n              Chiffre.io\n            </a>\n          </nav>\n        </div>\n      </div>\n\n      <HireMe outerClass=\"mt-12\" />\n\n      <nav\n        role=\"list\"\n        className=\"!mt-12 flex flex-col items-center gap-4 text-center text-sm sm:block\"\n      >\n        <a role=\"listitem\" href={editUrl} className=\"!text-gray-500\">\n          Edit this page on GitHub\n        </a>\n        <span className=\"hidden text-gray-500 sm:inline\">{separator}</span>\n        <a role=\"listitem\" href={hnUrl} className=\"!text-gray-500\">\n          Discuss on Hacker News\n        </a>\n      </nav>\n    </article>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/[year]/page.tsx",
    "content": "import { getAllPosts } from 'lib/blog'\nimport Link from 'next/link'\nimport { FiX } from 'react-icons/fi'\nimport { Button } from 'ui/components/buttons/button'\nimport { BlogPostPreview } from '../components/blog-post-preview'\nimport { BlogRollHeader } from '../components/blog-roll-header'\n\ntype PageProps = {\n  params: Promise<{\n    year: string\n  }>\n}\n\nexport const dynamicParams = false\n\nexport async function generateStaticParams() {\n  const posts = await getAllPosts()\n  const years = Array.from(\n    new Set(\n      posts\n        .filter(post => post.meta.publicationDate?.getFullYear())\n        .map(post => post.meta.publicationDate?.getFullYear())\n    )\n  )\n  return years.map(year => ({ year: year!.toString() }))\n}\n\nexport async function generateMetadata({ params }: PageProps) {\n  const { year } = await params\n  const posts = await getAllPosts()\n  const fromThisYear = posts.filter(\n    post => post.meta.publicationDate?.getFullYear() === parseInt(year)\n  )\n  const count = fromThisYear.length\n  const tags = Array.from(\n    new Set(fromThisYear.flatMap(post => post.meta.tags ?? []))\n  ).filter(tag => tag !== 'til')\n  const tagsString =\n    tags.length === 1\n      ? tags[0]\n      : tags.slice(0, tags.length - 1).join(', ') +\n        ', and ' +\n        tags[tags.length - 1]\n  return {\n    title: year,\n    description: `I wrote ${count} article${\n      count > 1 ? 's' : ''\n    } in ${year}, about ${tagsString}`\n  }\n}\n\nexport default async function YearIndex({ params }: PageProps) {\n  const { year } = await params\n  const posts = await getAllPosts()\n  const fromThisYear = posts.filter(\n    post => post.meta.publicationDate?.getFullYear() === parseInt(year)\n  )\n  return (\n    <>\n      <BlogRollHeader title=\"Articles\" />\n      <Link href=\"/posts\">\n        <Button\n          size=\"sm\"\n          className=\"rounded-full\"\n          variant=\"outline\"\n          leftIcon={<FiX />}\n        >\n          Clear filter {year}\n        </Button>\n      </Link>\n      <section role=\"feed\" aria-busy={false} className=\"mt-12 space-y-12\">\n        {fromThisYear.map(post => (\n          <BlogPostPreview {...post} key={post.urlPath} />\n        ))}\n      </section>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/components/blog-post-preview.tsx",
    "content": "import { Post } from 'lib/blog'\nimport Link from 'next/link'\nimport { formatDate } from 'ui/format'\nimport { TagsNav } from '../../../../ui/components/tag'\n\ntype BlogPostPreviewProps = Post & {\n  Heading?: 'h2' | 'h3'\n}\n\nexport const BlogPostPreview: React.FC<BlogPostPreviewProps> = ({\n  meta: { title, publicationDate, description, tags },\n  readingTime,\n  urlPath,\n  Heading = 'h2'\n}) => {\n  return (\n    <article className=\"not-prose\">\n      <hgroup>\n        <Heading\n          className=\"mb-1 text-2xl font-bold\"\n          style={{ color: 'var(--tw-prose-headings)' }}\n        >\n          <Link href={urlPath}>{title}</Link>\n        </Heading>\n        <figcaption className=\"flex flex-wrap gap-y-1 text-sm text-gray-500\">\n          <span className=\"mr-2 inline-block\">\n            {publicationDate ? (\n              formatDate(publicationDate)\n            ) : (\n              <span className=\"font-semibold italic text-amber-600\">\n                Unpublished\n              </span>\n            )}\n            &nbsp;•&nbsp;{readingTime}\n          </span>\n          {tags && Boolean(tags.length) && <TagsNav tags={tags} />}\n        </figcaption>\n      </hgroup>\n      <p className=\"mt-3 leading-relaxed\">{description}</p>\n    </article>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/components/blog-roll-header.tsx",
    "content": "import { FiRss } from 'react-icons/fi'\n\ntype BlogRollHeaderProps = {\n  title: React.ReactNode\n  description?: React.ReactNode\n}\n\nexport const BlogRollHeader: React.FC<BlogRollHeaderProps> = ({\n  title,\n  description = 'I usually write about stuff. Not regularly.'\n}) => {\n  return (\n    <>\n      <header className=\"flex items-baseline\">\n        <h1>{title}</h1>\n        <nav className=\"not-prose ml-auto space-x-2 text-sm text-gray-500\">\n          <FiRss\n            className=\"-mt-1 inline-block stroke-amber-500\"\n            strokeWidth={3}\n          />\n          <a href=\"/posts/feed/rss.xml\">RSS</a>\n          <a href=\"/posts/feed/atom.xml\">Atom</a>\n          <a href=\"/posts/feed/articles.json\">JSON</a>\n        </nav>\n      </header>\n      <p>{description}</p>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/feed/[format]/route.ts",
    "content": "import { Feed } from 'feed'\nimport { getAllPosts } from 'lib/blog'\nimport { url } from 'lib/paths'\nimport { NextResponse } from 'next/server'\n\nexport async function generateStaticParams() {\n  return [\n    { format: 'rss.xml' },\n    { format: 'atom.xml' },\n    { format: 'articles.json' }\n  ]\n}\n\nexport async function GET(\n  _: Request,\n  { params }: { params: Promise<{ format: string }> }\n) {\n  const { format: formatSlug } = await params\n  const format =\n    formatSlug === 'rss.xml'\n      ? 'rss'\n      : formatSlug === 'atom.xml'\n        ? 'atom'\n        : formatSlug === 'articles.json'\n          ? 'json'\n          : null\n\n  if (format === null) {\n    return NextResponse.json(\n      {\n        error: 'Unsupported feed format'\n      },\n      {\n        status: 400\n      }\n    )\n  }\n\n  const now = Date.now()\n  const allPosts = await getAllPosts()\n  const publishedPosts = allPosts.filter(\n    post => (post.meta.publicationDate?.valueOf() ?? Infinity) < now\n  )\n  const tags = new Set(publishedPosts.flatMap(post => post.meta.tags ?? []))\n  const feed = new Feed({\n    title: 'Articles by François Best',\n    description:\n      'I write about TypeScript, Node.js, React, security and privacy.',\n    id: url('/posts'),\n    link: url('/posts'),\n    language: 'en',\n    image: url('/favicons/apple-icon-120x120.png'),\n    favicon: url('/favicon.ico'),\n    copyright: 'All rights reserved 2023, François Best',\n    feedLinks: {\n      json: url('/posts/feed/articles.json'),\n      atom: url('/posts/feed/atom.xml'),\n      rss: url('/posts/feed/rss.xml')\n    },\n    author: {\n      name: 'François Best',\n      email: 'rss@francoisbest.com',\n      link: url('/')\n    }\n  })\n\n  // Register tags as categories\n  tags.forEach(tag => {\n    feed.addCategory(tag)\n  })\n\n  publishedPosts.forEach(post => {\n    const postUrl = new URL(url(post.urlPath))\n    postUrl.searchParams.set('utm_source', format)\n    feed.addItem({\n      title: post.meta.title,\n      id: post.urlPath,\n      link: postUrl.toString(),\n      image: post.ogImageExtension\n        ? url(`/posts/og/${post.slug.join('/')}`)\n        : undefined,\n      category: post.meta.tags?.map(tag => ({\n        name: tag,\n        term: tag\n      })),\n      description: post.meta.description,\n      content: `${post.meta.description}\n\n<a href=\"${postUrl.toString()}\">Full article</a> (${post.readingTime}).`,\n      author: [\n        {\n          name: 'François Best',\n          email: `${format}@francoisbest.com`,\n          link: url('/')\n        }\n      ],\n      published: new Date(post.meta.publicationDate!),\n      date: new Date(post.meta.publicationDate!)\n    })\n  })\n\n  if (format === 'rss') {\n    return new Response(feed.rss2(), {\n      headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' }\n    })\n  }\n  if (format === 'atom') {\n    return new Response(feed.atom1(), {\n      headers: { 'Content-Type': 'application/atom+xml; charset=utf-8' }\n    })\n  }\n  return new Response(feed.json1(), {\n    headers: { 'Content-Type': 'application/json; charset=utf-8' }\n  })\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/og/[...slug]/route.ts",
    "content": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport { getAllPosts, type OgImageExtension } from 'lib/blog'\n\nconst CONTENT_DIR = path.join(process.cwd(), 'content/blog')\n\nexport async function generateStaticParams() {\n  const posts = await getAllPosts()\n  return posts\n    .filter(post => post.ogImageExtension)\n    .map(post => ({ slug: post.slug }))\n}\n\nexport async function GET(\n  _: Request,\n  { params }: { params: Promise<{ slug: string[] }> }\n) {\n  const { slug } = await params\n  const dir = path.join(CONTENT_DIR, ...slug)\n\n  const extensions: OgImageExtension[] = ['jpg', 'png']\n  for (const ext of extensions) {\n    const filePath = path.join(dir, `opengraph-image.${ext}`)\n    try {\n      const buffer = await fs.readFile(filePath)\n      return new Response(new Uint8Array(buffer), {\n        headers: {\n          'Content-Type': ext === 'jpg' ? 'image/jpeg' : 'image/png',\n          'Cache-Control': 'public, max-age=31536000, immutable'\n        }\n      })\n    } catch {\n      continue\n    }\n  }\n\n  return new Response(null, { status: 404 })\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/page.tsx",
    "content": "import { getAllPosts } from 'lib/blog'\nimport { BlogPostPreview } from './components/blog-post-preview'\nimport { BlogRollHeader } from './components/blog-roll-header'\n\nexport const metadata = {\n  title: 'Articles',\n  description: 'I write about TypeScript, Node.js, React, security and privacy.'\n}\n\nexport default async function BlogIndex() {\n  const posts = await getAllPosts()\n  return (\n    <>\n      <BlogRollHeader title=\"Articles\" />\n      <section role=\"feed\" aria-busy={false} className=\"mt-12 space-y-12\">\n        {posts.map(post => (\n          <BlogPostPreview {...post} key={post.urlPath} />\n        ))}\n      </section>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/tags/[tag]/page.tsx",
    "content": "import { getAllPosts } from 'lib/blog'\nimport Link from 'next/link'\nimport { FiX } from 'react-icons/fi'\nimport { Button } from 'ui/components/buttons/button'\nimport { StaticTag } from 'ui/components/tag'\nimport { BlogPostPreview } from '../../components/blog-post-preview'\nimport { BlogRollHeader } from '../../components/blog-roll-header'\n\ntype PageProps = {\n  params: Promise<{\n    tag: string\n  }>\n}\n\nexport async function generateMetadata({ params }: PageProps) {\n  const { tag } = await params\n  return {\n    title: `${tag} posts`,\n    description: `A list of posts talking about '${tag}'`\n  }\n}\n\nexport default async function TagPage({ params }: PageProps) {\n  const tag = decodeURIComponent((await params).tag)\n  const posts = await getAllPosts()\n  const filtered = posts.filter(post => post.meta.tags?.includes(tag))\n  return (\n    <>\n      <BlogRollHeader title=\"Articles\" />\n      <nav className=\"flex items-center justify-between\">\n        <Link href=\"/posts\">\n          <Button\n            size=\"sm\"\n            className=\"rounded-full\"\n            variant=\"outline\"\n            leftIcon={<FiX />}\n          >\n            Clear filter &nbsp;<StaticTag>{tag}</StaticTag>\n          </Button>\n        </Link>\n        <Link href=\"/posts/tags\" className=\"text-sm\">\n          All tags\n        </Link>\n      </nav>\n      <section role=\"feed\" aria-busy={false} className=\"mt-12 space-y-12\">\n        {filtered.map(post => (\n          <BlogPostPreview {...post} key={post.urlPath} />\n        ))}\n      </section>\n    </>\n  )\n}\n\nexport async function generateStaticParams() {\n  const posts = await getAllPosts()\n  const tags = Array.from(new Set(posts.flatMap(post => post.meta.tags ?? [])))\n  return tags.map(tag => ({ tag }))\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/posts/tags/page.tsx",
    "content": "import { getAllPosts } from 'lib/blog'\nimport { LinkedTag } from 'ui/components/tag'\nimport { BlogRollHeader } from '../components/blog-roll-header'\n\nexport const metadata = {\n  title: 'Tags',\n  description: 'A list of the common topics I talk about'\n}\n\nexport default async function TagsIndex() {\n  const posts = await getAllPosts()\n  // Count tag frequency\n  const tags = posts\n    .flatMap(post => post.meta.tags ?? [])\n    .map(decodeURIComponent)\n    .reduce(\n      (dict, tag) => ({\n        ...dict,\n        [tag]: (dict[tag] ?? 0) + 1\n      }),\n      {} as Record<string, number>\n    )\n  const sortedByFrequency = Object.fromEntries(\n    Object.entries(tags).sort(([, a], [, b]) => b - a)\n  )\n\n  return (\n    <>\n      <BlogRollHeader title=\"Articles\" description={metadata.description} />\n      <nav className=\"not-prose mt-12 flex flex-wrap gap-4\">\n        {Object.entries(sortedByFrequency).map(([tag, frequency]) => (\n          <LinkedTag key={tag} href={`/posts/tags/${tag}`}>\n            {tag} ({frequency})\n          </LinkedTag>\n        ))}\n      </nav>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/public-keys/page.tsx",
    "content": "import { Metadata } from 'next'\nimport { HireMe } from 'ui/components/hire-me'\nimport { Note } from 'ui/components/note'\nimport { NpmPackage } from 'ui/embeds/npm-package'\n\nexport const metadata: Metadata = {\n  title: 'Public keys',\n  description: 'PKI for the rest of us'\n}\n\nexport default function PublicKeysPage() {\n  return (\n    <>\n      <h1>Public keys</h1>\n\n      <h2>ProtonMail</h2>\n      <p>\n        If you want to send me sensitive email, you can end-to-end encrypt it\n        with <strong>PGP</strong> using my public key, and send your encrypted\n        message to{' '}\n        <a href=\"mailto:contact@francoisbest.com\">contact@francoisbest.com</a>.\n      </p>\n      <pre>\n        <code>{`-----BEGIN PGP PUBLIC KEY BLOCK-----\nxjMEXQZrGBYJKwYBBAHaRw8BAQdAKtfWV7ftx6wz695KaVK1P5Uxv5uF9QSk\niKNg5WA7hxLNM2NvbnRhY3RAZnJhbmNvaXNiZXN0LmNvbSA8Y29udGFjdEBm\ncmFuY29pc2Jlc3QuY29tPsJ3BBAWCgAfBQJdBmsYBgsJBwgDAgQVCAoCAxYC\nAQIZAQIbAwIeAQAKCRDJqWp0dE//yUH3AQDNMweIEqr7vlGTmQ4YdGWIyyLU\nH2kvzd+j+Qln3p84jAEArveSnLwUiDwSY8A0nmpyyf8cTABOSmj/DfDf5inZ\n5gPOOARdBmsYEgorBgEEAZdVAQUBAQdAgxLRtIBYrllF7ZStXDzKHBDWVzTZ\nqF/D7DHH4mb/dDcDAQgHwmEEGBYIAAkFAl0GaxgCGwwACgkQyalqdHRP/8nJ\nJQEAo/7eICIapjrYXbW07qjoWgWvguhbUNWZvWg+ZhuITZoBAJqO5xwpUrGl\nnEp97ZmKIRYDdsklAepec7jZFZdrUxsB\n=60X5\n-----END PGP PUBLIC KEY BLOCK-----`}</code>\n      </pre>\n\n      <h2>GitHub</h2>\n      <p>\n        To verify signed commits made by my username{' '}\n        <a href=\"https://github.com/franky47\">franky47</a>.\n      </p>\n      <pre>\n        <code>\n          ssh-ed25519\n          AAAAC3NzaC1lZDI1NTE5AAAAIHab2oWbLJjK8dRsdd2zZHXCqswrDnt2rctUu+f0WBdJ\n        </code>\n      </pre>\n\n      <Note status=\"info\">\n        Also accessible at{' '}\n        <a href=\"https://github.com/franky47.keys\">github.com/franky47.keys</a>\n      </Note>\n\n      <h2>Chiffre.io</h2>\n      <p>\n        <a href=\"https://chiffre.io/analytics.js\">Tracker script</a> signature\n        public key:\n      </p>\n      <pre>\n        <code>spk.lGrzXbgqN5fEXZhntPyIPJk0mcnbP6viWXQaosIwYHk</code>\n      </pre>\n\n      <h2>Sceau</h2>\n\n      <NpmPackage pkg=\"sceau\" repo=\"47ng/sceau\" />\n\n      <ul>\n        <li>\n          <a href=\"https://github.com/47ng/sceau\">47ng/sceau</a>{' '}\n          <small className=\"text-gray-500\">\n            <em>(Sceau signs itself, dogfood FTW)</em>\n          </small>\n          <pre>\n            <code>\n              c30d5d28b88136c77168fb78bf117948127c4e22f987ab60cd083bbd6c7ac0c9\n            </code>\n          </pre>\n        </li>\n        <li>\n          <a href=\"https://github.com/47ng/opaque\">47ng/opaque</a>\n          <pre>\n            <code>\n              5ac3e4d721755717f07d2af2fc8814c28b8265390d195644ccbf4141a7483564\n            </code>\n          </pre>\n        </li>\n        <li>\n          <a href=\"https://github.com/47ng/fastify-micro\">\n            47ng/fastify-micro\n          </a>\n          <pre>\n            <code>\n              4375fc7bacb2f0a931d3a50367ad79f6562f600aad9dd83545544d9c0b2dc7d3\n            </code>\n          </pre>\n        </li>\n        <li>\n          <a href=\"https://github.com/SocialGouv/e2esdk\">SocialGouv/e2esdk</a>\n          <pre>\n            <code>\n              82182691aa16fb18c4ee5f502f9067fe486768391d6ad5baa95e7a68913c9ad9\n            </code>\n          </pre>\n        </li>\n        <li>\n          <a href=\"https://github.com/SocialGouv/streaming-file-encryption\">\n            SocialGouv/streaming-file-encryption\n          </a>\n          <pre>\n            <code>\n              cc5ce1aae47615906725d9859ae6c9202ca4406e14f242a4d1ef8a5a2cdadfb7\n            </code>\n          </pre>\n        </li>\n      </ul>\n\n      <HireMe outerClass=\"mt-12\" />\n\n      <nav role=\"list\" className=\"!mt-12 flex flex-col items-center text-center text-sm\">\n        <a role=\"listitem\" href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/app/(pages)/public-keys/page.tsx\" className=\"!text-gray-500\">\n          Edit this page on GitHub\n        </a>\n      </nav>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/safari-speedrun/page.tsx",
    "content": "import { Metadata } from 'next'\nimport { Suspense } from 'react'\nimport { Runner } from './runner'\n\nexport const metadata: Metadata = {\n  title: 'Safari rate limit detector',\n  description: 'Testing the boundaries of the Web History API'\n}\n\nexport default function SafariSpeedrunPage() {\n  return (\n    <>\n      <h1>Safari Rate Limit Detector</h1>\n      <Suspense fallback={<div>Loading...</div>}>\n        <Runner />\n      </Suspense>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/safari-speedrun/runner.tsx",
    "content": "'use client'\n\nimport { parseAsInteger, useQueryState } from 'nuqs'\nimport React from 'react'\n\nimport { Button } from 'ui/components/buttons/button'\n\nexport const Runner: React.FC = () => {\n  const [maxCount] = useQueryState('maxCount', parseAsInteger.withDefault(150))\n  const [delay] = useQueryState('delay', parseAsInteger.withDefault(320))\n  const [counter, setCounter] = useQueryState(\n    'counter',\n    parseAsInteger.withDefault(0)\n  )\n  const [running, setRunning] = React.useState(false)\n  const [results, setResults] = React.useState<Set<string>>(() => new Set())\n  const intervalRef = React.useRef<number | undefined>(undefined)\n\n  const run = React.useCallback(() => {\n    setCounter(x => {\n      if (x >= maxCount) {\n        setRunning(false)\n        return 0\n      }\n      return x + 1\n    }).catch(() => {\n      setResults(r => {\n        r.add(location.search)\n        return r\n      })\n    })\n  }, [setCounter, setRunning, maxCount])\n\n  React.useEffect(() => {\n    clearInterval(intervalRef.current)\n    if (!running) {\n      intervalRef.current = undefined\n      return\n    }\n    intervalRef.current = self.setInterval(run, delay)\n  }, [running, delay, run])\n\n  return (\n    <>\n      <section className=\"space-x-2\">\n        <Button disabled={running} onClick={() => setRunning(true)}>\n          Start\n        </Button>\n        <Button disabled={!running} onClick={() => setRunning(false)}>\n          Cancel\n        </Button>\n      </section>\n      <dl>\n        <dd>Delay: {delay}</dd>\n        <dd className=\"flex items-center gap-2\">\n          <progress value={counter} max={maxCount} />\n          {Math.round((100 * counter) / maxCount)}%\n        </dd>\n      </dl>\n      {Array.from(results).map(search => (\n        <p key={search}>{search}</p>\n      ))}\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/sitemap/page.tsx",
    "content": "import { Metadata } from 'next'\nimport Link from 'next/link'\nimport { HireMe } from 'ui/components/hire-me'\nimport { Note } from 'ui/components/note'\n\nexport const metadata: Metadata = {\n  title: 'Site map',\n  description:\n    \"Welcome to the Dungeon. It's dangerous to go alone. Here be dragons.\"\n}\n\nexport default function SitemapPage() {\n  return (\n    <>\n      <h1>Site map</h1>\n\n      <Note status=\"warning\" title=\"Here be dragons.\">\n        Links might break. If they do, let me know by{' '}\n        <a href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/app/(pages)/sitemap/page.tsx\">\n          editing this page on GitHub\n        </a>\n        .\n      </Note>\n\n      <p>\n        Also available in <Link href=\"/sitemap.xml\">XML</Link>, if that&apos;s your\n        thing.\n      </p>\n\n      <h2>Main Content</h2>\n      <ul>\n        <li>\n          <Link href=\"/\">Home</Link>\n        </li>\n        <li>\n          <Link href=\"/posts\">Articles</Link>\n        </li>\n        <li>\n          <Link href=\"/open-source\">Open Source</Link>\n        </li>\n      </ul>\n      <p>\n        Here are a few other pages not worthy of featuring on the main index\n        page:\n      </p>\n      <ul>\n        <li>\n          <Link href=\"/public-keys\">My public keys</Link>\n        </li>\n        <li>\n          <Link href=\"/uses\">\n            <code>/uses</code>\n          </Link>\n        </li>\n        <li>\n          <Link href=\"/music\">Music I enjoy listening to</Link>\n        </li>\n      </ul>\n\n      <h2>Demos & Tests</h2>\n      <ul>\n        <li>\n          <Link href=\"/horcrux\">Horcrux</Link>, a playground for Shamir Secret\n          Sharing\n        </li>\n      </ul>\n\n      <HireMe outerClass=\"mt-12\" />\n\n      <nav role=\"list\" className=\"!mt-12 flex flex-col items-center text-center text-sm\">\n        <a role=\"listitem\" href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/app/(pages)/sitemap/page.tsx\" className=\"!text-gray-500\">\n          Edit this page on GitHub\n        </a>\n      </nav>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/(pages)/uses/page.tsx",
    "content": "import { Metadata } from 'next'\nimport { HireMe } from 'ui/components/hire-me'\n\nexport const metadata: Metadata = {\n  title: 'Things I use'\n}\n\nexport default function UsesPage() {\n  return (\n    <>\n      <h1>\n        <code>uses</code>\n      </h1>\n      <p>A collection of things I use to work.</p>\n\n      <h2>Hardware</h2>\n      <ul>\n        <li>MacBook Pro 2015 (I prefer hardware buttons to the touchbar)</li>\n        <li>Beyerdynamic DT-770 Pro</li>\n        <li>Note pad + pencil</li>\n      </ul>\n\n      <h2>Programming Languages</h2>\n      <p>Main:</p>\n      <ul>\n        <li>TypeScript / JavaScript</li>\n        <li>C++</li>\n      </ul>\n      <p>Notions:</p>\n      <ul>\n        <li>Rust</li>\n        <li>Python</li>\n        <li>Ruby</li>\n      </ul>\n\n      <h2>Frameworks</h2>\n      <ul>\n        <li>Next.js / React</li>\n        <li>Node.js</li>\n        <li>Fastify.io</li>\n      </ul>\n\n      <h2>Databases / Datastores</h2>\n      <ul>\n        <li>PostgreSQL</li>\n        <li>Redis</li>\n      </ul>\n\n      <h2>UI Frameworks</h2>\n      <ul>\n        <li>Chakra-UI</li>\n        <li>TailwindCSS</li>\n      </ul>\n\n      <h2>Browser Extensions</h2>\n      <ul>\n        <li>Bitwarden</li>\n        <li>uBlock Origin</li>\n        <li>uMatrix</li>\n        <li>PrivacyRedirect</li>\n      </ul>\n\n      <h2>Visual Studio Code Extensions</h2>\n      <ul>\n        <li>Prettier</li>\n        <li>ungit</li>\n        <li>TodoTree</li>\n        <li>Error Lens</li>\n      </ul>\n\n      <h2>Services I like to use</h2>\n      <ul>\n        <li>ProtonMail</li>\n        <li>Bitwarden</li>\n        <li>GitHub</li>\n        <li>Slack</li>\n        <li>Excalidraw</li>\n        <li>Figma</li>\n        <li>Notion</li>\n      </ul>\n\n      <h2>Tools</h2>\n      <ul>\n        <li>Postico (PostgreSQL client)</li>\n        <li>Medis (Redis client)</li>\n        <li>Insomnia (Postman alternative)</li>\n      </ul>\n\n      <HireMe outerClass=\"mt-12\" />\n\n      <nav role=\"list\" className=\"!mt-12 flex flex-col items-center text-center text-sm\">\n        <a role=\"listitem\" href=\"https://github.com/franky47/francoisbest.com/blob/next/packages/francoisbest.com/src/app/(pages)/uses/page.tsx\" className=\"!text-gray-500\">\n          Edit this page on GitHub\n        </a>\n      </nav>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/.well-known/webfinger/route.ts",
    "content": "import { NextResponse } from 'next/server'\n\nexport async function GET() {\n  if (process.env.VERCEL_ENV !== 'production') {\n    return new NextResponse(null, {\n      status: 404,\n      statusText: 'Not found'\n    })\n  }\n  // https://notebook.lachlanjc.com/2022-11-18_link_your_domain_to_mastodon_with_nextjs\n  return NextResponse.json({\n    subject: 'acct:Franky47@mamot.fr',\n    aliases: ['https://mamot.fr/@Franky47', 'https://mamot.fr/users/Franky47'],\n    links: [\n      {\n        rel: 'http://webfinger.net/rel/profile-page',\n        type: 'text/html',\n        href: 'https://mamot.fr/@Franky47'\n      },\n      {\n        rel: 'self',\n        type: 'application/activity+json',\n        href: 'https://mamot.fr/users/Franky47'\n      },\n      {\n        rel: 'http://ostatus.org/schema/1.0/subscribe',\n        template: 'https://mamot.fr/authorize_interaction?uri={uri}'\n      }\n    ]\n  })\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/api/isr/route.ts",
    "content": "import { revalidateTag } from 'next/cache'\nimport { NextRequest, NextResponse } from 'next/server'\n\nconst ACCEPTED_TAGS = ['npm', 'github']\n\nexport async function GET(req: NextRequest) {\n  const token = req.nextUrl.searchParams.get('token')\n  if (token !== process.env.ISR_TOKEN) {\n    return NextResponse.json({ error: 'Invalid token' }, { status: 400 })\n  }\n  const now = new Date()\n  const tag = req.nextUrl.searchParams.get('tag')\n  if (!tag || !ACCEPTED_TAGS.includes(tag)) {\n    return NextResponse.json({ error: 'Invalid tag' }, { status: 400 })\n  }\n  revalidateTag(tag, 'max')\n  return NextResponse.json({\n    at: now.toISOString(),\n    revalidated: tag\n  })\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/global.css",
    "content": "@import 'tailwindcss';\n@plugin \"@tailwindcss/typography\";\n\n@custom-variant dark (&:where(.dark, .dark *));\n\n@theme {\n  /* Extend default colors with custom slate-based gray scale */\n  --color-gray-50: var(--color-slate-50);\n  --color-gray-100: var(--color-slate-100);\n  --color-gray-200: var(--color-slate-200);\n  --color-gray-300: var(--color-slate-300);\n  --color-gray-400: var(--color-slate-400);\n  --color-gray-500: var(--color-slate-500);\n  --color-gray-600: var(--color-slate-600);\n  --color-gray-700: var(--color-slate-700);\n  --color-gray-800: var(--color-slate-800);\n  --color-gray-900: var(--color-slate-900);\n  --color-gray-950: var(--color-slate-950);\n\n  /* Custom background colors */\n  --color-bg-light: white;\n  --color-bg-dark: #0a101d;\n}\n\n@layer base {\n  /* Excalidraw fonts */\n  @font-face {\n    font-family: 'Virgil';\n    font-style: normal;\n    font-weight: 400;\n    font-display: swap;\n    src:\n      url('/fonts/FG_Virgil.woff2') format('woff2'),\n      url('/fonts/FG_Virgil.woff') format('woff');\n  }\n  @font-face {\n    font-family: 'Cascadia';\n    font-style: normal;\n    font-weight: 400;\n    font-display: swap;\n    src:\n      url('/fonts/Cascadia.woff2') format('woff2'),\n      url('/fonts/Cascadia.woff') format('woff');\n  }\n}\n\na:hover {\n  text-decoration: underline;\n}\n\nimg + figcaption,\nsvg + figcaption {\n  text-align: center;\n}\n\naside[role='note'] a,\naside[role='note'] strong {\n  color: currentColor;\n}\n\n/* Header links */\n\nh2,\nh3,\nh4 {\n  /* Give headings a little bit of breathing room when scrolled to */\n  scroll-margin-top: 2rem;\n}\n\nh2 > a > .icon.icon-link::after,\nh3 > a > .icon.icon-link::after,\nh4 > a > .icon.icon-link::after {\n  content: '#';\n  margin-left: 0.5rem;\n  opacity: 0;\n  @apply transition-opacity ease-out;\n}\n\nh2:hover > a > .icon.icon-link::after,\nh3:hover > a > .icon.icon-link::after,\nh4:hover > a > .icon.icon-link::after {\n  opacity: 0.25;\n}\n\nh2 > a:hover > .icon.icon-link::after,\nh3 > a:hover > .icon.icon-link::after,\nh4 > a:hover > .icon.icon-link::after {\n  opacity: 1;\n}\n\n/* Hacker News embed */\n\n.prose blockquote.hacker-news p:first-of-type::before {\n  content: none;\n}\n.prose blockquote.hacker-news p:last-of-type::after {\n  content: none;\n}\n\n.prose blockquote.hacker-news {\n  @apply bg-gray-50/50 py-2 not-italic dark:bg-gray-800/50;\n}\n\n/* Code highlighting with rehype-pretty-code / shiki */\n\nfigure[data-rehype-pretty-code-figure] > figcaption:first-child {\n  background: #1f2028;\n  font-size: 0.85em;\n  color: #828bb8;\n  padding: 0.6rem 1rem;\n  border-bottom: 1px solid #2f334d;\n  border-top-left-radius: 0.5rem;\n  border-top-right-radius: 0.5rem;\n}\n\nfigure[data-rehype-pretty-code-figure]:has(> figcaption:first-child) > pre {\n  border-top-left-radius: 0 !important;\n  border-top-right-radius: 0 !important;\n}\n\nfigure[data-rehype-pretty-code-figure] > figcaption:last-child {\n  background: #1f2028;\n  font-size: 0.85em;\n  color: #828bb8;\n  padding: 0.4rem 1rem;\n  border-top: 1px solid #2f334d;\n  border-bottom-left-radius: 0.5rem;\n  border-bottom-right-radius: 0.5rem;\n}\n\nfigure[data-rehype-pretty-code-figure]:has(> figcaption:last-child) > pre {\n  border-bottom-left-radius: 0 !important;\n  border-bottom-right-radius: 0 !important;\n}\n\npre {\n  @apply rounded-lg !px-0;\n}\n\npre > code {\n  @apply grid;\n  counter-reset: line;\n}\n\n[data-line] {\n  @apply border-l-2 border-l-transparent px-4;\n}\n\n[data-highlighted-line] {\n  background: rgba(200, 200, 255, 0.1);\n  @apply border-l-blue-400;\n}\n\n[data-highlighted-chars] {\n  @apply rounded bg-zinc-600/50;\n  box-shadow: 0 0 0 4px rgb(82 82 91 / 0.5);\n}\n\nspan[data-rehype-pretty-code-fragment] > code {\n  @apply rounded-md py-1;\n}\n\n/* Dark mode ---------------------------------------------------------------- */\n\n@utility bg-light {\n  background-color: var(--color-bg-light);\n}\n\n@utility bg-dark {\n  background-color: var(--color-bg-dark);\n}\n\n@layer base {\n  html {\n    @apply bg-light;\n  }\n\n  .dark {\n    @apply bg-dark text-gray-200;\n  }\n\n  .dark input,\n  .dark textarea {\n    @apply border-gray-700;\n  }\n}\n\n/* Dark mode end ------------------------------------------------------------ */\n\n@layer components {\n  /* Slider Thumb ----------------------------------------------------------- */\n\n  input[type='range']::-webkit-slider-thumb {\n    height: 1rem;\n    width: 1rem;\n    background: #3b71ca;\n    border-radius: 9999px;\n    border: 0;\n    appearance: none;\n    -moz-appearance: none;\n    -webkit-appearance: none;\n    cursor: pointer;\n  }\n  .dark input[type='range']::-webkit-slider-thumb {\n    background: #8faee0;\n  }\n  input[type='range']:disabled::-webkit-slider-thumb,\n  input[type='range']:disabled:focus::-webkit-slider-thumb {\n    background: #a3a3a3;\n  }\n  input[type='range']:disabled:active::-webkit-slider-thumb {\n    background: #a3a3a3;\n  }\n  .dark input[type='range']:disabled::-webkit-slider-thumb,\n  .dark input[type='range']:disabled:focus::-webkit-slider-thumb {\n    background: #737373;\n  }\n  .dark input[type='range']:disabled:active::-webkit-slider-thumb {\n    background: #737373;\n  }\n  input[type='range']::-moz-range-thumb {\n    background: #3b71ca;\n    border-radius: 9999px;\n    border: 0;\n    appearance: none;\n    -moz-appearance: none;\n    -webkit-appearance: none;\n    cursor: pointer;\n  }\n  .dark input[type='range']::-moz-range-thumb {\n    background: #8faee0;\n  }\n  input[type='range']:disabled::-moz-range-thumb {\n    background: #a3a3a3;\n  }\n  .dark input[type='range']:disabled::-moz-range-thumb {\n    background: #737373;\n  }\n\n  input[type='range']:focus::-webkit-slider-thumb {\n    background: #3061af;\n  }\n  input[type='range']:active::-webkit-slider-thumb {\n    background: #285192;\n  }\n  .dark input[type='range']:focus::-webkit-slider-thumb {\n    background: #6590d5;\n  }\n  .dark input[type='range']:active::-webkit-slider-thumb {\n    background: #3061af;\n  }\n\n  /* Slider Track ----------------------------------------------------------- */\n\n  input[type='range']::-moz-range-progress {\n    background: #3061af;\n  }\n  input[type='range']::-ms-fill-lower {\n    background: #3061af;\n  }\n  .dark input[type='range']::-moz-range-progress {\n    background: #6590d5;\n  }\n  .dark input[type='range']::-ms-fill-lower {\n    background: #6590d5;\n  }\n\n  input[type='range']:focus {\n    outline: none;\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/layout.tsx",
    "content": "import { url } from 'lib/paths'\nimport seo from 'lib/seo.json'\nimport { chiffreConfig } from 'lib/services/chiffre'\nimport { Metadata } from 'next'\nimport { ThemeProvider } from 'next-themes'\nimport { NuqsAdapter } from 'nuqs/adapters/next/app'\nimport { Favicons } from 'ui/head/favicons'\nimport './global.css'\n\nexport const metadata: Metadata = {\n  metadataBase: new URL(url('/')),\n  ...seo\n}\n\nexport default function RootLayout({\n  children\n}: {\n  children: React.ReactNode\n}) {\n  return (\n    <html\n      lang=\"en-GB\"\n      className=\"motion-safe:scroll-smooth\"\n      suppressHydrationWarning\n    >\n      <head>\n        <Favicons />\n        <link\n          key=\"rss-feed\"\n          rel=\"alternate\"\n          type=\"application/rss+xml\"\n          href=\"/posts/feed/rss.xml\"\n          title=\"Articles by François Best (RSS)\"\n        />\n        <link\n          key=\"atom-feed\"\n          rel=\"alternate\"\n          type=\"application/atom+xml\"\n          href=\"/posts/feed/atom.xml\"\n          title=\"Articles by François Best (Atom)\"\n        />\n        <link\n          key=\"json-feed\"\n          rel=\"alternate\"\n          type=\"application/json\"\n          href=\"/posts/feed/articles.json\"\n          title=\"Articles by François Best (JSON)\"\n        />\n        <meta name=\"twitter:dnt\" content=\"on\" />\n      </head>\n      <body>\n        <ThemeProvider\n          attribute=\"class\"\n          defaultTheme=\"system\"\n          enableSystem\n          disableTransitionOnChange\n        >\n          <NuqsAdapter>{children}</NuqsAdapter>\n        </ThemeProvider>\n        {chiffreConfig.enabled && (\n          <>\n            <script\n              id=\"chiffre:analytics\"\n              src=\"https://chiffre.io/analytics.js\"\n              data-chiffre-project-id={chiffreConfig.projectId}\n              data-chiffre-public-key={chiffreConfig.publicKey}\n              crossOrigin=\"anonymous\"\n              async\n            />\n            <noscript>\n              <img\n                src={`https://chiffre.io/noscript/${chiffreConfig.projectId}`}\n                alt=\"Chiffre.io anonymous visit counting for clients without JavaScript\"\n                crossOrigin=\"anonymous\"\n              />\n            </noscript>\n          </>\n        )}\n      </body>\n    </html>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/not-found.tsx",
    "content": "import Link from 'next/link'\nimport PageLayout from './(not-prose)/layout'\n\nexport default function NotFound() {\n  return (\n    <PageLayout>\n      <section className=\"my-24 text-center md:my-32\">\n        <p\n          role=\"presentation\"\n          aria-hidden\n          className=\"select-none text-8xl font-bold text-gray-200 dark:text-gray-800\"\n        >\n          404\n        </p>\n        <h2 className=\"my-4 text-2xl font-semibold text-gray-900 dark:text-gray-100\">\n          Page not found\n        </h2>\n        <p className=\"text-sm text-gray-500\">Sorry, there's nothing here.</p>\n        <Link\n          href=\"/\"\n          className=\"mt-8 inline-block text-sm font-medium underline\"\n        >\n          Return Home\n        </Link>\n      </section>\n    </PageLayout>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/robots.ts",
    "content": "import type { MetadataRoute } from 'next'\nimport { url } from 'lib/paths'\n\nconst isPreviewDeployment = process.env.VERCEL_ENV !== 'production'\n\nexport default function robots(): MetadataRoute.Robots {\n  return {\n    rules: [\n      {\n        userAgent: '*',\n        ...(isPreviewDeployment\n          ? { disallow: '/' }\n          : { allow: '/' })\n      },\n      {\n        userAgent: 'CCBot',\n        disallow: '/'\n      },\n      {\n        userAgent: 'GPTBot',\n        disallow: '/'\n      },\n      {\n        userAgent: 'ChatGPT-User',\n        disallow: '/'\n      }\n    ],\n    sitemap: url('/sitemap.xml')\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/sitemap.ts",
    "content": "import type { MetadataRoute } from 'next'\nimport { getAllPosts } from 'lib/blog'\nimport { url } from 'lib/paths'\n\nexport default async function sitemap(): Promise<MetadataRoute.Sitemap> {\n  const allPosts = await getAllPosts()\n  const now = Date.now()\n  const publishedPosts = allPosts.filter(\n    post => (post.meta.publicationDate?.valueOf() ?? Infinity) < now\n  )\n\n  const postEntries: MetadataRoute.Sitemap = publishedPosts.map(post => ({\n    url: url(post.urlPath),\n    lastModified: post.meta.publicationDate\n      ? new Date(post.meta.publicationDate)\n      : undefined\n  }))\n\n  // Derive unique tags from all published posts\n  const tags = Array.from(\n    new Set(publishedPosts.flatMap(post => post.meta.tags ?? []))\n  )\n  const tagEntries: MetadataRoute.Sitemap = [\n    { url: url('/posts/tags') },\n    ...tags.map(tag => ({ url: url(`/posts/tags/${tag}`) }))\n  ]\n\n  // Derive unique years from all published posts\n  const years = Array.from(\n    new Set(\n      publishedPosts\n        .map(post => post.meta.publicationDate?.getFullYear())\n        .filter((y): y is number => y !== undefined)\n    )\n  )\n  const yearEntries: MetadataRoute.Sitemap = years.map(year => ({\n    url: url(`/posts/${year}`)\n  }))\n\n  const staticPages: MetadataRoute.Sitemap = [\n    { url: url('/') },\n    { url: url('/posts') },\n    { url: url('/open-source') },\n    { url: url('/music') },\n    { url: url('/links') },\n    { url: url('/uses') },\n    { url: url('/public-keys') },\n    { url: url('/hashvatar') },\n    { url: url('/horcrux') },\n    { url: url('/woodworking/dovetail-designer') },\n    { url: url('/safari-speedrun') },\n    { url: url('/sitemap') }\n  ]\n\n  return [...staticPages, ...postEntries, ...tagEntries, ...yearEntries]\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/vcard/route.ts",
    "content": "import type { NextRequest } from 'next/server'\nimport { decryptPhoneNumber, vcard } from './vcard'\n\nexport const dynamic = 'force-dynamic'\n\nexport function GET(req: NextRequest) {\n  const key = req.cookies.get('phoneNumberKey')?.value\n  let phoneNumber: string | undefined = undefined\n  try {\n    phoneNumber = decryptPhoneNumber(key ?? '')\n  } catch {}\n  console.info(\n    JSON.stringify({\n      GET: '/vcard',\n      ua: req.headers.get('user-agent') ?? 'anonymous',\n      ref: req.referrer,\n      key: phoneNumber ? 'valid' : Boolean(key) ? 'invalid' : 'not-provided'\n    })\n  )\n  const res = new Response(vcard(phoneNumber))\n  res.headers.set('content-type', 'text/vcard')\n  return res\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/app/vcard/vcard.ts",
    "content": "import { hex, utf8 } from '@47ng/codec'\nimport nacl from 'tweetnacl'\n\n// export function encryptPhoneNumber() {\n//   const phoneNumber = process.env.PHONE_NUMBER\n//   if (!phoneNumber) {\n//     throw new Error('Missing PHONE_NUMBER environment variable')\n//   }\n//   const key = nacl.randomBytes(nacl.secretbox.keyLength)\n//   const nonce = nacl.randomBytes(nacl.secretbox.nonceLength)\n//   const ciphertext = nacl.secretbox(utf8.encode(phoneNumber), nonce, key)\n//   return {\n//     encrypted: [hex.encode(nonce), hex.encode(ciphertext)].join('.'),\n//     key: hex.encode(key),\n//   }\n// }\n\nexport function decryptPhoneNumber(key: string) {\n  const encrypted = process.env.ENCRYPTED_PHONE_NUMBER\n  if (!encrypted) {\n    throw new Error('Missing ENCRYPTED_PHONE_NUMBER environment variable')\n  }\n  const [nonce, ciphertext] = encrypted.split('.')\n  const phoneNumber = nacl.secretbox.open(\n    hex.decode(ciphertext),\n    hex.decode(nonce),\n    hex.decode(key)\n  )\n  if (!phoneNumber) {\n    throw new Error('Failed to decrypt phone number')\n  }\n  return utf8.decode(phoneNumber)\n}\n\nexport const vcard = (phoneNumber?: string) =>\n  `\nBEGIN:VCARD\nVERSION:3.0\nSOURCE:https://francoisbest.com/vcard\nFN:François Best\nN:Best;François\nPHOTO;TYPE=JPEG;VALUE=URI:https://res.47ng.com/francois.best.jpg\nORG:47ng\nTITLE:Freelance Software Engineer${\n    phoneNumber\n      ? `\nTEL;CELL:${phoneNumber}`\n      : ''\n  }\nEMAIL;WORK;INTERNET:hi@francoisbest.com\nURL:https://francoisbest.com\nTZ:Europe/Paris\nEND:VCARD\n`.trim()\n"
  },
  {
    "path": "packages/francoisbest.com/src/css.d.ts",
    "content": "declare module '*.css'\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/blog/defs.ts",
    "content": "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",
    "content": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport 'server-only'\nimport { blogSource } from 'lib/source'\nimport { PostMetadata } from './defs'\nimport { computeReadingTime } from './reading-time'\n\nconst CONTENT_DIR = path.join(process.cwd(), 'content/blog')\n\nexport type OgImageExtension = 'jpg' | 'png'\n\nexport type Post = {\n  slug: string[]\n  urlPath: string\n  meta: PostMetadata\n  readingTime: string\n  ogImageExtension?: OgImageExtension\n}\n\nexport async function getAllPosts(): Promise<Post[]> {\n  const pages = blogSource.getPages()\n  const posts = await Promise.all(pages.map(pageToPost))\n  return posts.sort((a, b) => {\n    const aPub = a.meta.publicationDate?.valueOf() ?? Infinity\n    const bPub = b.meta.publicationDate?.valueOf() ?? Infinity\n    if (aPub === bPub) {\n      return a.meta.title > b.meta.title ? 1 : -1\n    }\n    return aPub > bPub ? -1 : 1\n  })\n}\n\nexport async function getPost(slug: string[]): Promise<Post | undefined> {\n  const page = blogSource.getPage(slug)\n  if (!page) return undefined\n  return pageToPost(page)\n}\n\ntype FumadocsPage = ReturnType<typeof blogSource.getPages>[number]\n\nasync function pageToPost(page: FumadocsPage): Promise<Post> {\n  const [readingTime, ogImageExtension] = await Promise.all([\n    computeReadingTime(page.slugs),\n    detectOgImage(page.slugs)\n  ])\n  return {\n    slug: page.slugs,\n    urlPath: page.url,\n    meta: {\n      title: page.data.title ?? '',\n      description: page.data.description ?? '',\n      publicationDate: page.data.publicationDate,\n      tags: page.data.tags\n    },\n    readingTime,\n    ogImageExtension\n  }\n}\n\nasync function detectOgImage(\n  slugs: string[]\n): Promise<OgImageExtension | undefined> {\n  const dir = path.join(CONTENT_DIR, ...slugs)\n  for (const ext of ['jpg', 'png'] as const) {\n    try {\n      await fs.access(path.join(dir, `opengraph-image.${ext}`))\n      return ext\n    } catch {\n      continue\n    }\n  }\n  return undefined\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/blog/index.ts",
    "content": "export * from './defs'\nexport * from './engine'\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/blog/reading-time.test.ts",
    "content": "import { describe, expect, test } from 'vitest'\nimport { computeReadingTime } from './reading-time'\n\ndescribe('reading time', () => {\n  test('computes realistic reading time for a long blog post', async () => {\n    // \"The Security of GitHub Actions\" is a 9 min read on production\n    const result = await computeReadingTime(['2020', 'the-security-of-github-actions'])\n    expect(result).toMatch(/\\d+ min read/)\n    const minutes = parseInt(result)\n    expect(minutes).toBeGreaterThan(1)\n  })\n\n  test('computes realistic reading time for a short blog post', async () => {\n    // \"NPM download stats are down\" is a 2 min read on production\n    const result = await computeReadingTime(['2023', 'npm-download-stats-are-down'])\n    expect(result).toMatch(/\\d+ min read/)\n    const minutes = parseInt(result)\n    expect(minutes).toBeGreaterThanOrEqual(1)\n  })\n\n  test('strips frontmatter before computing reading time', async () => {\n    // If frontmatter wasn't stripped, the reading time would be inflated\n    // by the YAML keys and values. Verify the result is reasonable.\n    const result = await computeReadingTime(['2023', 'dotenv-is-dead'])\n    const minutes = parseInt(result)\n    expect(minutes).toBeLessThan(20) // sanity check\n    expect(minutes).toBeGreaterThanOrEqual(1)\n  })\n\n  test('does not return 1 min for every post', async () => {\n    const slugs = [\n      ['2020', 'the-security-of-github-actions'],\n      ['2023', 'storing-react-state-in-the-url-with-nextjs'],\n      ['2021', 'hashvatars'],\n      ['2019', 'strava-auth-cli-in-rust'],\n    ]\n    const times = await Promise.all(slugs.map(s => computeReadingTime(s).then(r => parseInt(r))))\n    // These posts have varying lengths - they shouldn't all be the same\n    const unique = new Set(times)\n    expect(unique.size).toBeGreaterThan(1)\n  })\n})\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/blog/reading-time.ts",
    "content": "import fs from 'node:fs/promises'\nimport path from 'node:path'\nimport readingTime from 'reading-time'\n\nconst CONTENT_DIR = path.join(process.cwd(), 'content/blog')\n\nexport async function computeReadingTime(slug: string[]): Promise<string> {\n  const filePath = path.join(CONTENT_DIR, ...slug, 'index.mdx')\n  const content = await fs.readFile(filePath, 'utf-8')\n  // Strip YAML frontmatter before computing\n  const body = content.replace(/^---\\r?\\n[\\s\\S]*?\\r?\\n---\\r?\\n?/, '')\n  return readingTime(body).text\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/env.ts",
    "content": "import { z } from 'zod'\n\nconst envSchema = z.object({\n  SPOTIFY_CLIENT_ID: z.string(),\n  SPOTIFY_CLIENT_SECRET: z.string()\n})\n\nexport const env = envSchema.parse(process.env)\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/mdx-components.tsx",
    "content": "import type { MDXComponents } from 'mdx/types'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { Note } from 'ui/components/note'\nimport { GitHubRepo } from 'ui/embeds/github-repo'\nimport { HackerNewsComment } from 'ui/embeds/hacker-news'\nimport { NpmPackage } from 'ui/embeds/npm-package'\nimport { WideContainer } from 'ui/layouts/wide-container'\n\nexport function getMdxComponents(): MDXComponents {\n  return {\n    Note,\n    WideContainer,\n    NpmPackage,\n    GitHubRepo,\n    Image,\n    HackerNewsComment,\n    img: Image as MDXComponents['img'],\n    a: ({ href, ref: _, ...props }) => {\n      if (href?.startsWith('/') || href?.startsWith('#')) {\n        return <Link href={href} {...props} />\n      }\n      return <a href={href} {...props} />\n    }\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/paths.test.ts",
    "content": "import { describe, expect, test } from 'vitest'\nimport { url } from './paths'\n\ndescribe('paths', () => {\n  test('url generates correct URLs', () => {\n    const original = process.env.DEPLOYMENT_URL\n    try {\n      process.env.DEPLOYMENT_URL = 'example.com'\n      expect(url('/posts')).toEqual('https://example.com/posts')\n    } finally {\n      process.env.DEPLOYMENT_URL = original\n    }\n  })\n})\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/paths.ts",
    "content": "import path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url))\n\nconst nextJsRootDir = path.resolve(__dirname, '../../')\nconst repoRoot = path.resolve(nextJsRootDir, '../../')\n\nexport function resolve(importMetaUrl: string, ...paths: string[]) {\n  const filePath = fileURLToPath(importMetaUrl)\n  const dirname = path.dirname(filePath)\n  const fileName = path.basename(filePath)\n  const absPath = path.resolve(\n    dirname,\n    ...(paths.length === 0 ? [fileName] : paths)\n  )\n  return path.resolve(process.cwd(), absPath.replace(nextJsRootDir, '.'))\n}\n\nexport function url(routePath: string) {\n  const base = process.env.DEPLOYMENT_URL ?? process.env.VERCEL_URL\n  if (base) {\n    return `https://${base}${routePath}`\n  }\n  return `http://localhost:${process.env.PORT ?? 3000}` + routePath\n}\n\nexport function gitHubUrl(\n  filePath: string,\n  branch = process.env.VERCEL_GIT_COMMIT_REF ?? 'next'\n) {\n  return filePath.replace(\n    repoRoot,\n    `https://github.com/franky47/francoisbest.com/blob/${branch}`\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/seo.json",
    "content": "{\n  \"title\": {\n    \"default\": \"François Best\",\n    \"template\": \"%s | François Best\"\n  },\n  \"description\": \"Freelance web developer and open-sourcerer.\",\n  \"authors\": [{ \"name\": \"François Best\" }],\n  \"keywords\": \"bio,homepage,engineer,developer,freelance,remote,typescript,node.js,node,react,open-source,open source,privacy,security,cryptography,e2ee,end-to-end encryption,end to end encryption,surveillance,web,blog,writer,writing\",\n  \"twitter\": {\n    \"card\": \"summary_large_image\",\n    \"creator\": \"@fortysevenfx\",\n    \"site\": \"@fortysevenfx\"\n  },\n  \"openGraph\": {\n    \"type\": \"website\",\n    \"locale\": \"en-GB\",\n    \"siteName\": \"François Best\"\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/services/chiffre.ts",
    "content": "type ChiffreConfig =\n  | {\n      enabled: true\n      projectId: string\n      publicKey: string\n    }\n  | {\n      enabled: false\n    }\n\nexport const chiffreConfig: ChiffreConfig =\n  Boolean(process.env.CHIFFRE_PUBLIC_KEY) &&\n  Boolean(process.env.CHIFFRE_PROJECT_ID)\n    ? {\n        enabled: true,\n        projectId: process.env.CHIFFRE_PROJECT_ID!,\n        publicKey: process.env.CHIFFRE_PUBLIC_KEY!\n      }\n    : { enabled: false }\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/services/github.ts",
    "content": "import 'server-only'\nimport { z } from 'zod'\n\nexport type GitHubRepositoryData = {\n  url: string\n  avatarUrl: string\n  title?: string\n  description?: string\n  version?: string\n  license?: string\n  stars: number\n  issues: number\n  prs: number\n  updatedAt: Date\n}\n\nconst repositoryQuerySchema = z.object({\n  data: z.object({\n    repository: z.object({\n      name: z.string(),\n      description: z.string().nullish(),\n      licenseInfo: z\n        .object({\n          spdxId: z.string().nullish()\n        })\n        .nullish(),\n      latestRelease: z\n        .object({\n          tagName: z.string().nullish()\n        })\n        .nullish(),\n      owner: z.object({\n        avatarUrl: z.string().url()\n      }),\n      issues: z.object({\n        totalCount: z.number()\n      }),\n      pullRequests: z.object({\n        totalCount: z.number()\n      }),\n      stargazerCount: z.number()\n    })\n  })\n})\n\nexport async function fetchRepository(\n  slug: string\n): Promise<GitHubRepositoryData> {\n  const [owner, repo] = slug.split('/')\n  const query = `query {\n  repository(owner: \"${owner}\", name: \"${repo}\") {\n    name\n    description\n    licenseInfo {\n      spdxId\n    }\n    latestRelease {\n      tagName\n    }\n    owner {\n      avatarUrl\n    }\n    issues(states: OPEN) {\n      totalCount\n    }\n    pullRequests(states: OPEN) {\n      totalCount\n    }\n    stargazerCount\n  }\n}`.replace(/\\s+/g, ' ') // Minify\n  const res = await fetch(`https://api.github.com/graphql`, {\n    method: 'POST',\n    headers: {\n      Authorization: `bearer ${process.env.GITHUB_TOKEN}`\n    },\n    body: JSON.stringify({ query }),\n    next: {\n      tags: ['github'],\n      revalidate: 3600 // 1h\n    }\n  })\n  const json = await res.json()\n  const parsed = repositoryQuerySchema.safeParse(json)\n  if (!parsed.success) {\n    throw new Error(\n      `GitHub API error for ${slug}: ${JSON.stringify(json.errors ?? json.message ?? parsed.error.message)}`\n    )\n  }\n  const { repository } = parsed.data.data\n  return {\n    url: `https://github.com/${slug}`,\n    avatarUrl: repository.owner.avatarUrl,\n    title: repository.name,\n    description: repository.description ?? undefined,\n    issues: repository.issues.totalCount,\n    prs: repository.pullRequests.totalCount,\n    stars: repository.stargazerCount,\n    license: repository.licenseInfo?.spdxId ?? undefined,\n    version: repository.latestRelease?.tagName?.replace(/^v/, '') ?? undefined,\n    updatedAt: new Date()\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/services/hacker-news.ts",
    "content": "import { z } from 'zod'\nimport { sanitizeHTML } from './html-sanitizer'\n\nconst hackerNewsId = z.number().int().positive()\n\nconst hackerNewsItemSchema = z.object({\n  id: hackerNewsId,\n  type: z.enum([\n    // 'job',\n    'story',\n    'comment'\n    // 'poll',\n    // 'pollopt',\n  ]),\n  by: z.string(),\n  text: z.string().transform(html => sanitizeHTML(html.trim())),\n  time: z.number().transform(t => new Date(t * 1000))\n  // descendants: z.number().int().positive().optional(),\n  // parent: hackerNewsId.optional(),\n  // kids: z.array(hackerNewsId).optional().default([]),\n  // deleted: z.boolean().optional(),\n  // dead: z.boolean().optional(),\n  // score: z.number().int().positive().optional(),\n})\n\nexport type HackerNewsItem = z.infer<typeof hackerNewsItemSchema>\n\nconst hnUrlRegexp = /^https?:\\/\\/news\\.ycombinator\\.com\\/item\\?id=(\\d+)$/\n\nexport async function getHackerNewsItem(url: string) {\n  const match = hnUrlRegexp.exec(url)\n  if (!match) {\n    throw new Error('Invalid Hacker News URL')\n  }\n  const id = parseInt(match[1], 10)\n  const res = await fetch(\n    `https://hacker-news.firebaseio.com/v0/item/${id}.json`\n  )\n  const item = await res.json()\n  return hackerNewsItemSchema.parse(item)\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/services/html-sanitizer.ts",
    "content": "import DOMPurify from 'dompurify'\nimport { JSDOM } from 'jsdom'\n\nconst window = new JSDOM('').window\nconst purify = DOMPurify(window)\n\nexport function sanitizeHTML(unsafeHTML: string) {\n  return purify.sanitize(unsafeHTML)\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/services/npm.ts",
    "content": "import { Temporal } from '@js-temporal/polyfill'\nimport 'server-only'\n\nconst NPM_API_URL = process.env.NPM_API_URL || 'https://api.npmjs.org'\n\nexport type NpmPackageStatsData = {\n  packageName: string\n  url: string\n  allTime: number\n  last30Days: number[]\n  versions: Record<string, number>\n  lastDate: Date\n  updatedAt: Date\n}\n\ntype RangeResponse = {\n  downloads: Array<{\n    downloads: number\n    day: string\n  }>\n}\n\nasync function getLastNDays(\n  pkg: string,\n  n: number\n): Promise<{ downloads: number[]; date: string }> {\n  const today = Temporal.Now.plainDateISO()\n  const start = today.subtract({ days: n }).toString()\n  const end = today.subtract({ days: 1 }).toString()\n  const url = `${NPM_API_URL}/downloads/range/${start}:${end}/${pkg}`\n  const { downloads } = await get<RangeResponse>(url)\n  return {\n    downloads: downloads.map(d => d.downloads),\n    date: end\n  }\n}\n\ntype PointResponse = {\n  downloads: number\n}\n\nasync function getAllTime(pkg: string): Promise<number> {\n  let downloads: number = 0\n  const now = Temporal.Now.plainDateISO()\n  let start = Temporal.PlainDate.from('2015-01-10') // NPM stats epoch\n  let end = start.add({ months: 18 })\n  while (Temporal.PlainDate.compare(start, now) < 0) {\n    const clampedEnd = Temporal.PlainDate.compare(end, now) > 0 ? now : end\n    const url = `${NPM_API_URL}/downloads/point/${start.toString()}:${clampedEnd.toString()}/${pkg}`\n    const res = await get<PointResponse | null>(url, 3, [404])\n    downloads += res?.downloads ?? 0\n    start = end\n    end = start.add({ months: 18 })\n  }\n  return downloads\n}\n\nasync function getVersions(pkg: string): Promise<Record<string, number>> {\n  type VersionsReponse = {\n    downloads: Record<string, number>\n  }\n  const url = `${NPM_API_URL}/versions/${encodeURIComponent(pkg)}/last-week`\n  const { downloads } = await get<VersionsReponse>(url)\n  return Object.fromEntries(\n    Object.entries(downloads).sort(([, a], [, b]) => (a < b ? 1 : -1))\n  )\n}\n\nexport async function fetchNpmPackage(\n  pkg: string\n): Promise<NpmPackageStatsData> {\n  const [allTime, { downloads: last30Days, date: lastDate }, versions] =\n    await Promise.all([getAllTime(pkg), getLastNDays(pkg, 30), getVersions(pkg)])\n  return {\n    packageName: pkg,\n    url: `https://npmjs.com/package/${pkg}`,\n    versions,\n    allTime,\n    lastDate: new Date(lastDate),\n    last30Days,\n    updatedAt: new Date()\n  }\n}\n\n// Bulk API types\ntype BulkPointResponse = Record<string, PointResponse>\ntype BulkRangeResponse = Record<string, RangeResponse>\n\nasync function getAllTimeBulk(\n  packages: string[]\n): Promise<Record<string, number>> {\n  const totals: Record<string, number> = Object.fromEntries(\n    packages.map(pkg => [pkg, 0])\n  )\n  const now = Temporal.Now.plainDateISO()\n  let start = Temporal.PlainDate.from('2015-01-10')\n  let end = start.add({ days: 365 })\n  const slug = packages.join(',')\n  while (Temporal.PlainDate.compare(start, now) < 0) {\n    const clampedEnd = Temporal.PlainDate.compare(end, now) > 0 ? now : end\n    const url = `${NPM_API_URL}/downloads/point/${start.toString()}:${clampedEnd.toString()}/${slug}`\n    const res = await get<BulkPointResponse | null>(url, 3, [404])\n    if (res) {\n      for (const pkg of packages) {\n        totals[pkg] += res[pkg]?.downloads ?? 0\n      }\n    }\n    start = end\n    end = start.add({ days: 365 })\n  }\n  return totals\n}\n\nasync function getLastNDaysBulk(\n  packages: string[],\n  n: number\n): Promise<Record<string, { downloads: number[]; date: string }>> {\n  const today = Temporal.Now.plainDateISO()\n  const start = today.subtract({ days: n }).toString()\n  const end = today.subtract({ days: 1 }).toString()\n  const slug = packages.join(',')\n  const url = `${NPM_API_URL}/downloads/range/${start}:${end}/${slug}`\n  const res = await get<BulkRangeResponse>(url)\n  const result: Record<string, { downloads: number[]; date: string }> = {}\n  for (const pkg of packages) {\n    const data = res[pkg]\n    result[pkg] = {\n      downloads: data?.downloads.map(d => d.downloads) ?? [],\n      date: end\n    }\n  }\n  return result\n}\n\nexport async function fetchAllNpmPackages(\n  packages: string[]\n): Promise<Record<string, NpmPackageStatsData>> {\n  // NPM bulk API doesn't support scoped packages — split them out\n  const unscoped = packages.filter(p => !p.startsWith('@'))\n  const scoped = packages.filter(p => p.startsWith('@'))\n\n  const [\n    bulkAllTime,\n    bulkLast30Days,\n    scopedResults,\n    versionsEntries\n  ] = await Promise.all([\n    unscoped.length > 0\n      ? getAllTimeBulk(unscoped)\n      : Promise.resolve({} as Record<string, number>),\n    unscoped.length > 0\n      ? getLastNDaysBulk(unscoped, 30)\n      : Promise.resolve(\n          {} as Record<string, { downloads: number[]; date: string }>\n        ),\n    Promise.all(\n      scoped.map(async pkg => {\n        const [allTime, last30Days] = await Promise.all([\n          getAllTime(pkg),\n          getLastNDays(pkg, 30)\n        ])\n        return [pkg, { allTime, last30Days }] as const\n      })\n    ),\n    Promise.all(\n      packages.map(pkg => getVersions(pkg).then(v => [pkg, v] as const))\n    )\n  ])\n\n  const scopedMap = Object.fromEntries(scopedResults)\n  const versionsMap = Object.fromEntries(versionsEntries)\n  const result: Record<string, NpmPackageStatsData> = {}\n  for (const pkg of packages) {\n    let allTime: number\n    let last30Days: number[]\n    let lastDate: string\n    if (pkg.startsWith('@')) {\n      allTime = scopedMap[pkg].allTime\n      last30Days = scopedMap[pkg].last30Days.downloads\n      lastDate = scopedMap[pkg].last30Days.date\n    } else {\n      allTime = bulkAllTime[pkg]\n      const data = bulkLast30Days[pkg]\n      last30Days = data.downloads\n      lastDate = data.date\n    }\n    result[pkg] = {\n      packageName: pkg,\n      url: `https://npmjs.com/package/${pkg}`,\n      allTime,\n      last30Days,\n      versions: versionsMap[pkg],\n      lastDate: new Date(lastDate),\n      updatedAt: new Date()\n    }\n  }\n  return result\n}\n\nasync function get<T = unknown>(\n  url: string,\n  retries = 3,\n  nullOnStatus: number[] = []\n): Promise<T> {\n  let lastError: unknown\n  for (let attempt = 0; attempt < retries; attempt++) {\n    let responseText = ''\n    try {\n      const res = await fetch(url, {\n        next: { revalidate: 86_400, tags: ['npm'] }\n      })\n      responseText = await res.text()\n      if (!res.ok) {\n        if (nullOnStatus.includes(res.status)) {\n          return null as T\n        }\n        const isRetryable = res.status === 429 || res.status >= 500\n        if (isRetryable && attempt < retries - 1) {\n          const delay = 500 * Math.pow(2, attempt)\n          await new Promise(resolve => setTimeout(resolve, delay))\n          continue\n        }\n        throw new Error(\n          `NPM API ${res.status} for ${url}\\n\\n${responseText}`\n        )\n      }\n      return JSON.parse(responseText) as T\n    } catch (error) {\n      lastError = error\n      if (error instanceof Error && error.message.startsWith('NPM API')) {\n        throw error\n      }\n      if (attempt < retries - 1) {\n        const delay = 500 * Math.pow(2, attempt)\n        await new Promise(resolve => setTimeout(resolve, delay))\n        continue\n      }\n      throw new Error(\n        `Failed to fetch ${url}: ${String(error)}\\n\\n${responseText}`,\n        { cause: error }\n      )\n    }\n  }\n  throw lastError\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/lib/source.ts",
    "content": "import { blog } from 'collections/server'\nimport { loader } from 'fumadocs-core/source'\nimport { toFumadocsSource } from 'fumadocs-mdx/runtime/server'\n\nexport const blogSource = loader({\n  baseUrl: '/posts',\n  source: toFumadocsSource(blog, [])\n})\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/browser-window-frame.tsx",
    "content": "import { twMerge } from 'tailwind-merge'\nimport { ThemeControls } from './theme-controls'\n\nexport type BrowserWindowFrameProps = React.ComponentProps<'figure'> & {\n  url: string\n  children: React.ReactNode\n}\n\nexport const BrowserWindowFrame: React.FC<BrowserWindowFrameProps> = ({\n  className,\n  children,\n  url,\n  ...props\n}) => {\n  const { protocol, host, pathname } = new URL(url)\n  return (\n    <figure\n      className={twMerge(\n        'not-prose overflow-hidden rounded-lg shadow-2xl',\n        className\n      )}\n      {...props}\n    >\n      <header className=\"flex items-center bg-gray-200 py-2 dark:bg-gray-800\">\n        <nav className=\"mx-4 flex space-x-2\">\n          <span className=\"h-3 w-3 rounded-full bg-red-500\" />\n          <span className=\"h-3 w-3 rounded-full bg-yellow-500\" />\n          <span className=\"h-3 w-3 rounded-full bg-green-500\" />\n        </nav>\n        <span className=\"flex-1 rounded-sm bg-gray-100 px-2 py-0.5 text-sm text-gray-500 dark:bg-gray-900\">\n          {protocol + '//'}\n          <span className=\"text-gray-900 dark:text-gray-300\">{host}</span>\n          {pathname === '/' ? null : pathname}\n        </span>\n        <ThemeControls size=\"sm\" className=\"-my-2 mx-1.5\" />\n      </header>\n      {children}\n    </figure>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/buttons/button-spinner.tsx",
    "content": "// Source:\n// https://tailwind-elements.com/docs/standard/components/spinners/\n\nimport React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport const ButtonSpinner: React.FC<React.ComponentProps<'div'>> = ({\n  className,\n  ...props\n}) => {\n  return (\n    <div\n      className={twMerge(\n        'inline-block h-[1em] w-[1em] animate-spin rounded-full border-[0.15em] border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]',\n        className\n      )}\n      role=\"status\"\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/buttons/button.tsx",
    "content": "// Inspired from Chakra-UI's buttons.\n// Source:\n// https://github.com/chakra-ui/chakra-ui/blob/main/packages/components/theme/src/components/button.ts\n// https://github.com/chakra-ui/chakra-ui/blob/main/packages/components/button/src/button.tsx\n\nimport React from 'react'\nimport { twMerge } from 'tailwind-merge'\nimport { ButtonSpinner } from './button-spinner'\n\nexport type ButtonSize = 'xs' | 'sm' | 'md' | 'lg'\nexport type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'link'\nexport type ButtonColor = 'gray' | 'green' | 'blue' | 'red'\nexport type ButtonProps = React.ComponentProps<'button'> & {\n  size?: ButtonSize\n  color?: ButtonColor\n  variant?: ButtonVariant\n  leftIcon?: React.ReactNode\n  rightIcon?: React.ReactNode\n  isLoading?: boolean\n  spinnerPlacement?: 'start' | 'center' | 'end'\n  loadingText?: string\n  children?: React.ReactNode\n}\n\nexport function Button({\n  size = 'md',\n  color = 'gray',\n  variant = 'solid',\n  leftIcon = null,\n  rightIcon = null,\n  isLoading = false,\n  spinnerPlacement = 'start',\n  loadingText,\n  className,\n  children,\n  ...props\n}: ButtonProps) {\n  const sizeClass = sizeClasses[size]\n  let colorClass = colorClasses[`${variant}_${color}` as const]\n  if (variant === 'outline') {\n    colorClass += colorClasses[`ghost_${color}`]\n  }\n  if (isLoading) {\n    if (loadingText) {\n      children = loadingText\n    }\n    if (spinnerPlacement === 'start') {\n      leftIcon = <ButtonSpinner />\n      rightIcon = null\n    }\n    if (spinnerPlacement === 'center') {\n      leftIcon = null\n      children = <ButtonSpinner />\n      rightIcon = null\n    }\n    if (spinnerPlacement === 'end') {\n      leftIcon = null\n      rightIcon = <ButtonSpinner />\n    }\n  }\n  return (\n    <button\n      className={twMerge(\n        `\n      inline-flex appearance-none\n      items-center justify-center rounded\n      font-medium\n      leading-5\n      transition-colors ease-out\n      disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none\n      `,\n        sizeClass,\n        colorClass,\n        className\n      )}\n      disabled={isLoading}\n      {...props}\n    >\n      {leftIcon && <ButtonIcon className=\"mr-2\">{leftIcon}</ButtonIcon>}\n      {children}\n      {rightIcon && <ButtonIcon className=\"ml-2\">{rightIcon}</ButtonIcon>}\n    </button>\n  )\n}\n\nconst ButtonIcon: React.FC<React.ComponentProps<'span'>> = ({\n  className,\n  ...props\n}) => (\n  <span\n    aria-hidden\n    className={twMerge('inline-flex shrink-0 self-center', className)}\n    {...props}\n  />\n)\n\nconst sizeClasses: Record<ButtonSize, string> = {\n  xs: 'text-xs min-w-[1.5rem] h-6  px-2',\n  sm: 'text-sm min-w-[2rem]   h-8  px-3',\n  md: 'text-md min-w-[2.5rem] h-10 px-4',\n  lg: 'text-lg min-w-[3rem]   h-12 px-6'\n}\n\ntype ButtonStyle = `${ButtonVariant}_${ButtonColor}`\n\nconst colorClasses: Record<ButtonStyle, string> = {\n  solid_gray: `\n    bg-gray-100                   dark:bg-white/20\n    text-gray-800                 dark:text-white/90\n    hover:bg-gray-200             dark:hover:bg-white/30\n    hover:disabled:bg-gray-100    dark:hover:disabled:bg-white/20\n    active:bg-gray-300            dark:active:bg-white/40\n  `,\n  solid_blue: `\n    bg-blue-500                   dark:bg-blue-200\n    text-white                    dark:text-gray-800\n    hover:bg-blue-600             dark:hover:bg-blue-300\n    hover:disabled:bg-blue-500    dark:hover:disabled:bg-blue-200\n    active:bg-blue-700            dark:active:bg-blue-400\n  `,\n  solid_green: `\n    bg-emerald-500                dark:bg-emerald-200\n    text-white                    dark:text-gray-800\n    hover:bg-emerald-600          dark:hover:bg-emerald-300\n    hover:disabled:bg-emerald-500 dark:hover:disabled:bg-emerald-200\n    active:bg-emerald-700         dark:active:bg-emerald-400\n  `,\n  solid_red: `\n    bg-red-500                    dark:bg-red-200\n    text-white                    dark:text-gray-800\n    hover:bg-red-600              dark:hover:bg-red-300\n    hover:disabled:bg-red-500     dark:hover:disabled:bg-red-200\n    active:bg-red-700             dark:active:bg-red-400\n  `,\n  // Note: slight deviation from the hover & active states in dark mode\n  ghost_gray: `\n    text-gray-800                 dark:text-white/90\n    hover:bg-gray-100             dark:hover:bg-gray-800\n    active:bg-gray-200            dark:active:bg-gray-700\n  `,\n  ghost_blue: `\n    text-blue-600                 dark:text-blue-200\n    bg-transparent\n    hover:bg-blue-50              dark:hover:bg-blue-200/10\n    active:bg-blue-100            dark:active:bg-blue-200/20\n  `,\n  ghost_green: `\n    text-emerald-600              dark:text-emerald-200\n    bg-transparent\n    hover:bg-emerald-50           dark:hover:bg-emerald-200/10\n    active:bg-emerald-100         dark:active:bg-emerald-200/20\n  `,\n  ghost_red: `\n    text-red-600                  dark:text-red-200\n    bg-transparent\n    hover:bg-red-50               dark:hover:bg-red-200/10\n    active:bg-red-100             dark:active:bg-red-200/20\n  `,\n  outline_gray: `\n    border\n    border-gray-200               dark:border-white/30\n  `,\n  outline_blue: `\n    border\n    border-current\n  `,\n  outline_green: `\n    border\n    border-current\n  `,\n  outline_red: `\n    border\n    border-current\n  `,\n  link_gray: `\n    p-0 h-auto leading-normal align-baseline\n    text-gray-500             dark:text-gray-200\n    active:text-gray-700      dark:active:text-gray-500\n    hover:underline\n    hover:disabled:no-underline\n  `,\n  link_blue: `\n    p-0 h-auto leading-normal align-baseline\n    text-blue-500             dark:text-blue-200\n    active:text-blue-700      dark:active:text-blue-500\n    hover:underline\n    hover:disabled:no-underline\n  `,\n  link_red: `\n    p-0 h-auto leading-normal align-baseline\n    text-red-500              dark:text-red-200\n    active:text-red-700       dark:active:text-red-500\n    hover:underline\n    hover:disabled:no-underline\n  `,\n  link_green: `\n    p-0 h-auto leading-normal align-baseline\n    text-emerald-500          dark:text-emerald-200\n    active:text-emerald-700   dark:active:text-emerald-500\n    hover:underline\n    hover:disabled:no-underline\n  `\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/buttons/icon-button.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\nimport { Button, ButtonProps } from './button'\n\nexport type IconButtonProps = Omit<\n  ButtonProps,\n  'leftIcon' | 'rightIcon' | 'children' | 'loadingText' | 'spinnerPlacement'\n> & {\n  icon: React.ReactNode\n  ['aria-label']: string\n}\n\nexport const IconButton: React.FC<IconButtonProps> = ({\n  icon,\n  className,\n  isLoading = false,\n  ...props\n}) => {\n  return (\n    <Button\n      className={twMerge('rounded-full p-0', className)}\n      isLoading={isLoading}\n      spinnerPlacement=\"center\"\n      {...props}\n    >\n      {!isLoading && icon}\n    </Button>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/forms/inputs.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\nimport { useFormControlContext } from './structure'\n\nexport type InputProps = React.ComponentProps<'input'> & {}\n\nexport const Input: React.FC<InputProps> = ({ className, ...props }) => {\n  const { name } = useFormControlContext()\n  return (\n    <input\n      id={name}\n      name={name}\n      type=\"text\"\n      dir=\"auto\"\n      className={twMerge(\n        `bg-light dark:bg-dark relative block h-10 w-full appearance-none rounded-sm border border-gray-200 px-4 dark:border-gray-800`,\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n// --\n\nexport type NumberInputProps = InputProps & {}\n\nexport const NumberInput: React.FC<NumberInputProps> = ({\n  className,\n  ...props\n}) => {\n  return (\n    <div className=\"relative flex\">\n      <Input\n        type=\"number\"\n        inputMode=\"decimal\"\n        className={twMerge('', className)}\n        {...props}\n      />\n    </div>\n  )\n}\n\n// --\n\nexport type TextareaProps = React.ComponentProps<'textarea'> & {}\n\nexport const Textarea: React.FC<TextareaProps> = ({ className, ...props }) => {\n  const { name } = useFormControlContext()\n  return (\n    <textarea\n      id={name}\n      name={name}\n      className={twMerge(\n        `bg-light dark:bg-dark relative block w-full appearance-none rounded-sm border border-gray-200 px-2 py-1 dark:border-gray-800`,\n        className\n      )}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/forms/radio.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\nimport { useFormControlContext } from './structure'\n\ntype RadioStateContext = {\n  value: string\n  onChange: (value: string) => void\n}\n\nexport type RadioGroupProps = React.ComponentProps<'fieldset'> &\n  RadioStateContext & {\n    children: React.ReactNode\n  }\n\nconst RadioStateContext = React.createContext<RadioStateContext>({\n  value: '',\n  onChange: () => {}\n})\n\nexport const RadioGroup: React.FC<RadioGroupProps> = ({\n  className,\n  children,\n  onChange,\n  value,\n  ...props\n}) => {\n  return (\n    <fieldset className={twMerge('', className)} {...props}>\n      <RadioStateContext.Provider value={{ value, onChange }}>\n        {children}\n      </RadioStateContext.Provider>\n    </fieldset>\n  )\n}\n\n// --\n\nexport type RadioProps = React.ComponentProps<'input'> & {\n  children: React.ReactNode\n  value: string\n}\n\nexport const Radio: React.FC<RadioProps> = ({\n  className,\n  children,\n  value: thisValue,\n  ...props\n}) => {\n  const { name } = useFormControlContext()\n  const { value: currentValue, onChange: onCheck } =\n    React.useContext(RadioStateContext)\n  return (\n    <div className={twMerge('flex items-center gap-2', className)}>\n      <input\n        type=\"radio\"\n        id={thisValue}\n        name={name}\n        value={thisValue}\n        checked={currentValue === thisValue}\n        onChange={e => {\n          if (e.target.checked) {\n            onCheck(thisValue)\n          }\n        }}\n        {...props}\n      />\n      <label htmlFor={thisValue}>{children}</label>\n    </div>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/forms/slider.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport type SliderProps = Omit<React.ComponentProps<'input'>, 'onChange'> & {\n  onChange: (value: number) => void\n}\n\nexport const Slider: React.FC<SliderProps> = ({\n  className,\n  onChange,\n  ...props\n}) => {\n  return (\n    <input\n      type=\"range\"\n      className={twMerge(\n        'my-2 block h-1 w-full cursor-pointer appearance-none border-transparent',\n        className\n      )}\n      onInput={e => onChange(e.currentTarget.valueAsNumber)}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/forms/structure.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\ntype FormControlContext = {\n  name?: string\n}\n\nexport type FormControlProps = React.ComponentProps<'div'> &\n  FormControlContext & {\n    children: React.ReactNode\n  }\n\nconst FormControlContext = React.createContext<FormControlContext>({})\n\nexport function useFormControlContext() {\n  return React.useContext(FormControlContext)\n}\n\nexport const FormControl: React.FC<FormControlProps> = ({\n  name,\n  children,\n  ...props\n}) => (\n  <div {...props}>\n    <FormControlContext.Provider value={{ name }}>\n      {children}\n    </FormControlContext.Provider>\n  </div>\n)\n\nexport const FormLabel: React.FC<React.ComponentProps<'label'>> = ({\n  className,\n  ...props\n}) => {\n  const { name } = useFormControlContext()\n  return (\n    <label\n      htmlFor={name}\n      className={twMerge('mb-1 block font-semibold', className)}\n      {...props}\n    />\n  )\n}\n\nexport const FormHelperText: React.FC<React.ComponentProps<'div'>> = ({\n  className,\n  ...props\n}) => (\n  <div\n    className={twMerge('mt-1 text-sm text-gray-500', className)}\n    {...props}\n  />\n)\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/graphs/svg-curve-graph.tsx",
    "content": "import { Temporal } from '@js-temporal/polyfill'\nimport React from 'react'\nimport { twJoin, twMerge } from 'tailwind-merge'\nimport { formatNumber } from 'ui/format'\n\n// Source:\n// https://medium.com/@francoisromain/smooth-a-svg-path-with-cubic-bezier-curves-e37b49d46c74\n\nexport type SvgCurveGraphProps = React.ComponentProps<'svg'> & {\n  data: number[]\n  width?: number\n  height?: number\n  mt?: number\n  mb?: number\n  lastDate: Date\n}\n\ntype Point = [number, number]\n\ntype CommandFn = (point: Point, index: number, array: Point[]) => string\n\nconst FLOAT_DECIMALS = 4\n\n// --\n\nfunction formatGraphData(\n  data: number[],\n  scale: { w: number; h: number; mt: number; mb: number }\n) {\n  const actualH = scale.h - scale.mt - scale.mb\n  const { min: minY, max: maxY } = data.reduce(\n    ({ min, max }, value) => ({\n      min: Math.min(min, value),\n      max: Math.max(max, value)\n    }),\n    { min: Infinity, max: -Infinity }\n  )\n  const scaleX = scale.w / (data.length - 1)\n  const scaleY = actualH / (maxY - minY)\n  const points: Point[] = data.map((value, i) => [\n    i * scaleX,\n    scale.mt + scaleY * (maxY - value)\n  ])\n  return points\n}\n\nconst svgPath = (points: Point[], command: CommandFn) => {\n  // build the d attributes by looping over the points\n  const d = points.reduce(\n    (acc, point, i, a) =>\n      i === 0 // if first point\n        ? `M ${point[0].toFixed(FLOAT_DECIMALS)},${point[1].toFixed(\n            FLOAT_DECIMALS\n          )}` // else\n        : `${acc} ${command([point[0], point[1]], i, a)}`,\n    ''\n  )\n  return d\n}\nconst lineCommand = (point: Point) => `L ${point[0]} ${point[1]}`\n\nconst line = (pointA: Point, pointB: Point) => {\n  const lengthX = pointB[0] - pointA[0]\n  const lengthY = pointB[1] - pointA[1]\n  return {\n    length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)),\n    angle: Math.atan2(lengthY, lengthX)\n  }\n}\n\nconst controlPoint = (\n  current: Point,\n  previous: Point,\n  next: Point,\n  reverse?: boolean\n) => {\n  // When 'current' is the first or last point of the array\n  // 'previous' or 'next' don't exist.\n  // Replace with 'current'\n  const p = previous || current\n  const n = next || current // The smoothing ratio\n  const smoothing = 0.2 // Properties of the opposed-line\n  const o = line(p, n) // If is end-control-point, add PI to the angle to go backward\n  const angle = o.angle + (reverse ? Math.PI : 0)\n  const length = o.length * smoothing // The control point position is relative to the current point\n  const x = current[0] + Math.cos(angle) * length\n  const y = current[1] + Math.sin(angle) * length\n  return [x, y]\n}\n\nconst bezierCommand: CommandFn = (point, i, a) => {\n  // start control point\n  const [cpsX, cpsY] = controlPoint(a[i - 1], a[i - 2], point) // end control point\n  const [cpeX, cpeY] = controlPoint(point, a[i - 1], a[i + 1], true)\n  const coordinates = (x: number, y: number) =>\n    [x.toFixed(FLOAT_DECIMALS), y.toFixed(FLOAT_DECIMALS)].join(',')\n  return [\n    'C',\n    coordinates(cpsX, cpsY),\n    coordinates(cpeX, cpeY),\n    coordinates(point[0], point[1])\n  ].join(' ')\n}\n\n// --\n\nexport const SvgCurveGraph: React.FC<SvgCurveGraphProps> = ({\n  data,\n  width: w = 600,\n  height: h = 120,\n  mt = 4,\n  mb = 56,\n  lastDate,\n  className,\n  ...props\n}) => {\n  const gradientId = React.useId()\n  const graphPoints = formatGraphData(data, { w, h, mt, mb })\n  if (graphPoints.length === 0) {\n    return null\n  }\n  const sum = data.reduce((sum, x) => sum + x)\n  // Floating point errors can cause subpixel gaps between \"bars\"\n  // and cause hover state to be dropped. Adding this margin closes this gap.\n  const mx = 0.0001 * w\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox={`0 0 ${w} ${h}`}\n      overflow=\"visible\"\n      className={twMerge(className)}\n      {...props}\n    >\n      <defs>\n        <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"1\" y2=\"0\">\n          <stop offset=\"0%\" stopColor=\"currentColor\" stopOpacity={0} />\n          <stop offset=\"100%\" stopColor=\"currentColor\" stopOpacity={0.2} />\n        </linearGradient>\n      </defs>\n      <g className=\"group/all\">\n        <g>\n          <path\n            // Background gradient\n            d={`${svgPath(graphPoints, bezierCommand)} ${lineCommand([\n              w,\n              h\n            ])} ${lineCommand([0, h])}`}\n            fill={`url(#${gradientId})`}\n            strokeWidth={0}\n          />\n          <path\n            // Curve\n            d={svgPath(graphPoints, bezierCommand)}\n            strokeWidth=\"2px\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          />\n        </g>\n        <g className=\"opacity-0 group-hover/all:opacity-100\">\n          {graphPoints.map(([x, y], i) => (\n            <g className=\"group/bar\" key={i}>\n              <rect\n                // This sets the size of the <g> group above\n                // so we can catch hover states on it.\n                x={x - (0.5 * w) / (graphPoints.length - 1) - mx}\n                y={mt}\n                width={w / (graphPoints.length - 1) + 2 * mx}\n                height={h}\n                fill=\"transparent\"\n              />\n              <circle\n                cx={x}\n                cy={y}\n                r={3}\n                strokeWidth={2}\n                className={twJoin(\n                  'opacity-0 transition-opacity ease-out group-hover/bar:opacity-100 group-hover/all:transition-none',\n                  'stroke-current',\n                  'fill-white dark:fill-gray-900'\n                )}\n              />\n              <text\n                className={twJoin(\n                  'select-none fill-current text-sm font-semibold tabular-nums',\n                  // Fade in and out when the whole graph is hovered, but\n                  // don't fade between bars\n                  'opacity-0 transition-opacity ease-out group-hover/bar:opacity-100 group-hover/all:transition-none'\n                )}\n                strokeWidth={2.5}\n                strokeLinejoin=\"round\"\n                x={w}\n                y={h}\n                dx={-8}\n                dy={-20}\n                textAnchor=\"end\"\n              >\n                {formatNumber(data[i])}\n              </text>\n              <text\n                className={twJoin(\n                  'select-none fill-gray-500 text-xs tabular-nums',\n                  // Fade in and out when the whole graph is hovered, but\n                  // don't fade between bars\n                  'opacity-0 transition-opacity ease-out group-hover/bar:opacity-100 group-hover/all:transition-none'\n                )}\n                strokeWidth={2.5}\n                strokeLinejoin=\"round\"\n                x={w}\n                y={h}\n                dy={-4}\n                dx={-8}\n                textAnchor=\"end\"\n              >\n                {Temporal.PlainDate.from(\n                  lastDate.toISOString().slice(0, 10)\n                )\n                  .subtract({ days: data.length - 1 - i })\n                  .toLocaleString('en', {\n                    day: '2-digit',\n                    month: 'short'\n                  })}\n              </text>\n            </g>\n          ))}\n        </g>\n        <g className=\"pointer-events-none select-none opacity-100 group-hover/all:opacity-0\">\n          <text\n            className=\"fill-current text-sm font-semibold tabular-nums\"\n            strokeWidth={2.5}\n            strokeLinejoin=\"round\"\n            x={w}\n            y={h}\n            dx={-8}\n            dy={-20}\n            textAnchor=\"end\"\n          >\n            {formatNumber(sum)}\n          </text>\n          <text\n            className=\"fill-gray-500 text-xs tabular-nums\"\n            strokeWidth={2.5}\n            strokeLinejoin=\"round\"\n            x={w}\n            y={h}\n            dy={-4}\n            dx={-8}\n            textAnchor=\"end\"\n          >\n            Last {data.length} days\n          </text>\n        </g>\n      </g>\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/hashvatar.client.tsx",
    "content": "'use client'\n\nimport React from 'react'\nimport { Input } from './forms/inputs'\nimport { Slider } from './forms/slider'\nimport { SHA256Avatar, SHA256AvatarProps, sha256 } from './hashvatar.server'\n\nexport function useHash(\n  text: string,\n  defaultValue = Array(64).fill('0').join('')\n) {\n  const [hash, setHash] = React.useState(defaultValue)\n  React.useEffect(() => {\n    sha256(text).then(setHash)\n  }, [text])\n  return hash\n}\n\n// --\n\nexport const AdjustableRadiusFactorSHA256Avatar: React.FC<\n  SHA256AvatarProps\n> = ({ ...props }) => {\n  const [radiusFactor, setRadiusFactor] = React.useState(1)\n  return (\n    <>\n      <SHA256Avatar {...props} radiusFactor={radiusFactor} transition={false} />\n      <div className=\"flex justify-between text-sm\">\n        <p>Equal Radii</p>\n        <p>Equal Areas</p>\n      </div>\n      <Slider\n        aria-label=\"slider-ex-1\"\n        value={radiusFactor}\n        onChange={setRadiusFactor}\n        //className=\"my-1\"\n        min={0}\n        max={1}\n        step={0.01}\n      />\n      <p className=\"!mt-2 text-center text-xs tabular-nums\">\n        Blend factor: {radiusFactor.toFixed(2)}\n      </p>\n    </>\n  )\n}\n\nexport const InteractiveAvatar: React.FC<\n  React.ComponentProps<typeof SHA256Avatar>\n> = ({ ...props }) => {\n  const [text, setText] = React.useState('Hello, world!')\n  const hash = useHash(\n    text,\n    '315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3'\n  )\n  React.useEffect(() => {\n    try {\n      const query = new URLSearchParams(location.search)\n      const demo = query.get('demo')\n      if (demo) {\n        setText(demo)\n      }\n    } catch {}\n  }, [])\n\n  const hashText = React.useMemo(() => {\n    return [hash.slice(0, hash.length / 2), hash.slice(hash.length / 2)].join(\n      '<wbr>'\n    )\n  }, [hash])\n\n  return (\n    <section className=\"space-y-8\">\n      <SHA256Avatar {...props} hash={hash} />\n      <p\n        className=\"text-center font-mono text-sm text-gray-500\"\n        dangerouslySetInnerHTML={{ __html: hashText }}\n      />\n      <Input\n        value={text}\n        onChange={e => setText(e.target.value)}\n        className=\"mx-auto max-w-xs\"\n      />\n    </section>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/hashvatar.server.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport async function sha256(message: string) {\n  // encode as UTF-8\n  const msgBuffer = new TextEncoder().encode(message)\n  // hash the message\n  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)\n  // convert ArrayBuffer to Array\n  const hashArray = Array.from(new Uint8Array(hashBuffer))\n  // convert bytes to hex string\n  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')\n  return hashHex\n}\n\nexport type Variants = 'normal' | 'stagger' | 'spider' | 'flower' | 'gem'\n\nexport type ColorMapper = (args: {\n  value: number // [0; 2 ^ bitCount - 1]\n  bitCount: number\n  hashSoul: number // [0-1]\n  circleSoul: number // [0-1]\n}) => string\n\nexport type SHA256AvatarProps = {\n  radiusFactor?: number\n  hash?: string\n  showGrid?: boolean\n  showLabels?: boolean\n  showSections?: boolean\n  mapColor?: ColorMapper\n  variant?: Variants\n  transition?: boolean\n}\n\ninterface Point {\n  x: number\n  y: number\n}\n\nfunction polarPoint(radius: number, angle: number): Point {\n  // Angle is expressed as [0,1[\n  // -Pi/2 to start at noon and go clockwise\n  // Trigonometric rotation + inverted Y = clockwise rotation, nifty!\n  return {\n    x: radius * Math.cos(2 * Math.PI * angle - Math.PI / 2),\n    y: radius * Math.sin(2 * Math.PI * angle - Math.PI / 2)\n  }\n}\n\nfunction moveTo({ x, y }: Point) {\n  return `M ${x} ${y}`\n}\n\nfunction lineTo({ x, y }: Point) {\n  return `L ${x} ${y}`\n}\n\nfunction arcTo({ x, y }: Point, radius: number, invert = false) {\n  return `A ${radius} ${radius} 0 0 ${invert ? 0 : 1} ${x} ${y}`\n}\n\ninterface GenerateSectionArgs {\n  value: string\n  index: number\n  outerRadius: number\n  innerRadius: number\n  horcrux: number\n  variant?: Variants\n}\n\nconst mapValueToColor: ColorMapper = ({ value, hashSoul, circleSoul }) => {\n  const colorH = value >> 4\n  const colorS = (value >> 2) & 0x03\n  const colorL = value & 0x03\n  const h = 360 * hashSoul + 120 * circleSoul + (30 * colorH) / 16\n  const s = 50 + (50 * colorS) / 4\n  const l = 50 + (40 * colorL) / 8\n  return `hsl(${h}, ${s}%, ${l}%)`\n}\n\nfunction generateSection({\n  value,\n  index,\n  outerRadius,\n  innerRadius,\n  horcrux,\n  variant = 'normal'\n}: GenerateSectionArgs) {\n  const circleIndex = Math.floor(index / 8)\n  const staggering =\n    variant === 'gem' || variant === 'flower'\n      ? circleIndex % 2 === 0\n        ? 0.5\n        : 0\n      : variant === 'stagger'\n        ? horcrux\n        : 0\n  const angle = (index + 0.5) / 8\n  const angleA = index / 8\n  const angleB = (index + 1) / 8\n  const angleOffset = staggering / 8\n  const arcRadius =\n    variant === 'gem'\n      ? 0\n      : variant === 'flower'\n        ? 0.25 * outerRadius\n        : outerRadius\n\n  const path = [\n    moveTo({ x: 0, y: 0 }),\n    lineTo(polarPoint(outerRadius, angleA)),\n    arcTo(polarPoint(outerRadius, angleB), arcRadius, variant === 'spider'),\n    'Z' // close the path\n  ].join(' ')\n\n  return {\n    path,\n    transform:\n      angleOffset !== 0 ? `rotate(${angleOffset.toFixed(6)}turn)` : undefined,\n    label: {\n      text: value,\n      ...polarPoint(\n        innerRadius === 0\n          ? outerRadius * 0.66\n          : innerRadius + (outerRadius - innerRadius) / 2,\n        angle\n      )\n    }\n  }\n}\n\nfunction getHashSoul(bytes: string[]) {\n  const circleSize = Math.round(bytes.length / 4)\n  const circles = [\n    bytes.slice(0, circleSize),\n    bytes.slice(1 * circleSize, 2 * circleSize),\n    bytes.slice(2 * circleSize, 3 * circleSize),\n    bytes.slice(3 * circleSize, 4 * circleSize)\n  ]\n  const xor = (xor: number, byte: string) => xor ^ parseInt(byte, 16)\n  return {\n    soul: (bytes.reduce(xor, 0) / 0xff) * 2 - 1,\n    horcruxes: circles.map(circle => (circle.reduce(xor, 0) / 0xff) * 2 - 1)\n  }\n}\n\n// --\n\nexport const SHA256Avatar: React.FC<\n  SHA256AvatarProps & React.ComponentProps<'svg'>\n> = ({\n  radiusFactor = 0.42,\n  hash = Array(64).fill('0').join(''),\n  showGrid = false,\n  showLabels = false,\n  showSections = true,\n  variant = 'normal',\n  mapColor = mapValueToColor,\n  width = '16rem',\n  height = '16rem',\n  transition = true,\n  ...props\n}) => {\n  const mix = (a: number, b: number) =>\n    a * radiusFactor + b * (1 - radiusFactor)\n\n  const r1 = variant === 'flower' ? 0.75 : 1\n  const r2 = mix((r1 * Math.sqrt(3)) / 2, r1 * 0.75)\n  const r3 = mix((r1 * Math.sqrt(2)) / 2, r1 * 0.5)\n  const r4 = mix(r1 * 0.5, r1 * 0.25)\n\n  const bytes = hash?.match(/.{1,2}/g)?.map(block => block) ?? []\n  const { soul, horcruxes } = getHashSoul(bytes)\n  const bitCount = Math.round((hash?.length ?? 0) / 64) // 32 sections = 64 hex characters\n\n  const innerRadii = [r2, r3, r4, 0]\n  const outerRadii = [r1, r2, r3, r4]\n  const sections = bytes.map((value, index) => {\n    const circleIndex = Math.floor(index / 8)\n    const innerRadius = innerRadii[circleIndex]\n    const outerRadius = outerRadii[circleIndex]\n    const horcrux = horcruxes[circleIndex]\n    return {\n      ...generateSection({\n        value,\n        index,\n        outerRadius,\n        innerRadius,\n        variant,\n        horcrux\n      }),\n      color: mapColor({\n        value: parseInt(value, 16),\n        bitCount,\n        hashSoul: soul,\n        circleSoul: horcrux\n      })\n    }\n  })\n\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"-1 -1 2 2\"\n      overflow=\"visible\"\n      width={width}\n      height={height}\n      {...props}\n    >\n      <g>\n        {sections.map((section, i) => (\n          <path\n            key={i}\n            d={section.path}\n            fill={showSections ? section.color : 'none'}\n            className={twMerge(\n              showGrid ? 'stroke-sky-500' : 'stroke-white dark:stroke-black',\n              transition\n                ? 'transition-transform duration-150 ease-out'\n                : undefined\n            )}\n            strokeWidth={0.02}\n            strokeLinejoin=\"round\"\n            style={{\n              transform: section.transform\n            }}\n          />\n        ))}\n      </g>\n      {showLabels && (\n        <g>\n          {sections.map((section, i) => (\n            <text\n              key={i}\n              x={section.label.x}\n              y={section.label.y + 0.03}\n              textAnchor=\"middle\"\n              fontSize={0.1}\n              fill=\"currentColor\"\n            >\n              {section.label.text}\n            </text>\n          ))}\n        </g>\n      )}\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/hire-me.tsx",
    "content": "import { FiMail } from 'react-icons/fi'\nimport { Note, NoteProps } from 'ui/components/note'\n\nconst AVAILABLE = undefined // eg: 'January 2026'\n\ntype HireMeProps = Omit<NoteProps, 'status' | 'title' | 'children'>\n\nexport const HireMe: React.FC<HireMeProps> = ({ ...props }) => {\n  return (\n    <Note\n      status=\"success\"\n      icon={(props: React.ComponentProps<'span'>) => <span {...props}>🤝</span>}\n      title=\"Hire me!\"\n      titleClass=\"text-lg underline decoration-dotted\"\n      innerClass=\"space-y-2 pt-2\"\n      {...props}\n    >\n      <p>\n        I build <strong>web apps</strong> for startups, businesses and public\n        institutions as a <strong>freelance</strong>{' '}web developer and designer.\n        Let&apos;s <strong>discuss your needs</strong> and see how I can help.\n      </p>\n      {typeof AVAILABLE === 'string' && (\n        <p>\n          <em>\n            <strong className=\"text-current\">Note:</strong> my earliest\n            availability is{' '}\n            <strong className=\"text-current\">{AVAILABLE}</strong>.\n          </em>\n        </p>\n      )}\n      <a\n        href=\"mailto:freelance@francoisbest.com\"\n        className=\"inline-block rounded-sm bg-green-600 px-4 py-2 text-lg !text-white no-underline hover:bg-green-500 hover:no-underline active:bg-green-600/75\"\n      >\n        <FiMail className=\"-mt-[2px] mr-2 inline-block\" />\n        Contact me\n      </a>\n    </Note>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/local-time.tsx",
    "content": "'use client'\n\nimport { Suspense } from 'react'\nimport { formatDate, formatTime } from 'ui/format'\nimport { useHydration } from 'ui/hooks/useHydration'\n\ntype Props = React.ComponentProps<'time'> & {\n  date: Date | string | number\n  hydratedSuffix?: React.ReactNode\n}\n\nexport function LocalDate({ date, hydratedSuffix = null, ...props }: Props) {\n  const iso = new Date(date).toISOString()\n  const hydrated = useHydration()\n  return (\n    <Suspense key={hydrated ? 'local' : 'utc'}>\n      <time dateTime={iso} title={iso} {...props}>\n        {formatDate(date)}\n        {hydrated ? hydratedSuffix : ' (UTC)'}\n      </time>\n    </Suspense>\n  )\n}\n\nexport function LocalTime({ date, hydratedSuffix = null, ...props }: Props) {\n  const iso = new Date(date).toISOString()\n  const hydrated = useHydration()\n  return (\n    <Suspense key={hydrated ? 'local' : 'utc'}>\n      <time dateTime={iso} title={iso} {...props}>\n        {formatDate(date)}\n        {hydrated ? hydratedSuffix : ' (UTC)'}\n      </time>\n    </Suspense>\n  )\n}\n\nexport function LocalDateTime({\n  date,\n  separator = ', ',\n  hydratedSuffix = null,\n  ...props\n}: Props & {\n  separator?: string\n}) {\n  const iso = new Date(date).toISOString()\n  const hydrated = useHydration()\n  return (\n    <Suspense key={hydrated ? 'local' : 'utc'}>\n      <time dateTime={iso} title={iso} {...props}>\n        {formatDate(date)}\n        {separator}\n        {formatTime(date)}\n        {hydrated ? hydratedSuffix : ' (UTC)'}\n      </time>\n    </Suspense>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/logo.tsx",
    "content": "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: 'w-16 h-16'\n} as const\n\ntype LogoProps = React.ComponentProps<'svg'> & {\n  size?: keyof typeof sizes\n  background?: boolean\n}\n\nexport const Logo: React.FC<LogoProps> = ({\n  size = 8,\n  background = true,\n  className,\n  ...props\n}) => {\n  const wh = sizes[size]\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      aria-label=\"Logo\"\n      viewBox=\"0 0 148 144\"\n      width={size * 4 + 'px'}\n      height={size * 4 + 'px'}\n      className={twMerge(wh, 'overflow-visible', className)}\n      {...props}\n    >\n      {background && <circle fill=\"#fff\" cx=\"74\" cy=\"72\" r=\"85\" />}\n      <path\n        d=\"M99.4622 61.7822C99.6547 64.3862 97.4912 66.0106 96.2381 67.6166C92.354 72.5825 88.3737 77.5347 83.9525 82.205C74.3494 82.705 62.9984 82.7484 53.5484 83.1259C51.744 83.1972 49.2684 83.8309 48.6347 82.0513C48.3722 79.4031 50.6115 77.81 51.8587 76.2166C55.7303 71.2644 59.6778 66.3819 63.9903 61.6284C73.8815 61.0584 85.4056 61.1609 95.1631 60.7072C96.769 60.63 98.925 60.0991 99.4622 61.7822ZM109.291 19.5531C110.909 18.3309 112.694 17.3069 113.744 16.0213C114.556 15.0294 116.073 12.6878 116.047 10.9538C116.003 8.06813 111.959 2.36688 110.058 1.27938C106.219 -0.921559 103.014 3.44188 100.384 5.88594C86.9544 18.3628 75.13 31.6013 62.1475 44.5831C61.124 45.6069 60.1006 47.1678 59.0772 47.5006C57.285 48.0831 54.1687 47.8653 51.859 47.9616C43.9512 48.2944 36.4394 48.1344 28.979 47.8078C18.8184 47.36 10.584 45.1975 0.5703 45.3509C-0.30595 50.4375 2.48967 55.825 5.17717 58.5569C9.06092 62.5047 16.6044 63.0103 24.6794 63.0103C31.66 63.0103 39.1525 62.5878 45.1028 62.3959C42.4922 65.5178 39.114 69.0181 35.7359 72.8369C32.9715 75.9663 28.2497 80.0675 26.8294 84.0478C25.249 88.4753 27.8725 93.5491 29.5937 96.9469C30.9181 99.5634 32.9784 104.183 36.3506 104.778C37.4765 104.976 39.044 104.414 40.4965 104.164C44.9628 103.403 48.7694 102.302 52.1672 101.399C57.7081 99.9287 62.8906 99.5888 68.444 98.4816C60.4528 107.248 49.46 115.949 39.5753 123.512C38.0715 124.665 36.3375 125.707 35.1215 126.89C33.9125 128.068 32.089 130.641 32.0506 132.727C32.0122 134.966 33.7972 137.417 34.9684 139.176C36.184 141.006 38.0844 143.354 40.3428 143.322C42.5119 143.29 45.5125 140.04 47.2531 138.408C53.594 132.464 59.2625 126.398 65.2197 120.441C71.1831 114.478 77.2612 108.573 83.0322 102.321C84.4656 100.766 87.2362 96.89 89.0209 96.3325C90.9278 95.7378 93.8909 95.9678 96.239 95.8725C104.019 95.545 111.953 95.6928 119.273 96.025C129.395 96.4863 137.808 98.8091 147.528 98.3294C148.02 92.7109 145.724 88.1431 142.921 85.2763C135.94 78.1231 115.153 81.6284 102.996 81.4375C105.548 78.3022 108.888 74.8728 112.208 71.1491C115.235 67.7575 119.509 64.085 121.115 60.0925C122.964 55.505 120.13 50.3288 118.352 46.7331C117.001 44.0009 115.017 39.49 111.441 39.055C109.036 38.7606 105.849 39.8997 103.302 40.4369C95.439 42.0944 88.0997 44.4869 79.6544 45.3509C87.8362 36.5534 99.0975 27.2247 109.291 19.5531Z\"\n        fill=\"#2f2f2f\"\n        className={background ? undefined : 'dark:fill-white'}\n      />\n    </svg>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/note.tsx",
    "content": "import {\n  FiAlertCircle,\n  FiAlertTriangle,\n  FiCheckCircle,\n  FiInfo,\n  FiPaperclip\n} from 'react-icons/fi'\nimport { twMerge } from 'tailwind-merge'\n\ntype NoteStatus = 'default' | 'info' | 'success' | 'warning' | 'error'\ntype NoteConfig = {\n  colors: string\n  stroke: string\n  icon: any\n}\n\nconst noteConfigs: Record<NoteStatus, NoteConfig> = {\n  default: {\n    colors:\n      'border-l-gray-400 bg-gray-50 dark:border-l-gray-600 dark:bg-gray-800/40',\n    stroke: 'stroke-gray-500',\n    icon: FiPaperclip\n  },\n  info: {\n    colors:\n      'border-l-blue-500 bg-sky-50 text-blue-950 dark:bg-sky-950/40 dark:text-blue-100',\n    stroke: 'stroke-blue-500',\n    icon: FiInfo\n  },\n  success: {\n    colors:\n      'border-l-green-500 bg-green-50 text-green-950 dark:bg-green-950/30 dark:text-green-100',\n    stroke: 'stroke-green-500',\n    icon: FiCheckCircle\n  },\n  warning: {\n    colors:\n      'border-l-amber-500 bg-amber-50 text-amber-950 dark:bg-amber-950/40 dark:text-amber-100',\n    stroke: 'stroke-amber-500',\n    icon: FiAlertTriangle\n  },\n  error: {\n    colors:\n      'border-l-red-500 bg-red-50 text-red-950 dark:bg-red-950/40 dark:text-red-100',\n    stroke: 'stroke-red-500',\n    icon: FiAlertCircle\n  }\n}\n\n// --\n\nexport type NoteProps = React.ComponentProps<'aside'> & {\n  status?: NoteStatus\n  title?: React.ReactNode\n  icon?: any\n  children: React.ReactNode\n  titleClass?: string\n  outerClass?: string\n  innerClass?: string\n}\n\nexport const Note: React.FC<NoteProps> = ({\n  status = 'default',\n  title = 'Note',\n  icon,\n  children,\n  titleClass = '',\n  outerClass = '',\n  innerClass = '',\n  ...props\n}) => {\n  const { colors, stroke, icon: defaultIcon } = noteConfigs[status]\n  const Icon = (typeof icon === 'string' ? () => icon : icon) ?? defaultIcon\n  return (\n    <aside\n      role=\"note\"\n      aria-details={status}\n      className={twMerge(\n        'rounded-xs -mx-2 my-6 border-l-4 bg-opacity-50 px-4 pb-4 pt-3 md:mx-0 dark:bg-opacity-40',\n        colors,\n        outerClass\n      )}\n      {...props}\n    >\n      <p className=\"not-prose !my-1 text-lg leading-snug\">\n        <Icon\n          className={`-mt-1 mr-1 inline-block ${stroke}`}\n          role=\"presentation\"\n          aria-hidden\n        />{' '}\n        <span className={twMerge('font-semibold', titleClass)}>{title}</span>\n      </p>\n      <div className={twMerge('!prose-base text-current', innerClass)}>\n        {children}\n      </div>\n    </aside>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/stat.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport const Stat: React.FC<React.ComponentProps<'dl'>> = ({\n  className,\n  ...props\n}) => {\n  return <dl className={twMerge('me-auto', className)} {...props} />\n}\n\nexport const StatLabel: React.FC<React.ComponentProps<'dt'>> = ({\n  className,\n  ...props\n}) => <dt className={twMerge('text-sm font-medium', className)} {...props} />\n\nexport const StatNumber: React.FC<React.ComponentProps<'dd'>> = ({\n  className,\n  ...props\n}) => <dd className={twMerge('text-2xl font-semibold', className)} {...props} />\n\nexport const StatHelpText: React.FC<React.ComponentProps<'dd'>> = ({\n  className,\n  ...props\n}) => <dd className={twMerge('text-sm text-gray-500', className)} {...props} />\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/tag.tsx",
    "content": "import Link, { LinkProps } from 'next/link'\nimport { twMerge } from 'tailwind-merge'\n\nconst tagClassName =\n  'flex h-5 items-center px-2 bg-indigo-50 text-indigo-800 dark:bg-indigo-950 dark:text-indigo-200 rounded-md text-xs font-semibold'\n\ntype StaticTagProps = React.ComponentProps<'span'> & {\n  children: React.ReactNode\n}\ntype LinkedTagProps = LinkProps & {\n  className?: string\n  children: React.ReactNode\n}\n\nexport const StaticTag: React.FC<StaticTagProps> = ({\n  className,\n  ...props\n}) => <span className={twMerge(tagClassName, className)} {...props} />\n\nexport const LinkedTag: React.FC<LinkedTagProps> = ({\n  className,\n  ...props\n}) => <Link className={twMerge(tagClassName, className)} {...props} />\n\n// --\n\ntype TagsNavProps = React.ComponentProps<'nav'> & {\n  tags: string[]\n}\n\nexport const TagsNav: React.FC<TagsNavProps> = ({ tags, className }) => {\n  return (\n    <nav aria-label=\"Tagged\" className={twMerge('flex gap-2', className)}>\n      {tags.map(tag => (\n        <LinkedTag key={tag} href={`/posts/tags/${tag}`}>\n          {tag}\n        </LinkedTag>\n      ))}\n    </nav>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/components/theme-controls.tsx",
    "content": "'use client'\n\nimport { useTheme } from 'next-themes'\nimport React from 'react'\nimport { FiMoon, FiSun } from 'react-icons/fi'\nimport { TbSunMoon } from 'react-icons/tb'\nimport { twMerge } from 'tailwind-merge'\nimport { IconButton, IconButtonProps } from 'ui/components/buttons/icon-button'\nimport { useHydration } from 'ui/hooks/useHydration'\n\ntype ThemeControlsProps = Omit<IconButtonProps, 'aria-label' | 'icon'>\n\nexport const ThemeControls: React.FC<ThemeControlsProps> = ({\n  className,\n  ...props\n}) => {\n  const { theme, setTheme } = useTheme()\n  const hydrated = useHydration()\n\n  const onClick = React.useCallback(() => {\n    if (theme === 'light') {\n      setTheme('dark')\n    } else if (theme === 'dark') {\n      setTheme('system')\n    } else {\n      setTheme('light')\n    }\n  }, [theme, setTheme])\n\n  const icon =\n    !hydrated || theme === 'system' ? (\n      <TbSunMoon />\n    ) : theme === 'dark' ? (\n      <FiMoon />\n    ) : (\n      <FiSun />\n    )\n\n  return (\n    <IconButton\n      icon={icon}\n      variant=\"ghost\"\n      className={twMerge('rounded-full', className)}\n      onClick={onClick}\n      aria-label={hydrated ? `Theme: ${theme}` : ''}\n      title={hydrated ? `Theme: ${theme}` : ''}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/blog-post-embed.tsx",
    "content": "import { getPost } from 'lib/blog'\nimport { FiBookmark } from 'react-icons/fi'\nimport { EmbedFrame, EmbedFrameProps } from 'ui/embeds/embed-frame'\nimport { BlogPostPreview } from '../../app/(pages)/posts/components/blog-post-preview'\n\ntype BlogPostEmbedProps = Omit<EmbedFrameProps, 'Icon' | 'children'> & {\n  slug: string[]\n}\n\nexport const BlogPostEmbed: React.FC<BlogPostEmbedProps> = async ({\n  className = 'my-8',\n  slug\n}) => {\n  const post = await getPost(slug)\n  if (!post) return null\n  return (\n    <EmbedFrame Icon={FiBookmark} className={className}>\n      <BlogPostPreview Heading=\"h3\" {...post} />\n    </EmbedFrame>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/embed-frame.tsx",
    "content": "import { twMerge } from 'tailwind-merge'\n\nexport type EmbedFrameProps = React.ComponentProps<'section'> & {\n  Icon: React.ComponentType\n  children: React.ReactNode\n  iconFill?: boolean\n  isError?: boolean\n}\n\nexport const EmbedFrame: React.FC<EmbedFrameProps> = ({\n  Icon,\n  children,\n  className,\n  iconFill = false,\n  isError = false,\n  ...props\n}) => {\n  return (\n    <section\n      className={twMerge(\n        'relative rounded-sm border px-4 py-3 shadow-md',\n        isError\n          ? 'border-red-400 bg-red-50/50 dark:border-red-800 dark:bg-red-950/50'\n          : 'bg-light border-gray-200 dark:border-gray-800 dark:bg-gray-900 dark:shadow-inner',\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <Icon\n        // @ts-ignore\n        role=\"presentation\"\n        aria-hidden\n        className={twMerge(\n          'absolute -top-1 right-2 !my-0',\n          iconFill\n            ? isError\n              ? 'fill-red-500'\n              : 'fill-gray-400 dark:fill-gray-500'\n            : isError\n              ? 'fill-red-50/50 stroke-red-500 dark:fill-red-950/50'\n              : 'fill-white stroke-gray-500 dark:fill-gray-900/50'\n        )}\n      />\n    </section>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/github-repo.tsx",
    "content": "import { fetchRepository } from 'lib/services/github'\nimport {\n  FiAlertCircle,\n  FiFileText,\n  FiGitPullRequest,\n  FiGithub,\n  FiStar,\n  FiTag\n} from 'react-icons/fi'\nimport { twMerge } from 'tailwind-merge'\nimport { EmbedFrame } from './embed-frame'\n\ntype GitHubRepoProps = React.ComponentProps<'section'> & {\n  slug: string\n}\n\nexport const GitHubRepo: React.FC<GitHubRepoProps> = async ({\n  slug,\n  className = 'my-8',\n  children,\n  ...props\n}) => {\n  const github = await fetchRepository(slug).catch(error => {\n    console.group('Failed to fetch GitHub repository data')\n    console.error(`repo: ${slug}`)\n    console.dir(error)\n    console.groupEnd()\n    return null\n  })\n  if (!github) {\n    return (\n      <EmbedFrame\n        Icon={FiGithub}\n        className={twMerge('not-prose space-y-4', className)}\n        {...props}\n      >\n        <h3 className=\"mt-0 text-xl font-semibold text-gray-900 dark:text-gray-100\">\n          <a href={`https://github.com/${slug}`}>{slug}</a>\n        </h3>\n        <p className=\"text-sm text-red-700 dark:text-red-400\">\n          GitHub data is currently unavailable.\n        </p>\n      </EmbedFrame>\n    )\n  }\n  return (\n    <EmbedFrame\n      Icon={FiGithub}\n      className={twMerge('not-prose space-y-4', className)}\n      {...props}\n    >\n      <h3 className=\"mt-0 text-xl font-semibold text-gray-900 dark:text-gray-100\">\n        <a href={github.url}>{slug}</a>\n      </h3>\n      <p>{github.description}</p>\n      {children}\n      <ul className=\"flex space-x-6 text-sm text-gray-500\">\n        {github.stars > 0 && (\n          <MetaListItem Icon={FiStar} text={github.stars} iconAlt=\"Stars\" />\n        )}\n        <MetaListItem\n          Icon={FiAlertCircle}\n          text={github.issues}\n          iconAlt=\"Open Issues\"\n        />\n        <MetaListItem\n          Icon={FiGitPullRequest}\n          text={github.prs}\n          iconAlt=\"Open Pull Requests\"\n        />\n        {Boolean(github.version) && (\n          <MetaListItem\n            Icon={FiTag}\n            text={`v${github.version}`}\n            iconAlt=\"Last release\"\n          />\n        )}\n        {Boolean(github.license) && (\n          <MetaListItem Icon={FiFileText} text={github.license} iconAlt=\"License\" />\n        )}\n      </ul>\n    </EmbedFrame>\n  )\n}\n\n// --\n\ntype MetaListItemProps = {\n  Icon: React.ComponentType\n  iconAlt: string\n  text?: string | number\n}\n\nconst MetaListItem: React.FC<MetaListItemProps> = ({\n  Icon,\n  iconAlt,\n  text = '--'\n}) => {\n  return (\n    <li className=\"flex items-center gap-x-2\" title={iconAlt}>\n      <Icon\n        // @ts-ignore\n        role=\"img\"\n        aria-hidden\n      />\n      {text}\n    </li>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/hacker-news.tsx",
    "content": "import { SiYcombinator } from '@icons-pack/react-simple-icons'\nimport { getHackerNewsItem } from 'lib/services/hacker-news'\nimport { LocalDateTime } from 'ui/components/local-time'\n\ntype HackerNewsCommentProps = {\n  url: string\n}\n\nexport async function HackerNewsComment({ url }: HackerNewsCommentProps) {\n  try {\n    const data = await getHackerNewsItem(url)\n    return (\n      <blockquote className=\"hacker-news border-l-orange-500\">\n        <div dangerouslySetInnerHTML={{ __html: data.text }} />\n        <figcaption className=\"flex !text-sm\">\n          <a\n            href={`https://news.ycombinator.com/user?id=${data.by}`}\n            className=\"text-current no-underline\"\n          >\n            {data.by}\n          </a>\n          &nbsp;|&nbsp;\n          <a href={url} className=\"text-current no-underline\">\n            <LocalDateTime date={data.time} />\n          </a>\n          <SiYcombinator\n            title=\"Hacker News\"\n            className=\"ml-auto mr-3 inline-block text-orange-500\"\n            size={16}\n          />\n        </figcaption>\n      </blockquote>\n    )\n  } catch (error) {\n    console.error(error)\n    return (\n      <blockquote className=\"hacker-news border-l-orange-500\">\n        <p>\n          Failed to fetch Hacker News item:\n          <br />\n          <span className=\"text-red-500\">{String(error)}</span>\n        </p>\n        <a href={url}>{url}</a>\n        <figcaption className=\"flex !text-sm\">\n          <SiYcombinator\n            title=\"Hacker News\"\n            className=\"ml-auto mr-3 inline-block text-orange-500\"\n            size={16}\n          />\n        </figcaption>\n      </blockquote>\n    )\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/npm-package.tsx",
    "content": "import { fetchRepository } from 'lib/services/github'\nimport { fetchNpmPackage, type NpmPackageStatsData } from 'lib/services/npm'\nimport React from 'react'\nimport {\n  FiDownload,\n  FiFileText,\n  FiPackage,\n  FiStar,\n  FiTag\n} from 'react-icons/fi'\n\nimport Image from 'next/image'\nimport { twMerge } from 'tailwind-merge'\nimport { SvgCurveGraph } from 'ui/components/graphs/svg-curve-graph'\nimport { Logo } from 'ui/components/logo'\nimport { formatStatNumber } from 'ui/format'\nimport { EmbedFrame, EmbedFrameProps } from './embed-frame'\n\nexport type NpmPackageProps = Omit<EmbedFrameProps, 'Icon' | 'children'> & {\n  pkg: string\n  repo: string\n  accent?: string\n  versionRollout?: number\n  npmData?: NpmPackageStatsData | null\n  children?: React.ReactNode\n}\n\nexport const NpmPackage: React.FC<NpmPackageProps> = async ({\n  pkg,\n  repo,\n  accent = 'text-blue-500',\n  className = 'my-8',\n  children,\n  npmData,\n  versionRollout = 5,\n  ...props\n}) => {\n  const [npmResult, github] = await Promise.all([\n    npmData !== undefined\n      ? Promise.resolve(npmData)\n      : fetchNpmPackage(pkg).catch(error => {\n          console.group('Failed to fetch NPM package data')\n          console.error(`package: ${pkg}`)\n          console.dir(error)\n          console.groupEnd()\n          return null\n        }),\n    fetchRepository(repo).catch(error => {\n      console.group('Failed to fetch GitHub repository data')\n      console.error(`repo: ${repo}`)\n      console.dir(error)\n      console.groupEnd()\n      return null\n    })\n  ])\n\n  if (!github && !npmResult) {\n    return (\n      <EmbedFrame\n        Icon={FiPackage}\n        className={twMerge('not-prose', className)}\n        {...props}\n      >\n        <div className=\"p-4 text-center text-sm text-red-700 dark:text-red-400\">\n          <FiPackage className=\"mr-1 inline-block -translate-y-px\" /> Package\n          data is currently unavailable.\n        </div>\n      </EmbedFrame>\n    )\n  }\n\n  return (\n    <EmbedFrame\n      Icon={FiPackage}\n      className={twMerge('relative px-0', className)}\n      {...props}\n    >\n      <data aria-hidden className=\"hidden\">\n        {npmResult && (\n          <>\n            NPM updated at: {npmResult.updatedAt.toISOString()}\n            <br />\n          </>\n        )}\n        {github && <>GitHub updated at: {github.updatedAt.toISOString()}</>}\n      </data>\n      <figure className=\"not-prose !my-0\">\n        <div className=\"px-4\">\n          <header\n            className=\"mb-2 flex flex-wrap justify-between gap-2\"\n            style={{ alignItems: 'last baseline' }}\n          >\n            <a href={github?.url ?? `https://github.com/${repo}`}>\n              <h3 className=\"mt-0 flex items-center text-xl font-semibold text-gray-900 dark:text-gray-100\">\n                {github && (\n                  <Image\n                    width={24}\n                    height={24}\n                    src={github.avatarUrl}\n                    alt={`Avatar for GitHub account ${repo.split('/')[0]}`}\n                    className=\"mr-2 rounded-full\"\n                  />\n                )}\n                {repo}\n              </h3>\n            </a>\n            <div className=\"flex gap-6 text-sm text-gray-500\">\n              {github && (\n                <dl className=\"flex items-center gap-1\" title=\"Stars\">\n                  <FiStar />\n                  <dd>{formatStatNumber(github.stars)}</dd>\n                </dl>\n              )}\n              {npmResult && (\n                <dl\n                  className=\"flex items-center gap-1\"\n                  title=\"NPM downloads (all time)\"\n                >\n                  <FiDownload />\n                  <dd>{formatStatNumber(npmResult.allTime)}</dd>\n                </dl>\n              )}\n              {github?.version && (\n                <dl className=\"flex items-center gap-1\" title=\"Latest version\">\n                  <FiTag />\n                  <span>{github.version}</span>\n                </dl>\n              )}\n              {github?.license && (\n                <dl className=\"flex items-center gap-1\" title=\"License\">\n                  <FiFileText />\n                  <dd>{github.license.split(' ')[0]}</dd>\n                </dl>\n              )}\n            </div>\n          </header>\n          {github?.description && (\n            <p className=\"my-4\">{github.description}</p>\n          )}\n          {children}\n          <pre className=\"my-4 rounded-sm border border-gray-200 bg-gray-50/50 !p-2 text-sm dark:border-gray-800 dark:bg-gray-950 dark:shadow-inner\">\n            <details className=\"text-gray-500\">\n              <summary>\n                <span className=\"select-none text-red-500/75\">$ </span>pnpm add{' '}\n                <a\n                  href={`https://www.npmjs.com/package/${pkg}`}\n                  className={accent}\n                >\n                  {pkg}\n                </a>\n              </summary>\n              <div>\n                <span className=\"ml-1 select-none text-red-500/75\"> $ </span>\n                yarn add {pkg}\n              </div>\n              <div>\n                <span className=\"ml-1 select-none text-red-500/75\"> $ </span>\n                npm install {pkg}\n              </div>\n            </details>\n          </pre>\n        </div>\n        {npmResult && versionRollout && (\n          <VersionRollout\n            versions={npmResult.versions}\n            accent={accent}\n            limit={versionRollout}\n            latestVersion={github?.version}\n          />\n        )}\n        {npmResult && npmResult.last30Days && (\n          <SvgCurveGraph\n            data={npmResult.last30Days}\n            className={accent}\n            height={120}\n            lastDate={npmResult.lastDate}\n          />\n        )}\n        {!npmResult && (\n          <div className=\"mb-8 p-4 text-center text-sm text-red-700 dark:text-red-400\">\n            <FiPackage className=\"mr-1 inline-block -translate-y-px\" /> NPM\n            package data is currently unavailable.\n          </div>\n        )}\n        <footer\n          role=\"presentation\"\n          className=\"absolute bottom-3 left-3 flex h-6 items-center gap-2 text-sm\"\n        >\n          <Logo size={6} background={false} />\n          <span className=\"text-md font-semibold\">47ng</span>\n          <span className=\"text-gray-500/80\">•</span>\n          <a href=\"https://francoisbest.com/open-source\" className={accent}>\n            francoisbest.com\n            <span className=\"text-gray-500/80\">/open-source</span>\n          </a>\n        </footer>\n      </figure>\n    </EmbedFrame>\n  )\n}\n\n// --\n\ntype VersionRolloutProps = {\n  versions: Record<string, number>\n  accent: string\n  limit: number\n  latestVersion?: string\n}\n\nconst VersionRollout: React.FC<VersionRolloutProps> = ({\n  versions,\n  accent,\n  latestVersion,\n  limit\n}) => {\n  const data = Object.entries(versions).slice(0, limit)\n  const totalCount = Object.values(versions).reduce(\n    (sum, count) => sum + count,\n    0\n  )\n  return (\n    <>\n      <div className=\"px-4 pb-2 text-xs\">\n        <p className=\"mb-1 flex text-gray-500\">\n          Version rollout\n          <span className=\"ml-auto\">Last week</span>\n        </p>\n        {data.map(([version, count]) => (\n          <div key={version} className=\"relative flex\">\n            <span\n              className={twMerge(\n                'relative z-10 font-mono',\n                version === latestVersion ? accent : undefined,\n                version === latestVersion ? 'font-semibold' : undefined\n              )}\n            >\n              {version}\n            </span>\n            <div\n              aria-hidden\n              className={twMerge(\n                'rounded-xs absolute bottom-0 left-0 right-24 top-0 my-0.5 appearance-none bg-current opacity-10',\n                accent\n              )}\n              style={{\n                maxWidth: `${(100 * count) / totalCount}%`\n              }}\n            />\n            <div className=\"ml-auto text-right tabular-nums\">\n              <span className={twMerge('font-semibold', accent)}>\n                {formatStatNumber(count)}\n              </span>{' '}\n              <span className=\"text-gray-500\">\n                ({((100 * count) / totalCount).toFixed(0).padStart(2, '0')}%)\n              </span>\n            </div>\n          </div>\n        ))}\n      </div>\n    </>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/spotify-album.tsx",
    "content": "import Image from 'next/image'\nimport React, { Suspense } from 'react'\nimport ReactDOM from 'react-dom'\nimport { WideContainer } from 'ui/layouts/wide-container'\nimport { SpotifyData, SpotifyLoader } from './spotify-loader'\n\nexport const SpotifyAlbumGrid: React.FC<{\n  children: React.ReactNode\n}> = props => (\n  <WideContainer>\n    <section\n      role=\"feed\"\n      aria-busy={false}\n      className=\"not-prose grid grid-cols-1 justify-center gap-x-4 gap-y-8 overflow-hidden sm:grid-cols-2 md:grid-cols-3\"\n      {...props}\n    />\n  </WideContainer>\n)\n\ntype SpotifyAlbumProps = Partial<SpotifyData> & {\n  url: string\n}\n\nexport function SpotifyAlbum({ url, ...props }: SpotifyAlbumProps) {\n  return (\n    <Suspense fallback={<EmptyAlbumView type=\"loading\" />}>\n      <SpotifyLoader\n        url={url}\n        Success={SpotifyAlbumView}\n        Failure={EmptyAlbumView}\n        {...props}\n      />\n    </Suspense>\n  )\n}\n\ntype EmptyAlbumViewProps = {\n  type: 'loading' | 'error'\n}\n\nexport const EmptyAlbumView: React.FC<EmptyAlbumViewProps> = ({\n  type = 'error'\n}) => {\n  return (\n    <figure>\n      <Image\n        width={256}\n        height={256}\n        src={\n          type === 'error'\n            ? '/img/album-cover-missing.jpg'\n            : '/img/album-cover-placeholder.jpg'\n        }\n        alt=\"Album not found\"\n        className=\"font-xs relative mx-auto flex h-64 w-64 items-center justify-center overflow-hidden rounded-sm bg-black drop-shadow-lg\"\n      />\n      <figcaption className=\"mt-2 text-center text-sm italic text-gray-500\">\n        {type === 'error' ? 'Missing album data' : 'Loading...'}\n      </figcaption>\n    </figure>\n  )\n}\n\nconst SpotifyAlbumView = ({ link, title, artist, image }: SpotifyData) => {\n  ReactDOM.preconnect('https://i.scdn.co')\n  ReactDOM.prefetchDNS('https://i.scdn.co')\n  return (\n    <figure>\n      <a href={link}>\n        <Image\n          width={256}\n          height={256}\n          src={image}\n          alt={`${title}, an album by ${artist}`}\n          className=\"font-xs relative mx-auto flex h-64 w-64 items-center justify-center overflow-hidden rounded-sm bg-gray-200 drop-shadow-lg dark:bg-gray-800\"\n          unoptimized\n          crossOrigin=\"anonymous\"\n        />\n      </a>\n      <figcaption className=\"mt-2 text-center text-sm text-gray-500\">\n        <a href={link}>{title === artist ? title : title + ' • ' + artist}</a>\n      </figcaption>\n    </figure>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/spotify-artist.tsx",
    "content": "import Image from 'next/image'\nimport { Suspense } from 'react'\nimport ReactDOM from 'react-dom'\nimport { SpotifyData, SpotifyLoader } from './spotify-loader'\n\nexport const SpotifyArtistGrid: React.FC<{\n  children: React.ReactNode\n}> = props => (\n  <section\n    role=\"feed\"\n    aria-busy={false}\n    className=\"not-prose grid grid-cols-2 gap-x-4 gap-y-8 overflow-hidden md:grid-cols-4\"\n    {...props}\n  />\n)\n\ntype SpotifyArtistProps = Partial<SpotifyData> & {\n  url: string\n}\n\nexport function SpotifyArtist({ url, ...props }: SpotifyArtistProps) {\n  return (\n    <Suspense fallback={<SpotifyArtistLoading />}>\n      <SpotifyLoader\n        url={url}\n        Success={SpotifyArtistView}\n        Failure={SpotifyArtistError}\n        {...props}\n      />\n    </Suspense>\n  )\n}\n\nexport const SpotifyArtistLoading: React.FC = props => (\n  <div\n    role=\"presentation\"\n    aria-hidden\n    className=\"mx-auto h-24 w-24 animate-pulse rounded-full bg-gray-300\"\n    {...props}\n  />\n)\nexport const SpotifyArtistError: React.FC = props => (\n  <SpotifyArtistView\n    title=\"No artist data\"\n    artist=\"No artist data\"\n    image=\"#\"\n    link=\"#\"\n    {...props}\n  />\n)\n\nexport const SpotifyArtistView: React.FC<SpotifyData> = ({\n  title,\n  link,\n  image,\n  ...props\n}) => {\n  ReactDOM.preconnect('https://i.scdn.co')\n  ReactDOM.prefetchDNS('https://i.scdn.co')\n  return (\n    <figure className=\"text-center\" {...props}>\n      <a\n        href={link}\n        className=\"mx-auto block h-24 w-24 overflow-hidden rounded-full drop-shadow-lg\"\n      >\n        <Image width={24 * 4} height={24 * 4} src={image} alt={title} />\n      </a>\n      <figcaption className=\"mt-2 text-center text-sm text-gray-500\">\n        <a href={link}>{title}</a>\n      </figcaption>\n    </figure>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/embeds/spotify-loader.tsx",
    "content": "import { SpotifyApi } from '@spotify/web-api-ts-sdk'\nimport { env } from 'lib/env'\nimport { parse as parseSpotifyUri } from 'spotify-uri'\nimport { z } from 'zod'\n\nconst spotify = SpotifyApi.withClientCredentials(\n  env.SPOTIFY_CLIENT_ID,\n  env.SPOTIFY_CLIENT_SECRET\n)\n\nconst spotifyDataSchema = z.object({\n  title: z.string(),\n  artist: z.string(),\n  image: z.string().url(),\n  link: z.string().url()\n})\n\nexport type SpotifyData = z.infer<typeof spotifyDataSchema>\n\ntype SpotifyLoaderProps<OtherProps> = OtherProps & {\n  url: string\n  'aria-label'?: string\n  Success: React.FC<SpotifyData>\n  Failure: React.FC<any>\n}\n\nexport async function SpotifyLoader<OtherProps>({\n  url,\n  Success,\n  Failure,\n  ...props\n}: SpotifyLoaderProps<OtherProps>) {\n  try {\n    const data = await loadSpotifyData(url)\n    return <Success {...data} {...props} />\n  } catch (error) {\n    console.group('Failed to fetch Spotify data')\n    console.error(`label: ${props['aria-label']}`)\n    console.error(`url:   ${url}`)\n    console.dir(error)\n    console.groupEnd()\n    return <Failure {...props} />\n  }\n}\n\nfunction loadSpotifyData(url: string): Promise<SpotifyData> {\n  const { id, type } = parseSpotifyUri(url)\n  switch (type) {\n    case 'artist':\n      return spotify.artists.get(id).then(artist => ({\n        title: artist.name,\n        artist: artist.name,\n        image: artist.images[0]?.url ?? '',\n        link: artist.external_urls.spotify\n      }))\n    case 'album':\n      return spotify.albums.get(id).then(album => ({\n        title: album.name,\n        artist: album.artists.map(a => a.name).join(', '),\n        image: album.images[0]?.url ?? '',\n        link: album.external_urls.spotify\n      }))\n    default:\n      throw new Error(`Unsupported Spotify type: ${type}`)\n  }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/format.ts",
    "content": "const LOCALE = 'en-GB'\n\n/**\n * Format a date-ish object to a locale-friendly string\n */\nexport function formatDate(\n  date?: Date | string | number,\n  defaultValue: string = '',\n  options: Intl.DateTimeFormatOptions = {}\n) {\n  if (!date) {\n    return defaultValue\n  }\n  // https://css-tricks.com/how-to-convert-a-date-string-into-a-human-readable-format/\n  return new Date(date).toLocaleDateString(LOCALE, {\n    year: 'numeric',\n    month: 'long',\n    day: 'numeric',\n    ...options\n  })\n}\n\nexport function formatTime(date: Date | string | number) {\n  return new Date(date).toLocaleTimeString(LOCALE, {\n    hour12: false,\n    hour: '2-digit',\n    minute: '2-digit'\n  })\n}\n\nconst numberFormat = Intl.NumberFormat(LOCALE)\n\nexport function formatNumber(value: number) {\n  return numberFormat.format(value)\n}\n\nexport function formatStatNumber(\n  number: number,\n  options: Intl.NumberFormatOptions = {}\n): string {\n  return number.toLocaleString(LOCALE, {\n    notation: 'compact',\n    unitDisplay: 'short',\n    ...options\n  })\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/head/favicons.tsx",
    "content": "export const Favicons = () => (\n  <>\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"57x57\"\n      href=\"/favicons/apple-icon-57x57.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"60x60\"\n      href=\"/favicons/apple-icon-60x60.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"72x72\"\n      href=\"/favicons/apple-icon-72x72.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"76x76\"\n      href=\"/favicons/apple-icon-76x76.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"114x114\"\n      href=\"/favicons/apple-icon-114x114.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"120x120\"\n      href=\"/favicons/apple-icon-120x120.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"144x144\"\n      href=\"/favicons/apple-icon-144x144.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"152x152\"\n      href=\"/favicons/apple-icon-152x152.png\"\n    />\n    <link\n      rel=\"apple-touch-icon\"\n      sizes=\"180x180\"\n      href=\"/favicons/apple-icon-180x180.png\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      sizes=\"192x192\"\n      href=\"/favicons/android-icon-192x192.png\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      sizes=\"32x32\"\n      href=\"/favicons/favicon-32x32.png\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      sizes=\"96x96\"\n      href=\"/favicons/favicon-96x96.png\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      sizes=\"16x16\"\n      href=\"/favicons/favicon-16x16.png\"\n    />\n    <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n    <meta name=\"msapplication-TileColor\" content=\"#ffffff\" />\n    <meta\n      name=\"msapplication-TileImage\"\n      content=\"/favicons/ms-icon-144x144.png\"\n    />\n    <meta name=\"theme-color\" content=\"#ffffff\" />\n  </>\n)\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/hooks/useClipboard.ts",
    "content": "import { useCallback, useEffect, useState } from 'react'\n\nexport function useClipboard(value: string, timeout = 1500) {\n  const [hasCopied, setHasCopied] = useState(false)\n\n  const onCopy = useCallback(() => {\n    try {\n      navigator.clipboard.writeText(value).then(\n        () => setHasCopied(true),\n        () => {} // Clipboard write failed; hasCopied stays false\n      )\n    } catch {\n      // Clipboard API unavailable (non-secure context, etc.)\n    }\n  }, [value])\n\n  useEffect(() => {\n    if (!hasCopied) return\n    const id = window.setTimeout(() => setHasCopied(false), timeout)\n    return () => window.clearTimeout(id)\n  }, [timeout, hasCopied])\n\n  return { onCopy, hasCopied }\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/hooks/useHydration.ts",
    "content": "'use client'\n\nimport React from 'react'\n\nexport function useHydration() {\n  const [hydrated, setHydrated] = React.useState(false)\n  React.useEffect(() => {\n    setHydrated(true)\n  }, [])\n  return hydrated\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/layouts/footer.tsx",
    "content": "import { chiffreConfig } from 'lib/services/chiffre'\nimport Link from 'next/link'\nimport React from 'react'\nimport { BsDiscord, BsMastodon } from 'react-icons/bs'\nimport { FiGithub, FiLinkedin, FiMail } from 'react-icons/fi'\nimport { IconButton, IconButtonProps } from 'ui/components/buttons/icon-button'\n\nexport const Footer: React.FC = () => {\n  const gitSha1 = process.env.VERCEL_GIT_COMMIT_SHA ?? 'local'\n  const iconButtonProps: Omit<IconButtonProps, 'icon' | 'aria-label'> = {\n    variant: 'ghost',\n    className: 'rounded-full'\n  }\n\n  return (\n    <footer className=\"my-8\">\n      <nav className=\"flex flex-row justify-center gap-2 p-2\">\n        <a href=\"https://mamot.fr/@Franky47\" rel=\"me\">\n          <IconButton\n            icon={<BsMastodon />}\n            aria-label=\"Mastodon\"\n            {...iconButtonProps}\n          />\n        </a>\n        <a href=\"https://bsky.app/profile/francoisbest.com\">\n          <IconButton\n            icon={\n              <svg\n                role=\"presentation\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"currentColor\"\n                viewBox=\"0 0 24 24\"\n                className=\"size-4\"\n              >\n                <path d=\"M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z\" />\n              </svg>\n            }\n            aria-label=\"Bluesky\"\n            {...iconButtonProps}\n          />\n        </a>\n        {/* <a href=\"https://twitter.com/fortysevenfx\" rel=\"me\">\n          <IconButton\n            icon={<FiTwitter />}\n            aria-label=\"Twitter/X\"\n            {...iconButtonProps}\n          />\n        </a> */}\n        <a href=\"https://discord.com/users/francois.best#7881\">\n          <IconButton\n            icon={<BsDiscord />}\n            aria-label=\"Discord\"\n            {...iconButtonProps}\n          />\n        </a>\n        <a href=\"https://github.com/franky47\">\n          <IconButton\n            icon={<FiGithub />}\n            aria-label=\"GitHub\"\n            {...iconButtonProps}\n          />\n        </a>\n        <a href=\"https://www.linkedin.com/in/francoisbest\">\n          <IconButton\n            icon={<FiLinkedin />}\n            aria-label=\"LinkedIn\"\n            {...iconButtonProps}\n          />\n        </a>\n        <a href=\"mailto:hi@francoisbest.com\">\n          <IconButton\n            icon={<FiMail />}\n            aria-label=\"Email\"\n            {...iconButtonProps}\n          />\n        </a>\n      </nav>\n      <p className=\"text-center text-xs leading-5 text-gray-500\">\n        © 2019, François Best •{' '}\n        <a\n          href={`https://github.com/franky47/francoisbest.com/tree/${gitSha1}`}\n          className=\"font-mono\"\n        >\n          {gitSha1.slice(0, 8)}\n        </a>{' '}\n        • <Link href=\"/sitemap\">Site map</Link>\n        {chiffreConfig.enabled && (\n          <>\n            <br />\n            End-to-end encrypted analytics by{' '}\n            <a href=\"https://chiffre.io\" className=\"font-semibold\">\n              Chiffre.io\n            </a>\n          </>\n        )}\n      </p>\n    </footer>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/layouts/nav-header.tsx",
    "content": "import Link from 'next/link'\nimport React from 'react'\nimport { BsMastodon } from 'react-icons/bs'\nimport { FiTwitter } from 'react-icons/fi'\nimport { twMerge } from 'tailwind-merge'\nimport { IconButton } from 'ui/components/buttons/icon-button'\nimport { Logo } from 'ui/components/logo'\nimport { ThemeControls } from 'ui/components/theme-controls'\nimport { NavLink } from './nav-link'\n\nexport const NavHeader: React.FC<React.ComponentProps<'header'>> = ({\n  className,\n  ...props\n}) => {\n  return (\n    <header\n      className={twMerge(\n        'mx-auto flex max-w-3xl flex-wrap items-center gap-1 px-2 pb-8 pt-2 md:pt-12',\n        className\n      )}\n      {...props}\n    >\n      <nav className=\"flex items-center gap-6\" aria-label=\"Navigation\">\n        <Link href=\"/\" className=\"rounded-full\">\n          <Logo aria-label=\"François Best\" />\n        </Link>\n        <NavLink href=\"/\">About</NavLink>\n        <NavLink href=\"/open-source\">Open Source</NavLink>\n        <NavLink href=\"/posts\" exactMatch={false}>\n          Blog\n        </NavLink>\n      </nav>\n      <nav aria-label=\"Social\" className=\"ml-auto flex gap-1\">\n        <a href=\"https://mamot.fr/@Franky47\" rel=\"me\" tabIndex={-1}>\n          <IconButton\n            icon={<BsMastodon />}\n            aria-label=\"Mastodon\"\n            variant=\"ghost\"\n            className=\"rounded-full\"\n          />\n        </a>\n        <a href=\"https://bsky.app/profile/francoisbest.com\">\n          <IconButton\n            icon={\n              <svg\n                role=\"presentation\"\n                xmlns=\"http://www.w3.org/2000/svg\"\n                fill=\"currentColor\"\n                viewBox=\"0 0 24 24\"\n                className=\"size-4\"\n              >\n                <path d=\"M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z\" />\n              </svg>\n            }\n            aria-label=\"BlueSky\"\n            variant=\"ghost\"\n            className=\"rounded-full\"\n          />\n        </a>\n        {/* <a href=\"https://twitter.com/fortysevenfx\" rel=\"me\" tabIndex={-1}>\n          <IconButton\n            icon={<FiTwitter />}\n            aria-label=\"Twitter/X\"\n            variant=\"ghost\"\n            className=\"rounded-full\"\n          />\n        </a> */}\n      </nav>\n      <ThemeControls />\n    </header>\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/layouts/nav-link.tsx",
    "content": "'use client'\n\nimport Link, { LinkProps } from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport React from 'react'\n\ntype NavLinkProps = LinkProps & {\n  children: React.ReactNode\n  exactMatch?: boolean\n}\n\nexport const NavLink: React.FC<NavLinkProps> = ({\n  href,\n  exactMatch = true,\n  ...props\n}) => {\n  const pathname = usePathname()\n  const isActive = exactMatch\n    ? pathname === String(href)\n    : pathname.startsWith(String(href))\n  return (\n    <Link\n      href={href}\n      className={isActive ? 'flex-shrink-0 underline' : 'flex-shrink-0'}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/layouts/wide-container.tsx",
    "content": "import React from 'react'\nimport { twMerge } from 'tailwind-merge'\n\nexport const WideContainer: React.FC<React.ComponentProps<'section'>> = ({\n  className,\n  ...props\n}) => {\n  return (\n    <section\n      className={twMerge(\n        'mx-0 max-w-full p-0 md:max-w-none lg:-mx-16 lg:max-w-4xl',\n        className\n      )}\n      {...props}\n    />\n  )\n}\n"
  },
  {
    "path": "packages/francoisbest.com/src/ui/theme/moonlight-ii.json",
    "content": "{\n  \"name\": \"Moonlight II\",\n  \"type\": \"dark\",\n  \"colors\": {\n    \"foreground\": \"#c8d3f5\",\n    \"focusBorder\": \"#82aaff\",\n    \"contrastBorder\": \"#15151b\",\n    \"editorCursor.foreground\": \"#82aaff\",\n    \"editorRuler.foreground\": \"#444a73bb\",\n    \"scrollbar.shadow\": \"#00000022\",\n    \"tree.indentGuidesStroke\": \"#828bb866\",\n    \"editorLink.activeForeground\": \"#c8d3f5\",\n    \"selection.background\": \"#c8d3f5\",\n    \"progressBar.background\": \"#82aaff\",\n    \"textLink.foreground\": \"#65bcff\",\n    \"textLink.activeForeground\": \"#b2dfff\",\n    \"editorLineNumber.foreground\": \"#444a73\",\n    \"editorLineNumber.activeForeground\": \"#828bb8\",\n    \"editorBracketMatch.border\": \"#82aaffbb\",\n    \"editorBracketMatch.background\": \"#1F2028\",\n    \"editorWhitespace.foreground\": \"#c8d3f540\",\n    \"editor.background\": \"#1F2028\",\n    \"editor.foreground\": \"#c8d3f5\",\n    \"editor.lineHighlightBackground\": \"#2f334d\",\n    \"editor.selectionBackground\": \"#828bb850\",\n    \"editor.selectionHighlightBackground\": \"#444a73\",\n    \"editor.findMatchBackground\": \"#444a73\",\n    \"editor.findMatchBorder\": \"#86e1fc\",\n    \"editor.findMatchHighlightBackground\": \"#444a73\",\n    \"editorOverviewRuler.findMatchForeground\": \"#86e1fcbb\",\n    \"editorOverviewRuler.errorForeground\": \"#ff757fcc\",\n    \"editorOverviewRuler.infoForeground\": \"#65bcff66\",\n    \"editorOverviewRuler.warningForeground\": \"#ffc777cc\",\n    \"editorOverviewRuler.modifiedForeground\": \"#82aaff66\",\n    \"editorOverviewRuler.addedForeground\": \"#c3e88d66\",\n    \"editorOverviewRuler.deletedForeground\": \"#ff98a466\",\n    \"editorOverviewRuler.bracketMatchForeground\": \"#3e68d7bb\",\n    \"editorOverviewRuler.border\": \"#1F2028\",\n    \"editorHoverWidget.background\": \"#15151b\",\n    \"editorHoverWidget.border\": \"#000000aa\",\n    \"editorIndentGuide.background\": \"#444a73bb\",\n    \"editorIndentGuide.activeBackground\": \"#828bb8aa\",\n    \"editorGroupHeader.tabsBackground\": \"#1c1d24\",\n    \"editorGroup.border\": \"#15151b\",\n    \"editorGutter.modifiedBackground\": \"#82aaff66\",\n    \"editorGutter.addedBackground\": \"#c3e88d66\",\n    \"editorGutter.deletedBackground\": \"#ff5370aa\",\n    \"tab.activeBorder\": \"#82aaff\",\n    \"tab.activeModifiedBorder\": \"#828bb8\",\n    \"tab.unfocusedActiveBorder\": \"#828bb8\",\n    \"tab.activeForeground\": \"#c8d3f5\",\n    \"tab.activeBackground\": \"#1F2028\",\n    \"tab.inactiveForeground\": \"#828bb8\",\n    \"tab.inactiveBackground\": \"#1c1d24\",\n    \"tab.unfocusedActiveForeground\": \"#c8d3f5\",\n    \"tab.border\": \"#15151b\",\n    \"statusBar.noFolderBackground\": \"#1F2028\",\n    \"statusBar.border\": \"#15151b\",\n    \"statusBar.background\": \"#1c1d24\",\n    \"statusBar.foreground\": \"#828bb8\",\n    \"statusBar.debuggingBackground\": \"#baacff\",\n    \"statusBar.debuggingForeground\": \"#c8d3f5\",\n    \"statusBarItem.hoverBackground\": \"#828bb820\",\n    \"activityBar.background\": \"#1c1d24\",\n    \"activityBar.border\": \"#1F202860\",\n    \"activityBar.foreground\": \"#b4c2f0\",\n    \"activityBarBadge.background\": \"#3e68d7\",\n    \"activityBarBadge.foreground\": \"#ffffff\",\n    \"titleBar.activeBackground\": \"#1c1d24\",\n    \"titleBar.activeForeground\": \"#c8d3f5\",\n    \"titleBar.inactiveBackground\": \"#1c1d24\",\n    \"titleBar.inactiveForeground\": \"#828bb8\",\n    \"sideBar.background\": \"#1c1d24\",\n    \"sideBar.foreground\": \"#828bb8\",\n    \"sideBar.border\": \"#15151b\",\n    \"titleBar.border\": \"#15151b\",\n    \"sideBarTitle.foreground\": \"#c8d3f5\",\n    \"sideBarSectionHeader.background\": \"#1c1d24\",\n    \"sideBarSectionHeader.border\": \"#2f334d\",\n    \"input.background\": \"#15151b\",\n    \"input.foreground\": \"#c8d3f5\",\n    \"input.placeholderForeground\": \"#c8d3f5aa\",\n    \"input.border\": \"#00000066\",\n    \"inputValidation.errorBackground\": \"#c53b53\",\n    \"inputValidation.errorForeground\": \"#ffffff\",\n    \"inputValidation.errorBorder\": \"#ff537050\",\n    \"inputValidation.infoBackground\": \"#446bbb\",\n    \"inputValidation.infoForeground\": \"#ffffff\",\n    \"inputValidation.infoBorder\": \"#82aaff50\",\n    \"inputValidation.warningBackground\": \"#ad7c43\",\n    \"inputValidation.warningForeground\": \"#ffffff\",\n    \"inputValidation.warningBorder\": \"#ffc77750\",\n    \"dropdown.foreground\": \"#c8d3f5\",\n    \"dropdown.background\": \"#2f334d\",\n    \"dropdown.border\": \"#00000066\",\n    \"list.hoverForeground\": \"#c8d3f5\",\n    \"list.hoverBackground\": \"#1c1d24\",\n    \"list.activeSelectionBackground\": \"#383e5c\",\n    \"list.activeSelectionForeground\": \"#ffffff\",\n    \"list.inactiveSelectionForeground\": \"#c8d3f5\",\n    \"list.inactiveSelectionBackground\": \"#292e46\",\n    \"list.focusBackground\": \"#131421\",\n    \"list.focusForeground\": \"#c8d3f5\",\n    \"list.highlightForeground\": \"#86e1fc\",\n    \"list.warningForeground\": \"#ffc777cc\",\n    \"terminal.foreground\": \"#bcc4d6\",\n    \"terminal.selectionBackground\": \"#c8d3f544\",\n    \"terminal.ansiWhite\": \"#c8d3f5\",\n    \"terminal.ansiBlack\": \"#000000\",\n    \"terminal.ansiBlue\": \"#82aaff\",\n    \"terminal.ansiCyan\": \"#86e1fc\",\n    \"terminal.ansiGreen\": \"#c3e88d\",\n    \"terminal.ansiMagenta\": \"#fca7ea\",\n    \"terminal.ansiRed\": \"#ff757f\",\n    \"terminal.ansiYellow\": \"#ffc777\",\n    \"terminal.ansiBrightWhite\": \"#c8d3f5\",\n    \"terminal.ansiBrightBlack\": \"#828bb8\",\n    \"terminal.ansiBrightBlue\": \"#82aaff\",\n    \"terminal.ansiBrightCyan\": \"#86e1fc\",\n    \"terminal.ansiBrightGreen\": \"#c3e88d\",\n    \"terminal.ansiBrightMagenta\": \"#fca7ea\",\n    \"terminal.ansiBrightRed\": \"#ff757f\",\n    \"terminal.ansiBrightYellow\": \"#ffc777\",\n    \"terminal.border\": \"#2f334d\",\n    \"scrollbarSlider.background\": \"#828bb830\",\n    \"scrollbarSlider.hoverBackground\": \"#a9b8e830\",\n    \"scrollbarSlider.activeBackground\": \"#82aaff\",\n    \"minimap.findMatchHighlight\": \"#86e1fccc\",\n    \"minimap.selectionHighlight\": \"#86e1fc33\",\n    \"minimapGutter.addedBackground\": \"#c3e88d66\",\n    \"minimapGutter.modifiedBackground\": \"#82aaff66\",\n    \"editorSuggestWidget.background\": \"#15151b\",\n    \"editorSuggestWidget.foreground\": \"#a9b8e8\",\n    \"editorSuggestWidget.highlightForeground\": \"#86e1fc\",\n    \"editorSuggestWidget.selectedBackground\": \"#2f334d\",\n    \"editorSuggestWidget.border\": \"#00000033\",\n    \"editorError.foreground\": \"#ff5370\",\n    \"editorWarning.foreground\": \"#ffc777cc\",\n    \"editorWidget.background\": \"#1c1d24\",\n    \"editorWidget.resizeBorder\": \"#82aaff\",\n    \"editorMarkerNavigation.background\": \"#c8d3f505\",\n    \"widget.shadow\": \"#00000033\",\n    \"panel.border\": \"#00000033\",\n    \"panel.background\": \"#1c1d24\",\n    \"panel.dropBackground\": \"#c8d3f5\",\n    \"panelTitle.inactiveForeground\": \"#828bb8\",\n    \"panelTitle.activeForeground\": \"#c8d3f5\",\n    \"panelTitle.activeBorder\": \"#82aaff\",\n    \"terminalCursor.foreground\": \"#82aaff\",\n    \"diffEditor.insertedTextBackground\": \"#c3e88d15\",\n    \"diffEditor.removedTextBackground\": \"#ff537020\",\n    \"notifications.background\": \"#15151b\",\n    \"notifications.foreground\": \"#c8d3f5\",\n    \"notificationLink.foreground\": \"#82aaff\",\n    \"badge.background\": \"#3e68d7\",\n    \"badge.foreground\": \"#ffffff\",\n    \"button.background\": \"#3e68d7\",\n    \"button.hoverBackground\": \"#65bcffcc\",\n    \"extensionButton.prominentBackground\": \"#3e68d7\",\n    \"extensionButton.prominentHoverBackground\": \"#65bcffcc\",\n    \"peekView.border\": \"#00000030\",\n    \"peekViewEditor.background\": \"#c8d3f505\",\n    \"peekViewTitle.background\": \"#c8d3f505\",\n    \"peekViewResult.background\": \"#c8d3f505\",\n    \"peekViewEditorGutter.background\": \"#c8d3f505\",\n    \"peekViewTitleDescription.foreground\": \"#c8d3f560\",\n    \"peekViewResult.matchHighlightBackground\": \"#828bb850\",\n    \"peekViewEditor.matchHighlightBackground\": \"#828bb850\",\n    \"debugToolBar.background\": \"#1c1d24\",\n    \"pickerGroup.foreground\": \"#82aaff\",\n    \"gitDecoration.deletedResourceForeground\": \"#ff5370dd\",\n    \"gitDecoration.conflictingResourceForeground\": \"#ffc777cc\",\n    \"gitDecoration.modifiedResourceForeground\": \"#82aaffee\",\n    \"gitDecoration.untrackedResourceForeground\": \"#77e0c6dd\",\n    \"gitDecoration.ignoredResourceForeground\": \"#777fabaa\",\n    \"gitlens.trailingLineForegroundColor\": \"#828bb8aa\",\n    \"editorCodeLens.foreground\": \"#828bb8\",\n    \"peekViewResult.selectionBackground\": \"#828bb870\",\n    \"breadcrumb.background\": \"#1F2028\",\n    \"breadcrumb.foreground\": \"#828bb8\",\n    \"breadcrumb.focusForeground\": \"#c8d3f5\",\n    \"breadcrumb.activeSelectionForeground\": \"#82aaff\",\n    \"breadcrumbPicker.background\": \"#1c1d24\",\n    \"menu.background\": \"#1c1d24\",\n    \"menu.foreground\": \"#c8d3f5\",\n    \"menu.selectionBackground\": \"#00000050\",\n    \"menu.selectionForeground\": \"#82aaff\",\n    \"menu.selectionBorder\": \"#00000030\",\n    \"menu.separatorBackground\": \"#c8d3f5\",\n    \"menubar.selectionBackground\": \"#00000030\",\n    \"menubar.selectionForeground\": \"#82aaff\",\n    \"menubar.selectionBorder\": \"#00000030\",\n    \"settings.dropdownForeground\": \"#c8d3f5\",\n    \"settings.dropdownBackground\": \"#2f334d\",\n    \"settings.dropdownBorder\": \"#15151b\",\n    \"settings.numberInputForeground\": \"#c8d3f5\",\n    \"settings.numberInputBackground\": \"#15151b\",\n    \"settings.numberInputBorder\": \"#00000066\",\n    \"settings.textInputForeground\": \"#c8d3f5\",\n    \"settings.textInputBackground\": \"#15151b\",\n    \"settings.textInputBorder\": \"#00000066\",\n    \"settings.headerForeground\": \"#82aaff\",\n    \"settings.modifiedItemIndicator\": \"#82aaff\",\n    \"settings.checkboxBackground\": \"#131421\",\n    \"settings.checkboxForeground\": \"#c8d3f5\",\n    \"settings.checkboxBorder\": \"#00000066\"\n  },\n  \"tokenColors\": [\n    {\n      \"name\": \"Comment\",\n      \"scope\": [\n        \"comment\",\n        \"punctuation.definition.comment\",\n        \"string.quoted.docstring\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#858aa6\"\n      }\n    },\n    {\n      \"name\": \"Variables and Plain Text\",\n      \"scope\": [\n        \"variable\",\n        \"support.variable\",\n        \"string constant.other.placeholder\",\n        \"text.html\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#c8d3f5\"\n      }\n    },\n    {\n      \"name\": \"DOM Variables\",\n      \"scope\": [\n        \"support.variable.dom\",\n        \"support.constant.math\",\n        \"support.type.object.module\",\n        \"support.variable.object.process\",\n        \"support.constant.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"Nil\",\n      \"scope\": [\"constant.language.undefined\", \"constant.language.null\"],\n      \"settings\": {\n        \"foreground\": \"#7f85a3\"\n      }\n    },\n    {\n      \"name\": \"PHP Constants\",\n      \"scope\": [\"constant.other.php\"],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"Colors\",\n      \"scope\": [\"constant.other.color\"],\n      \"settings\": {\n        \"foreground\": \"#ffffff\"\n      }\n    },\n    {\n      \"name\": \"Invalid\",\n      \"scope\": [\"invalid\", \"invalid.illegal\"],\n      \"settings\": {\n        \"foreground\": \"#ff5370\"\n      }\n    },\n    {\n      \"name\": \"Invalid deprecated\",\n      \"scope\": [\"invalid.deprecated\"],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Keyword, Storage\",\n      \"scope\": [\n        \"keyword\",\n        \"storage.type\",\n        \"storage.modifier\",\n        \"keyword.other.important\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Keyword, Storage\",\n      \"scope\": [\"keyword.control\", \"storage\"],\n      \"settings\": {}\n    },\n    {\n      \"name\": \"Interpolation\",\n      \"scope\": [\n        \"punctuation.definition.template-expression\",\n        \"punctuation.section.embedded\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Spread\",\n      \"scope\": [\"keyword.operator.spread\", \"keyword.operator.rest\"],\n      \"settings\": {\n        \"foreground\": \"#ff757f\",\n        \"fontStyle\": \"bold\"\n      }\n    },\n    {\n      \"name\": \"Operator, Misc\",\n      \"scope\": [\n        \"keyword.operator\",\n        \"keyword.control\",\n        \"punctuation\",\n        \"punctuation.definition.string\",\n        \"punctuation.support.type.property-name\",\n        \"text.html.vue-html meta.tag\",\n        \"punctuation.definition.keyword\",\n        \"punctuation.terminator.rule\",\n        \"punctuation.definition.entity\",\n        \"constant.other.color\",\n        \"meta.tag\",\n        \"punctuation.definition.tag\",\n        \"punctuation.separator.inheritance.php\",\n        \"punctuation.definition.block.tag\",\n        \"punctuation.definition.tag.html\",\n        \"punctuation.definition.tag.begin.html\",\n        \"punctuation.definition.tag.end.html\",\n        \"meta.property-list\",\n        \"meta.brace.square\",\n        \"keyword.other.template\",\n        \"keyword.other.substitution\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Keyword Control\",\n      \"scope\": [\"keyword.control\"],\n      \"settings\": {}\n    },\n    {\n      \"name\": \"Tag\",\n      \"scope\": [\"entity.name.tag\", \"meta.tag\", \"markup.deleted.git_gutter\"],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Function, Special Method\",\n      \"scope\": [\n        \"entity.name.function\",\n        \"variable.function\",\n        \"keyword.other.special-method\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"Support Function\",\n      \"scope\": [\"support.function\", \"meta.function-call entity.name.function\"],\n      \"settings\": {\n        \"foreground\": \"#65bcff\"\n      }\n    },\n    {\n      \"name\": \"C-related Block Level Variables\",\n      \"scope\": [\"source.cpp meta.block variable.other\"],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Other Variable, String Link\",\n      \"scope\": [\"support.other.variable\", \"string.other.link\"],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Constant, Function Argument, Tag Attribute, Embedded\",\n      \"scope\": [\n        \"variable.other.constant\",\n        \"constant.language\",\n        \"keyword.other.type.php\",\n        \"storage.type.php\",\n        \"support.constant\",\n        \"constant.character\",\n        \"constant.escape\",\n        \"keyword.other.unit\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ff98a4\"\n      }\n    },\n    {\n      \"name\": \"Number, Boolean\",\n      \"scope\": [\n        \"constant.numeric\",\n        \"constant.language.boolean\",\n        \"constant.language.json\",\n        \"constant.language.infinity\",\n        \"constant.language.nan\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ff966c\"\n      }\n    },\n    {\n      \"name\": \"Function Argument\",\n      \"scope\": [\n        \"variable.parameter.function.language.special\",\n        \"variable.parameter\",\n        \"meta.function.parameter variable\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#fca7ea\"\n      }\n    },\n    {\n      \"name\": \"String, Symbols, Inherited Class, Markup Heading\",\n      \"scope\": [\n        \"string\",\n        \"constant.other.symbol\",\n        \"constant.other.key\",\n        \"entity.other.inherited-class\",\n        \"markup.heading\",\n        \"markup.inserted.git_gutter\",\n        \"meta.group.braces.curly constant.other.object.key.js string.unquoted.label.js\",\n        \"meta.attribute-selector\"\n      ],\n      \"settings\": {\n        \"fontStyle\": \"\",\n        \"foreground\": \"#c3e88d\"\n      }\n    },\n    {\n      \"name\": \"Object\",\n      \"scope\": [\"variable.other.object\"],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"Object Key\",\n      \"scope\": [\n        \"meta.object-literal.key\",\n        \"string.alias.graphql\",\n        \"string.unquoted.graphql\",\n        \"string.unquoted.alias.graphql\",\n        \"meta.field.declaration.ts variable.object.property\",\n        \"variable.object.property\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#4fd6be\"\n      }\n    },\n    {\n      \"name\": \"Nested Object Property\",\n      \"scope\": [\"meta.object.member\", \"variable.other.object.property\"],\n      \"settings\": {\n        \"foreground\": \"#a9b8e8\"\n      }\n    },\n    {\n      \"name\": \"Object Property\",\n      \"scope\": [\n        \"variable.other.property\",\n        \"support.variable.property\",\n        \"support.variable.property.dom\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#a9b8e8\"\n      }\n    },\n    {\n      \"name\": \"Haskell Constants\",\n      \"scope\": [\"source.haskell constant.other.haskell\"],\n      \"settings\": {\n        \"foreground\": \"#ff98a4\"\n      }\n    },\n    {\n      \"name\": \"Haskell Imports\",\n      \"scope\": [\"source.haskell meta.import.haskell entity.name.namespace\"],\n      \"settings\": {\n        \"foreground\": \"#c8d3f5\"\n      }\n    },\n    {\n      \"name\": \"Types Fixes\",\n      \"scope\": [\n        \"source.haskell storage.type\",\n        \"source.c storage.type\",\n        \"source.java storage.type\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"Lambda Arrow\",\n      \"scope\": [\"storage.type.function\"],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Class, Support\",\n      \"scope\": [\n        \"entity.name\",\n        \"support.type\",\n        \"support.class\",\n        \"support.orther.namespace.use.php\",\n        \"meta.use.php\",\n        \"support.other.namespace.php\",\n        \"markup.changed.git_gutter\",\n        \"support.type.sys-types\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"Entity types\",\n      \"scope\": [\"support.type\"],\n      \"settings\": {\n        \"foreground\": \"#ff966c\"\n      }\n    },\n    {\n      \"name\": \"CSS Class and Support\",\n      \"scope\": [\n        \"source.css support.type.property-name\",\n        \"source.sass support.type.property-name\",\n        \"source.scss support.type.property-name\",\n        \"source.less support.type.property-name\",\n        \"source.stylus support.type.property-name\",\n        \"source.postcss support.type.property-name\",\n        \"support.type.property-name.css\",\n        \"support.type.vendored.property-name\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"Sub-methods\",\n      \"scope\": [\n        \"entity.name.module.js\",\n        \"variable.import.parameter.js\",\n        \"variable.other.class.js\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Language methods\",\n      \"scope\": [\"variable.language\"],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"entity.name.method.js\",\n      \"scope\": [\"entity.name.method.js\"],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"meta.method.js\",\n      \"scope\": [\n        \"meta.class-method.js entity.name.function.js\",\n        \"variable.function.constructor\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"Attributes\",\n      \"scope\": [\"entity.other.attribute-name\"],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"HTML Attributes\",\n      \"scope\": [\n        \"text.html.basic entity.other.attribute-name.html\",\n        \"text.html.basic entity.other.attribute-name\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"HTML Doctype\",\n      \"scope\": [\n        \"meta.tag.metadata.doctype entity.name.tag\",\n        \"meta.tag.metadata.doctype entity.other.attribute-name\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"CSS Classes\",\n      \"scope\": [\"entity.other.attribute-name.class\"],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"CSS ID's\",\n      \"scope\": [\"source.sass keyword.control\"],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"CSS psuedo selectors\",\n      \"scope\": [\n        \"entity.other.attribute-name.pseudo-class\",\n        \"entity.other.attribute-name.pseudo-element\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#4fd6be\"\n      }\n    },\n    {\n      \"name\": \"CSS Property value\",\n      \"scope\": [\"support.constant.property-value\"],\n      \"settings\": {\n        \"foreground\": \"#fca7ea\"\n      }\n    },\n    {\n      \"name\": \"Inserted\",\n      \"scope\": [\"markup.inserted\"],\n      \"settings\": {\n        \"foreground\": \"#c3e88d\"\n      }\n    },\n    {\n      \"name\": \"Deleted\",\n      \"scope\": [\"markup.deleted\"],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Changed\",\n      \"scope\": [\"markup.changed\"],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Regular Expressions\",\n      \"scope\": [\"string.regexp\"],\n      \"settings\": {\n        \"foreground\": \"#b4f9f8\"\n      }\n    },\n    {\n      \"name\": \"Regular Expressions - Punctuation\",\n      \"scope\": [\"punctuation.definition.group\"],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Regular Expressions - Character Class\",\n      \"scope\": [\n        \"constant.other.character-class.regexp\",\n        \"keyword.control.anchor.regexp\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Regular Expressions - Character Class Set\",\n      \"scope\": [\"constant.other.character-class.set.regexp\"],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"Regular Expressions - Quantifier\",\n      \"scope\": [\"keyword.operator.quantifier.regexp\"],\n      \"settings\": {\n        \"foreground\": \"#fca7ea\"\n      }\n    },\n    {\n      \"name\": \"Escape Characters\",\n      \"scope\": [\"constant.character.escape\"],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"URL\",\n      \"scope\": [\"*url*\", \"*link*\", \"*uri*\"],\n      \"settings\": {\n        \"fontStyle\": \"underline\"\n      }\n    },\n    {\n      \"name\": \"Decorators\",\n      \"scope\": [\n        \"tag.decorator.js entity.name.tag.js\",\n        \"tag.decorator.js punctuation.definition.tag.js\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"CSS Units\",\n      \"scope\": [\"keyword.other.unit\"],\n      \"settings\": {\n        \"foreground\": \"#fc7b7b\"\n      }\n    },\n    {\n      \"name\": \"ES7 Bind Operator\",\n      \"scope\": [\n        \"source.js constant.other.object.key.js string.unquoted.label.js\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"JSON Key - Level 0\",\n      \"scope\": [\n        \"source.json meta.structure.dictionary.json support.type.property-name.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"JSON Key - Level 1\",\n      \"scope\": [\n        \"source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#65bcff\"\n      }\n    },\n    {\n      \"name\": \"JSON Key - Level 2\",\n      \"scope\": [\n        \"source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"JSON Key - Level 3\",\n      \"scope\": [\n        \"source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#fca7ea\"\n      }\n    },\n    {\n      \"name\": \"JSON Key - Level 4\",\n      \"scope\": [\n        \"source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"JSON Key - Level 5\",\n      \"scope\": [\n        \"source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#4fd6be\"\n      }\n    },\n    {\n      \"name\": \"JSON Key - Level 6\",\n      \"scope\": [\n        \"source.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json meta.structure.dictionary.value.json meta.structure.dictionary.json support.type.property-name.json\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"Plain Punctuation\",\n      \"scope\": [\"punctuation.definition.list_item.markdown\"],\n      \"settings\": {\n        \"foreground\": \"#828bb8\"\n      }\n    },\n    {\n      \"name\": \"Block Punctuation\",\n      \"scope\": [\n        \"meta.block\",\n        \"meta.brace\",\n        \"punctuation.definition.block\",\n        \"punctuation.definition.parameters\",\n        \"punctuation.section.function\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#b4c2f0\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Plain\",\n      \"scope\": [\"meta.jsx.children\", \"meta.embedded.block\"],\n      \"settings\": {\n        \"foreground\": \"#b4c2f0\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Markup Raw Inline\",\n      \"scope\": [\"text.html.markdown markup.inline.raw.markdown\"],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Markup Raw Inline Punctuation\",\n      \"scope\": [\n        \"text.html.markdown markup.inline.raw.markdown punctuation.definition.raw.markdown\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Heading punctuation\",\n      \"scope\": [\n        \"markdown.heading\",\n        \"markup.heading | markup.heading entity.name\",\n        \"markup.heading.markdown punctuation.definition.heading.markdown\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Markup - Italic\",\n      \"scope\": [\"markup.italic\"],\n      \"settings\": {\n        \"fontStyle\": \"italic\",\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Markup - Bold\",\n      \"scope\": [\"markup.bold\", \"markup.bold string\"],\n      \"settings\": {\n        \"fontStyle\": \"bold\",\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Markup - Bold-Italic\",\n      \"scope\": [\n        \"markup.bold markup.italic\",\n        \"markup.italic markup.bold\",\n        \"markup.quote markup.bold\",\n        \"markup.bold markup.italic string\",\n        \"markup.italic markup.bold string\",\n        \"markup.quote markup.bold string\"\n      ],\n      \"settings\": {\n        \"fontStyle\": \"bold\",\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"name\": \"Markup - Underline\",\n      \"scope\": [\"markup.underline\"],\n      \"settings\": {\n        \"fontStyle\": \"underline\",\n        \"foreground\": \"#ff966c\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Blockquote\",\n      \"scope\": [\"markup.quote punctuation.definition.blockquote.markdown\"],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Markup - Quote\",\n      \"scope\": [\"markup.quote\"],\n      \"settings\": {\n        \"fontStyle\": \"italic\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Link\",\n      \"scope\": [\"string.other.link.title.markdown\"],\n      \"settings\": {\n        \"foreground\": \"#82aaff\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Link Description\",\n      \"scope\": [\"string.other.link.description.title.markdown\"],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Link Anchor\",\n      \"scope\": [\"constant.other.reference.link.markdown\"],\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"name\": \"Markup - Raw Block\",\n      \"scope\": [\"markup.raw.block\"],\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Fenced Bode Block Variable\",\n      \"scope\": [\n        \"markup.fenced_code.block.markdown\",\n        \"markup.inline.raw.string.markdown\"\n      ],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Fenced Language\",\n      \"scope\": [\"variable.language.fenced.markdown\"],\n      \"settings\": {\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Markdown - Separator\",\n      \"scope\": [\"meta.separator\"],\n      \"settings\": {\n        \"fontStyle\": \"bold\",\n        \"foreground\": \"#86e1fc\"\n      }\n    },\n    {\n      \"name\": \"Markup - Table\",\n      \"scope\": [\"markup.table\"],\n      \"settings\": {\n        \"foreground\": \"#828bb8\"\n      }\n    },\n    {\n      \"scope\": \"token.info-token\",\n      \"settings\": {\n        \"foreground\": \"#65bcff\"\n      }\n    },\n    {\n      \"scope\": \"token.warn-token\",\n      \"settings\": {\n        \"foreground\": \"#ffc777\"\n      }\n    },\n    {\n      \"scope\": \"token.error-token\",\n      \"settings\": {\n        \"foreground\": \"#ff757f\"\n      }\n    },\n    {\n      \"scope\": \"token.debug-token\",\n      \"settings\": {\n        \"foreground\": \"#c099ff\"\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/francoisbest.com/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noEmit\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"Bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"react-jsx\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"lib/*\": [\n        \"./src/lib/*\"\n      ],\n      \"ui/*\": [\n        \"./src/ui/*\"\n      ],\n      \"app/*\": [\n        \"./src/app/*\"\n      ],\n      \"collections/*\": [\n        \"./.source/*\"\n      ]\n    },\n    \"esModuleInterop\": true\n  },\n  \"include\": [\n    \"next-env.d.ts\",\n    \"**/*.ts\",\n    \"**/*.tsx\",\n    \".next/types/**/*.ts\",\n    \".next/dev/types/**/*.ts\"\n  ],\n  \"exclude\": [\n    \"node_modules\"\n  ]\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - 'packages/*'\n"
  },
  {
    "path": "turbo.json",
    "content": "{\n  \"$schema\": \"https://turbo.build/schema.json\",\n  \"tasks\": {\n    \"dev\": {\n      \"cache\": false,\n      \"persistent\": true\n    },\n    \"build\": {\n      \"outputs\": [\".next/**\", \"!.next/cache/**\"],\n      \"dependsOn\": [\"^build\"],\n      \"env\": [\n        \"DEPLOYMENT_URL\",\n        \"VERCEL_ENV\",\n        \"VERCEL_GIT_COMMIT_REF\",\n        \"VERCEL_GIT_COMMIT_SHA\",\n        \"VERCEL_URL\",\n        \"GITHUB_TOKEN\",\n        \"SPOTIFY_CLIENT_ID\",\n        \"SPOTIFY_CLIENT_SECRET\"\n      ]\n    },\n    \"lint\": {},\n    \"test\": {},\n    \"typecheck\": {}\n  }\n}\n"
  }
]