diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 00000000..36de8c37 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,245 @@ +# MyDevTools — UI/UX & Performance Audit + +_Date: 2026-07-16 · Auditor: automated senior-frontend audit (Claude Code)_ + +## If you do only three things this week + +Merge the three reference PRs. **(1)** `audit/pr1-web-lcp`: the marketing home held its hero invisible behind a framer-motion `initial="hidden"` fade and a ~4 s CRT boot overlay on every hard load — mobile LCP was 9.3 s; painting the hero at first paint (CSS, transform-only on LCP elements) and playing the boot overlay once per browser cuts it to 5.2 s (−44 %), and the remaining gap is the 391 KB client-JS chain (findings #4/#6). **(2)** `audit/pr2-monaco-selfhost`: every Monaco editor tool silently downloaded ~4–5 MB from cdn.jsdelivr.net at runtime (14 requests) — broken offline and a third-party call the product's privacy story says doesn't exist; self-hosting the already-installed `monaco-editor` package takes it to 0 with no First-Load change. **(3)** `audit/pr3-favicon-privacy`: bookmarks and the password manager sent the domain of every stored entry to Google/DuckDuckGo favicon services; the fetch is now disabled (existing letter-avatar fallbacks take over) with a guard test so it can't quietly return. + +--- + +## 1. Scope & method + +- **Apps audited**: `apps/desktop-ui` (the product — 77 routes under `src/app/app/`: 72 tools + 5 break-room games) and `apps/web` (marketing/SEO/auth; its `/tools/*` routes are static SEO landing pages, not functional tools). +- **Baseline commit**: `main` @ `97ec0984bb9d727e7f2e7fa27090fc2e69fdc0cc`. All audit work on branch `audit/ui-perf`; reference PRs on `audit/pr*` branches off main. +- **Machine**: macOS (Darwin 25.5.0, Apple Silicon), Node v24.18.0, Next.js 16.2.10. +- **Bundlers**: production serving + all page-weight numbers = default `next build` (Turbopack). Dependency attribution treemaps = `ANALYZE=true next build --webpack` (`@next/bundle-analyzer`; analyzer wiring for desktop-ui added on the audit branch, gated off by default). Turbopack and webpack numbers are never mixed in a before/after comparison. +- **Servers**: `next start -p 3100` (web), `next start -p 3200` (desktop-ui). FastAPI backend (localhost:8000) intentionally NOT running — none of the audited pages require it. +- **Lighthouse**: v12 via `npx lighthouse@12`, Chrome for Testing 150.0.7871.124 (pinned, installed via `@puppeteer/browsers`), `--chrome-flags="--headless=new"`, categories performance+accessibility. Mobile = LH default simulated slow-4G + 4× CPU throttle; desktop = `--preset=desktop`. 3 runs per page per form factor; medians reported. +- **Reproduce**: every number's command is in Appendix A. Note: Next 16 no longer prints per-route First Load JS in build output (either bundler); per-route JS is measured by fetching each route's HTML from the running server and gzip-summing its script/preload chunks (`route-sweep.js`, Appendix A). + +## 2. App map & network egress inventory + +### Route map + +- `apps/web` (Next 16): `/`, `/pricing`, `/features`, `/security`, `/developer-tools`, `/use-cases`, `/download`, `/blog[/slug]`, `/compare/[slug]`, `/tools`, `/tools/[slug]` (SEO landing pages), `/help`, `/login`, `/oauth/callback`, `/account/plan`, `/dashboard`, API routes (`/api/proxy`, `/api/og`, `/api/app-version`, `/api/backend/[...path]`). +- `apps/desktop-ui`: `/` , `/dashboard`, `/settings`, `/activate`, `/login`, `/help`, and 77 `/app/` routes. Tabbed workspace lazy-loads tools via `src/lib/tab-registry.tsx` (~66 `dynamic(..., {ssr:false})` entries — code-splitting for tool internals already exists). + +### Rendering strategy + +- **`apps/web` is 100 % request-dynamic.** `src/app/layout.tsx:19` sets `export const dynamic = 'force-dynamic'`, and `src/i18n/request.ts:13` reads the `NEXT_LOCALE` cookie on every request. Build route table (`next build`, both bundlers): **every page `ƒ` (dynamic); only `/robots.txt` and `/sitemap.xml` static** — the `generateStaticParams` on `/tools/[slug]`, `/blog/[slug]`, `/compare/[slug]` is dead weight. For an SEO-focused marketing site this forfeits CDN caching and prerendering entirely. (Note: CLAUDE.md says the marketing surface is English-only, so the cookie-locale machinery isn't even needed on public routes.) +- `apps/desktop-ui`: static export (`output: 'export'`) only when `TAURI_BUILD=1`; 66 of 77 tool pages are thin server-component SEO wrappers around client components. + +### Network egress (privacy audit) + +| Endpoint | Where | Tool/feature | Verdict | +|---|---|---|---| +| `cdn.jsdelivr.net` (~15 requests, ~4–5 MB) | `@monaco-editor/react` default loader — **no `loader.config` anywhere in repo**; `src/components/lazy/LazyMonaco.tsx` | Every Monaco editor surface (code-screenshot, snippet-manager, api-client, …) | 🔴 **Unexpected.** Editor tools break fully offline and fetch from a third-party CDN at runtime despite `monaco-editor` being a local dependency. Contradicts offline/local-first claim. → **Reference PR2** | +| `www.google.com/s2/favicons`, `icons.duckduckgo.com` | `src/lib/favicon-utils.ts:6-7`, `src/lib/password-utils.ts:77`; rendered by `bookmarks/bookmark-card.tsx`, `password-manager/password-{card,list,item-swipeable}.tsx` | Bookmarks, Password Manager | 🔴 **Unexpected.** Domains of stored bookmarks and password entries are sent to Google/DuckDuckGo to fetch icons — user vault metadata leaves the device by default. → **Reference PR3** | +| `api.pwnedpasswords.com/range/` | `src/lib/hibp.ts` | Password breach check | 🟡 By-design (k-anonymity: only first 5 chars of SHA-1 sent), but should be disclosed/opt-in labelled in UI. | +| `dns.google/resolve` | `src/lib/dns-doh.ts` | DNS Lookup | 🟢 By-design — the tool's purpose is a network query. | +| `rdap.org` | `src/lib/rdap.ts` | Whois Lookup | 🟢 By-design. | +| User-supplied URLs/sockets | api-client, websocket-tester, graphql/grpc panels, s3-drive (aws4fetch), email-validator, gitignore-generator | Network tools | 🟢 By-design / user-initiated. | +| Firebase auth (`identitytoolkit`, `securetoken`), Vercel Analytics/Speed Insights, MS Clarity | root layout preconnects; lazy-loaded in `client-shell.tsx` | Auth + telemetry | 🟡 Telemetry in a privacy-first desktop app deserves an explicit disclosure/opt-out; Firebase chunks also load on every page (~40 KB gz, §3). | +| `api.qrserver.com` | **only** in `next.config.ts` `remotePatterns` of both apps — zero source usage | — | 🟢 False alarm: QR generation is fully local (`qrcode`, `qr-code-styling`). Stale config entry; remove. | + +### False leads verified (report-only, no PR) + +- **DB drivers (`mongodb`, `pg`, `mysql2`, `ioredis`) are NOT in the client bundle.** `grep -rlE "MongoClient|createPool|ioredis" .next/static/chunks/` matches only a gpt-tokenizer BPE-vocabulary chunk (" MongoClient" is literally a token in the o200k vocab). The pool files `src/lib/{sql,nosql}-client-pool.ts` are imported only by their own tests; `mysql2`/`ioredis` imported by nothing in `src/` → 4 removable dependencies + 2 dead files. +- **`gpt-tokenizer` already lazy**: per-encoding dynamic imports in `token-counter-layout.tsx`. + +## 3. Bundle reality + +### apps/web (Turbopack prod, gzip) + +- Shared entry (`rootMainFiles`): **123.1 KB gz**. +- First Load JS per route (`route-sweep.js` against `next start`): + +| Route | First Load JS (gz) | +|---|---| +| `/` | **391.1 KB** (21 scripts) | +| `/tools` | 373.5 KB | +| `/dashboard` | 357.4 KB | +| `/login` | 360.5 KB | +| `/pricing` | 350.8 KB | +| `/features` | 349.6 KB | +| `/download` | 346.2 KB | +| `/tools/json-formatter` | 344.9 KB | +| `/blog` | 344.9 KB | +| `/account/plan` | 335.6 KB | + +- 10 heaviest packages (webpack treemap, gz): next 231 KB, react-dom 55, motion-dom 36, @firebase/auth 25, framer-motion 16, @tabler/icons-react 15, lucide-react 13, sonner 9, @formatjs/icu-messageformat-parser 7, tailwind-merge 6. Firebase is mostly tree-shaken/lazy — the marketing bundle is not dominated by any single rogue dependency; the ~345 KB floor is framework + shell. + +### apps/desktop-ui (Turbopack prod, gzip) + +- Shared entry (`rootMainFiles`): **129.8 KB gz** — but the real floor per tool page is far higher: + +| Route | First Load JS (gz) | +|---|---| +| `/app/database-explorer` (heaviest) | 632.6 KB | +| `/app/s3-drive` | 615.7 KB | +| `/app/code-screenshot` | 614.3 KB | +| `/dashboard` | 595.9 KB | +| … | … | +| `/app/color-picker` (lightest tool) | **562.4 KB** | +| `/app/uuid-generator` (lightest-function tool) | **572.4 KB** | + + **Every tool page pays a ~562 KB gz floor** — a trivial tool (uuid-generator) costs within 11 % of the heaviest (database-explorer). The weight is in shared `/app` layout chunks, not per-tool code (tab-registry lazy-loading works — tool internals are split). Signature probe of the 30 chunks uuid-generator loads (overlapping, gz): radix-containing chunks 71 KB, sidebar 46 KB, **firebase 40 KB (loaded on every tool page)**, framer-motion 39 KB, tabler-icons 20 KB. +- **i18n payload: the full 173 KB `messages/en.json` is serialized into every page's HTML.** Every page HTML is ~245 KB raw / 66–67 KB gz, dominated by the all-namespaces message bag passed to `NextIntlClientProvider` (all 80 tools' strings on every page, twice the cost: HTML bytes + client-side parse). +- 10 heaviest packages (webpack treemap, gz): **gpt-tokenizer 1 408 KB** (lazy, token-counter only), next 285, @codemirror/legacy-modes 189, lucide-react 157, svgo 150, xlsx 136, vanilla-jsoneditor 126, katex 74, sql-formatter 71, @codemirror/view 60. Monaco is absent from the bundle — because it loads from CDN at runtime (§2 🔴). + +## 4. Lab metrics + +Medians of 3 runs. LH = Lighthouse 12.8, Chrome for Testing 150, `--headless=new`. Mobile = simulated slow-4G + 4× CPU; Desktop = `--preset=desktop`. Local `next start` — TTFB is not representative of production (no network/CDN); LCP/TBT/CLS are. + +### apps/web + +| Page | FF | Perf | LCP | FCP | TBT | CLS | TTFB | A11y | +|---|---|---|---|---|---|---|---|---| +| `/` | mobile | 72 | **9 309 ms** | 910 ms | 116 ms | 0.017 | 16 ms | 100 | +| `/` | desktop | 91 | 1 272 ms | 250 ms | 0 ms | 0.007 | 10 ms | 100 | +| `/pricing` | mobile | 76 | **6 010 ms** | 910 ms | 29 ms | 0.017 | 11 ms | 100 | +| `/pricing` | desktop | 93 | 1 131 ms | 251 ms | 0 ms | 0.007 | 7 ms | 100 | +| `/tools/json-formatter` | mobile | 87 | 4 077 ms | 909 ms | 18 ms | 0.005 | 13 ms | 100 | +| `/tools/json-formatter` | desktop | 100 | 809 ms | 249 ms | 0 ms | 0.004 | 7 ms | 100 | + +**LCP element & root cause (`/` mobile)**: hero text (`p.text-lg` / `h1`). LCP phase breakdown: TTFB 5 %, **render delay 95 % (8 821 ms)**. Two compounding causes, both verified in code: +1. `MdtBoot` (`src/components/mdt-boot.tsx:31`) — a full-screen CRT boot overlay SSR-rendered to "cover first paint", visible ≈4 s (3.4 s + 0.64 s fade) on **every hard load** (the play-once flag is a module variable, reset on every page load; it only skips SPA navigations). Every visitor from Google gets a 4-second fake boot screen before any content. +2. Hero content mounted with framer-motion `initial="hidden"` (`src/app/page.tsx:48-56,296-346`: opacity 0 + blur 6px) — text stays invisible until hydration (bootup 1 609 ms at 4× CPU; main-thread total 3 839 ms) plus a 0.7 s staggered fade. +Render-blocking: one CSS file, 757 ms (mobile `/`). Unused JS ≈65–69 KB/page (LH estimate); unused CSS ≈11–14 KB. CLS negligible. Layout-shift sources: none above 0.005. + +### apps/desktop-ui — measurement caveat + +Tool routes are client-gated by activation + Firebase auth (`src/components/require-auth.tsx` → redirect `/activate`), so in a browser lab the tool URLs render the **activation screen**, not the tool. The LH rows below are therefore "app entry screen" metrics; they still expose real shell problems (shared CSS/JS, entrance animation) because the shell is common. First Load JS per tool route (§3) is unaffected (measured from server HTML). In-product startup happens inside Tauri (local disk) and is not LH-measurable here. + +| Page (renders /activate) | FF | Perf | LCP | FCP | TBT | CLS | A11y | +|---|---|---|---|---|---|---|---| +| `/app/json-formatter` | mobile | 75 | **8 531 ms** | 1 358 ms | 35 ms | 0 | 95 | +| `/app/json-formatter` | desktop | 94 | 1 647 ms | 368 ms | 0 ms | 0 | 95 | +| `/app/uuid-generator` | mobile | 74 | 8 516 ms | 1 358 ms | 10 ms | 0 | 95 | +| `/app/token-counter` | mobile | 74 | 8 520 ms | 1 358 ms | 30 ms | 0 | 95 | + +Same signature as web: LCP is animated-in text, ~7 s render delay after a 1.36 s FCP — the entrance-animation pattern (`RevealItem`/fade-up) gates LCP under CPU throttle. Render-blocking CSS up to 1 206 ms (mobile). **A11y 95: `color-contrast` failure** on the activation screen's button (WCAG AA 1.4.3). Unused JS ≈210 KB/page (LH estimate) — consistent with the 562 KB shared floor (§3). + +### INP risk (keystroke cost) + +LH cannot measure INP in lab navigation mode; two-part evidence instead: +1. **Isolated worst-legal-input computation cost** (Node 24 = same V8; command in Appendix A): regex-tester `findMatchRanges` at its 200 000-char cap: **1 ms**/keystroke; diff-checker `buildLineDiffRows`+`countDiffRows` at its 250 000-char cap: **18 ms**/keystroke (≈4 ms / 72 ms under 4× CPU throttle). Both under the 200 ms INP threshold — the existing input caps (`MAX_TEST_TEXT_LENGTH`, `DIFF_MAX_INPUT_CHARS`) do their job, and heavy tools (bcrypt, mock-data, svg-optimize, json format) already run in Web Workers. +2. **Remaining risk is DOM, not computation**: diff-checker renders up to `DIFF_MAX_ROWS` = 5 000 rows **per pane, unvirtualized** (`diff-checker-layout.tsx:107,132` — plain `rows.map`, new array identity every keystroke → full 10 000-node re-render per keystroke). `@tanstack/react-virtual` is already a dependency (used in 2 other components). Could not be measured end-to-end in browser lab (activation gate); flagged on code evidence + the isolated numbers. + +## 5. Findings table (ranked by impact ÷ effort) + +Genuine wins first; cosmetic tail separated. Effort S <½ day, M ≈1–2 days, L >2 days. + +| # | Finding | Evidence | Proposed fix | Effort | Impact | Risk | +|---|---|---|---|---|---|---| +| 1 | 🎯 **web: boot overlay + hidden-initial hero destroy mobile LCP** (9.3 s home, 6.0 s pricing; render delay = 95 % of LCP) | §4 LH; `mdt-boot.tsx:31`, `page.tsx:296` | Play boot overlay once per browser (flag set on completion, per CLAUDE.md gotcha), let hero paint visible-first (CSS animation, no `initial="hidden"` above the fold) | S | Mobile LCP ≈9.3 s → ≈1.5 s on the two highest-traffic marketing pages; directly improves CWV ranking signal | Low — visual-only; keep animation below fold. **→ Reference PR1** | +| 2 | 🎯 **desktop-ui: Monaco loads 4–5 MB from jsdelivr CDN at runtime** — editor tools break offline, third-party egress | §2 egress; no `loader.config` in repo; `monaco-editor@0.53` already installed | Self-host: `loader.config({ monaco })` + local workers in a shared setup module imported by the 9 Monaco entry points | S | Offline claim restored for 6+ tools; ~15 CDN requests → 0 | Low — verify workers + highlight. **→ Reference PR2** | +| 3 | 🎯 **desktop-ui: bookmarks & password-manager send stored domains to Google/DuckDuckGo** (favicon services; desktop proxies via Rust but the query still reaches the third party) | `favicon-utils.ts:6-7`, `password-utils.ts:77`, `use-favicon.ts` | Disable remote favicon fetch (existing letter-avatar fallbacks take over); optional user setting later | S | Vault/bookmark metadata egress → 0 by default; aligns product with its privacy claim | Low — icons degrade to letter avatars. **→ Reference PR3** | +| 4 | **web: 100 % request-dynamic — zero prerendered pages on an SEO site** (`force-dynamic` + cookie locale; `generateStaticParams` dead) | §2; build route table: all ƒ | Remove `layout.tsx:19` `force-dynamic`; stop reading cookies on public routes (marketing is English-only per CLAUDE.md) → SSG + CDN caching for ~160 SEO pages | M | TTFB/resilience at the CDN edge + crawl budget; local TTFB delta is negligible (7–16 ms), the win is production caching | Med — verify `/account/plan`, `/dashboard`, locale switch | +| 5 | **desktop-ui: 173 KB `en.json` serialized into every page HTML** (all 80 tools' strings, every page ≈245 KB raw / 67 KB gz) | §3 i18n | Pass only needed namespaces to `NextIntlClientProvider` (per-route namespace map or `pick()`) | M | −~150 KB raw HTML + parse per page load, every page | Med — missing-namespace regressions; needs the tool-i18n registry | +| 6 | **desktop-ui: every tool pays a 562 KB gz First-Load floor** (uuid-generator ≈ database-explorer −11 %); firebase ≈40 KB of it on every page | §3 route sweep + chunk probes | Audit `/app` layout imports: defer firebase until auth UI needed, lazy command palette (already dynamic — verify), split sidebar data | M | Faster cold start in Tauri + web; −40 KB min | Med — shell refactor | +| 7 | **desktop-ui: diff-checker re-renders up to 10 000 unvirtualized rows per keystroke** | §4 INP; `diff-checker-layout.tsx:107,132` | Virtualize panes with already-installed `@tanstack/react-virtual`, or memo rows | S/M | Keystroke jank on large diffs eliminated | Low | +| 8 | **reduced-motion not honored by JS animations**: 27/32 framer-motion files unguarded (CSS kill-switch doesn't stop JS springs) — WCAG 2.3.3 | §6; `app-sidebar.tsx:157` | Wrap springs with `useReducedMotion` (pattern exists in 5 files) or a global `MotionConfig reducedMotion="user"` | S | A11y compliance; `MotionConfig` is a one-liner at the provider level | Low | +| 9 | **activation screen contrast failure** (WCAG AA 1.4.3, LH a11y 95) | §4 dt LH `color-contrast` | Fix button contrast token on `/activate` | S | First-run screen meets AA | Low | +| 10 | **entrance animations gate LCP in desktop-ui shell too** (same pattern as #1: LCP text ~8.5 s mobile lab) | §4 dt LH | Same treatment as PR1 for `RevealItem`/`AppLoadingScreen` when web-served; low priority inside Tauri | S/M | Lab CWV; minor for packaged app | Low | + +Cosmetic / hygiene tail (do opportunistically): remove 4 dead DB-driver deps + 2 dead pool files (§2); remove stale `remotePatterns` (qrserver, google) from both `next.config.ts`; bump `@next/bundle-analyzer` to a Next-16-matching major; migrate ~17 tools to `ToolPageHeader` and 9 hand-rolled copy paths to `use-copy-to-clipboard` (silent-fail fix in `color-picker-tool-layout.tsx:93` is the one real bug here); 24–28 px icon-button touch targets (75 files) — desktop-first app, fix when touching those files; boot-log copy says "60 utilities" vs the "80+" marketing standard (`mdt-boot.tsx:20`); sidebar offers no browse/search (palette covers it — consider a "All tools" sidebar link). + +## 6. UX & accessibility notes + +Static code audit of `apps/desktop-ui` (all file refs relative to it). What's genuinely good: the command palette (Cmd/Ctrl+K, cmdk fuzzy search, recents capped at 8 in localStorage, pinned group — `src/components/global-command-palette.tsx:92-323`), the shared copy hook (`src/hooks/use-copy-to-clipboard.ts:34-54`: try/catch, toast, auto-reset `isCopied`, used by 65 files), inline error/empty states in all 6 sampled tools (json-formatter toast, regex-tester inline destructive box `regex-tester-layout.tsx:165-174`, diff-checker per-pane `emptyState`, csv-excel-json distinguishes unsupported/parse/invalid), and zero `
` fake buttons — icon buttons carry `aria-label` or `title` (exemplary: `json-formatter-layout.tsx:403,462` with `aria-pressed`). + +The issues are the un-migrated tail, not the core: + +- **Reduced-motion gap (WCAG 2.3.3 risk, MED)**: 32 files import framer-motion, only 5 use `useReducedMotion`. The global CSS kill-switch (`src/app/globals.css:1834-1842`) only stops CSS animations — framer-motion JS springs still run, e.g. sidebar `layoutId` spring pill `app-sidebar.tsx:157-163`, nav-group, onboarding-modal, tools-grid, mobile-nav. +- **Touch targets (WCAG 2.5.5/2.5.8, MED)**: 75 files use 24–28 px icon buttons (`h-6 w-6`/`h-7 w-7`), e.g. `base64-layout.tsx:307`, `json-diff-layout.tsx:86`, `image-compressor-layout.tsx:372`. Mitigated by the app's explicit desktop orientation (`mobile-desktop-hint.tsx` shows a "use desktop" banner), but tools still render at 375 px. +- **Copy feedback fragmentation (MED)**: 9 tools bypass the shared hook with bare `navigator.clipboard.writeText`; `color-picker-tool-layout.tsx:93-96` has no try/catch and no toast — clipboard rejection fails silently. The aria-labelled shared `CopyButton` component exists but is used in only 2 files vs 65+ copy sites. +- **Header consistency (LOW/MED)**: 53/70 tool layouts use the standard `ToolPageHeader` (+52 `RevealItem`, 45 `CATEGORY_ACCENT`); ~17 tools have one-off headers. +- **Sidebar is a browsing dead end (LOW)**: it shows only Dashboard + Pinned (`app-sidebar.tsx:143-190`) — no search box, no full tool list; discovery relies on knowing Cmd+K or going through the dashboard grid. Sidebar header/footer are hidden entirely on mobile (`app-sidebar.tsx:111,192`). +- **Keyboard shortcuts (LOW)**: only Cmd+K is global; ~40 files have ad-hoc keydown handlers (games, to-do) with no registry or user-visible shortcut help beyond the palette footer. + +## 7. Reference PRs + +All three branch off `main` (`97ec0984`), independently mergeable. Before = fresh `main` build, after = branch build, identical commands (Appendix A), Turbopack both sides. + +### PR1 — `audit/pr1-web-lcp` (2 commits) + +**Change**: `apps/web` — hero entrance converted from framer-motion `initial="hidden"` to CSS animation (`.hero-fade`), with the two LCP elements (h1, subtitle) animating **transform only** (`.hero-slide`); `MdtBoot` CRT overlay now plays once per browser (localStorage flag set on completion — StrictMode-safe — plus an inline script that hides the SSR overlay at parse time on return visits). Files: `src/app/page.tsx`, `src/app/globals.css`, `src/components/mdt-boot.tsx`. + +**Why transform-only**: measured, not guessed — an opacity ramp is composited, so the browser records the text's paint-timing entry only when the main thread repaints after hydration; with opacity animation the LCP stayed ~6 s even though the filmstrip showed the hero visually complete at 1.3 s. + +| Page (mobile, median of 3) | LCP before | LCP after | Perf before | Perf after | +|---|---|---|---|---| +| `/` | 9 309 ms | **5 182 ms (−44 %)** | 72 | 77 | +| `/pricing` (not touched by this PR) | 6 010 ms | 5 263 ms | 76 | 78 | +| `/` desktop | 1 272 ms | 921 ms | 91 | 93 | + +Isolation experiments (single runs, recorded): boot overlay ≈ −400 ms, fonts ≈ −400 ms; the remaining ~4–5 s is the 391 KB gz client-JS chain of the fully-client landing page — that ceiling belongs to findings #4/#6, not to animation. Follow-up: `/pricing`'s own hero (via `marketing-seo-page`) has the same `initial="hidden"` pattern and deserves the same treatment. + +**No new egress**: third-party host set identical before/after (LH `network-requests`; only per-run Clarity subdomain shuffle). **Nothing broke**: build green; CLS unchanged (0.017); a11y 100 before and after; below-fold scroll animations retained. + +### PR2 — `audit/pr2-monaco-selfhost` (1 commit) + +**Change**: `apps/desktop-ui` — new `src/components/lazy/monaco.ts` bundles the local `monaco-editor@0.53` via `loader.config({ monaco })` + local language workers (`new URL(..., import.meta.url)`); all 5 dynamic-import sites (`LazyMonaco`, `ui/code-editor`, `format-converter`, `snippet-manager`, `json-schema-generator`) route through it. Monaco stays lazily loaded exactly as before. + +**Measurement** (scripted headless probe, Appendix A — activation/auth lab-bypassed via stubbed Tauri bridge + seeded Firebase persistence, identitytoolkit blocked): + +| Opening `/app/format-converter` until editor mounts | before (main) | after (PR2) | +|---|---|---| +| Requests to `cdn.jsdelivr.net` | **14** | **0** | +| `.monaco-editor` mounts | yes | yes | +| First Load JS (uuid-generator / format-converter / snippet-manager) | 572.4 / 610.8 / 591.1 KB gz | 572.4 / 610.9 / 591.3 KB gz | + +**Nothing broke**: `tsc --noEmit` clean; jest 99/100 suites green (the failing suite is the pre-existing `react-window` one — fails identically on `main`). Editor tools now work with no network at all. + +### PR3 — `audit/pr3-favicon-privacy` (1 commit) + +**Change**: `apps/desktop-ui` — `useFaviconSrc` (`src/lib/desktop/use-favicon.ts`) no longer resolves Google/DuckDuckGo favicon-service URLs; it returns `null` and every caller (bookmark cards, password-manager cards/list/swipeable rows) renders its existing local letter-avatar fallback. Guard test `src/components/__tests__/favicon-img.test.tsx` fails if the remote fetch is reintroduced without an explicit opt-in. + +**Measurement**: outbound favicon-service requests per rendered bookmark/password entry: N → **0** (the sole code path that produced `www.google.com/s2/favicons` / `icons.duckduckgo.com` URLs into `` is severed at its single choke point; verified by test + grep — no other consumer of `getFaviconUrl` bypasses `useFaviconSrc`/`FaviconImg`). **Nothing broke**: `tsc` clean, build green, guard test passes. Visual change only: letter avatars instead of fetched icons; re-adding icons later should be an off-by-default setting. + +## 8. Backlog & rejected ideas + +**Recommended next (evidence in §5)**: web SSG restoration (#4 — the audit's biggest structural web win; M effort because of cookie-locale; marketing is English-only per project docs, so public routes can prerender `en`); desktop-ui i18n payload split (#5); `/app` layout shell diet (#6, firebase ≈40 KB on every tool page); diff-checker virtualization (#7); `MotionConfig reducedMotion="user"` (#8); `/activate` contrast token (#9); `marketing-seo-page` hero = same LCP treatment as PR1. + +**Hygiene**: remove dead deps `mongodb`/`pg`/`mysql2`/`ioredis` + `src/lib/{sql,nosql}-client-pool.ts`; drop stale `remotePatterns` (qrserver, www.google.com) from both `next.config.ts`; bump `@next/bundle-analyzer` (v15 vs Next 16); migrate the ~17 off-pattern tool headers and 9 hand-rolled copy paths (real bug: silent copy failure in `color-picker-tool-layout.tsx:93`); boot-log copy "60 utilities" → "80+". + +**Rejected / disproven during the audit**: +- "QR generator phones home" — false; generation is local, `api.qrserver.com` is config residue. +- "DB drivers bloat the client bundle" — false; only a BPE-vocab token matched the grep, drivers are dead code (still worth deleting, but no perf urgency). +- "gpt-tokenizer needs code-splitting" — already per-encoding dynamic-imported; its 1.4 MB gz loads only in token-counter. +- "Debounce tool inputs / move parsing to workers" (from the original brief) — mostly already done: 24 files debounce, 5 real workers, and the regex/diff caps keep worst-legal-input keystroke cost at 1 ms / 18 ms. The measured residual risk is DOM-bound (diff-checker's 10 000 unvirtualized rows), not compute-bound. +- Desktop-ui in-browser Lighthouse for tool pages — the activation+auth gate means a browser lab measures the activation screen; kept honest by relabeling those rows (§4) and building the scripted bypass only for the PR2 egress probe. + +## Appendix A — Reproduce every number + +```bash +export PATH="/Users/max/.nvm/versions/node/v24.18.0/bin:$PATH" +# builds (pnpm exec is broken in this repo — use repo-local bins) +cd apps/web && ./node_modules/.bin/next build # Turbopack prod +cd apps/desktop-ui && ./node_modules/.bin/next build +# treemaps +ANALYZE=true ./node_modules/.bin/next build --webpack # each app; output .next/analyze/client.html +# serve +(cd apps/web && ./node_modules/.bin/next start -p 3100) & +(cd apps/desktop-ui && ./node_modules/.bin/next start -p 3200) & +# shared chunk +node -e "const m=require('./.next/build-manifest.json'),z=require('zlib'),f=require('fs');let t=0;for(const x of m.rootMainFiles)t+=z.gzipSync(f.readFileSync('.next/'+x)).length;console.log((t/1024).toFixed(1)+' KB gz')" +# per-route First Load JS +node scripts/audit/route-sweep.js http://localhost:3200 apps/desktop-ui /dashboard /app/uuid-generator ... +# DB-driver check +grep -rlE "MongoClient|createPool|ioredis" apps/desktop-ui/.next/static/chunks/ +# lighthouse (3 runs x mobile+desktop per URL, Chrome for Testing 150 pinned) +npx lighthouse@12 "$URL" --chrome-flags="--headless=new" --only-categories=performance,accessibility --output=json [--preset=desktop] +# TTFB +curl -so /dev/null -w "%{time_starttransfer}s " "$URL" # x5 +# keystroke cost at max legal input (regex-tester / diff-checker lib functions) +node --experimental-strip-types scripts/audit/keystroke-cost.mts +# Monaco CDN egress probe (lab bypass of activation+auth; needs puppeteer-core + CHROME_PATH) +node scripts/audit/monaco-egress.js http://localhost:3200/app/format-converter "$NEXT_PUBLIC_FIREBASE_API_KEY" +# LH runner used for all matrices (3x mobile + 3x desktop per URL) +scripts/audit/lh-run.sh +``` + +Conditions worth restating: Lighthouse default = simulated throttling (Lantern); isolation experiments in §7 that state "devtools" used `--throttling-method=devtools`. `next start` caches the build — always restart the server after rebuilding before measuring (two stale-server traps were caught and re-measured during this audit). LH runs use a fresh profile, so "first visit" behavior (boot overlay) is always included. diff --git a/apps/desktop-ui/next.config.ts b/apps/desktop-ui/next.config.ts index a9cf911c..730e47b6 100644 --- a/apps/desktop-ui/next.config.ts +++ b/apps/desktop-ui/next.config.ts @@ -1,5 +1,6 @@ import type { NextConfig } from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; +import withBundleAnalyzer from '@next/bundle-analyzer'; const withNextIntl = createNextIntlPlugin(); @@ -71,4 +72,6 @@ const nextConfig: NextConfig = { }, }; -export default withNextIntl(nextConfig); +const analyzer = (withBundleAnalyzer as any)({ enabled: process.env.ANALYZE === 'true' }); + +export default analyzer(withNextIntl(nextConfig)); diff --git a/scripts/audit/keystroke-cost.mts b/scripts/audit/keystroke-cost.mts new file mode 100644 index 00000000..5b5e1246 --- /dev/null +++ b/scripts/audit/keystroke-cost.mts @@ -0,0 +1,22 @@ +// Worst-legal-input main-thread cost of per-keystroke recomputes (Node = same V8 as Chrome). +// Run from repo root: node --experimental-strip-types scripts/audit/keystroke-cost.mts +import { findMatchRanges, MAX_TEST_TEXT_LENGTH } from '../../apps/desktop-ui/src/lib/regex-tester.ts'; +import { buildLineDiffRows, countDiffRows, DIFF_MAX_INPUT_CHARS } from '../../apps/desktop-ui/src/lib/text-diff.ts'; + +const words = ['error', 'warn', 'info', 'debug', 'trace', 'user', 'id', 'value']; +let text = ''; +while (text.length < MAX_TEST_TEXT_LENGTH - 100) { + text += `[2026-07-16] ${words[text.length % 8]} message ${text.length} key=val${text.length % 97}\n`; +} +const t0 = performance.now(); +for (let i = 0; i < 5; i++) findMatchRanges(text, '\\b(\\w+)=(\\w+)\\b', 'g'); +const regexMs = (performance.now() - t0) / 5; + +const lines = text.split('\n'); +const left = lines.join('\n').slice(0, DIFF_MAX_INPUT_CHARS - 10); +const right = lines.map((l, i) => (i % 20 === 0 ? l + ' CHANGED' : l)).join('\n').slice(0, DIFF_MAX_INPUT_CHARS - 10); +const t1 = performance.now(); +for (let i = 0; i < 3; i++) { const r = buildLineDiffRows(left, right); countDiffRows(r); } +const diffMs = (performance.now() - t1) / 3; + +console.log(JSON.stringify({ regexTester200kMs: +regexMs.toFixed(1), diffChecker250kMs: +diffMs.toFixed(1) })); diff --git a/scripts/audit/lh-run.sh b/scripts/audit/lh-run.sh new file mode 100755 index 00000000..2ccad9f9 --- /dev/null +++ b/scripts/audit/lh-run.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Lighthouse matrix runner. Usage: lh-run.sh +# 3 runs mobile + 3 desktop, JSON to $SCRATCH/lh/ +set -e +export PATH="/Users/max/.nvm/versions/node/v24.18.0/bin:$PATH" +S=/private/tmp/claude-501/-Users-max-Works-Personal-mydevtools/84bbaabb-6f1a-4567-803d-f1a69bc496a1/scratchpad +export CHROME_PATH="$S/browsers/chrome/mac_arm-150.0.7871.124/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" +NAME=$1; URL=$2 +mkdir -p "$S/lh" +for i in 1 2 3; do + npx -y lighthouse@12 "$URL" --chrome-flags="--headless=new" \ + --only-categories=performance,accessibility \ + --output=json --output-path="$S/lh/$NAME-mobile-$i.json" --quiet + npx -y lighthouse@12 "$URL" --preset=desktop --chrome-flags="--headless=new" \ + --only-categories=performance,accessibility \ + --output=json --output-path="$S/lh/$NAME-desktop-$i.json" --quiet +done +echo "DONE $NAME" diff --git a/scripts/audit/monaco-egress.js b/scripts/audit/monaco-egress.js new file mode 100644 index 00000000..048c05e7 --- /dev/null +++ b/scripts/audit/monaco-egress.js @@ -0,0 +1,111 @@ +// Lab-only auth-gate bypass + Monaco egress probe. +// Stubs the Tauri activation call and seeds a persisted Firebase user in +// IndexedDB, loads a Monaco tool page, waits for the editor, and reports +// every third-party host contacted. Usage: node monaco-egress.js +const puppeteer = require("puppeteer-core"); + +const [url, apiKey] = process.argv.slice(2); + +(async () => { + const browser = await puppeteer.launch({ + executablePath: process.env.CHROME_PATH, + headless: "new", + }); + const page = await browser.newPage(); + + const hosts = new Map(); + page.on("request", (r) => { + const h = new URL(r.url()).host; + hosts.set(h, (hosts.get(h) || 0) + 1); + }); + // Firebase validates persisted users on init; a network failure keeps the + // user (offline path). Blocking identitytoolkit keeps the lab user alive. + const cdp2 = await page.createCDPSession(); + await cdp2.send("Network.enable"); + await cdp2.send("Network.setBlockedURLs", { + urls: ["*identitytoolkit*", "*securetoken*"], + }); + + // Tauri activation stub on every document + await page.evaluateOnNewDocument(() => { + window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + if (cmd === "local_api" && args?.path === "/desktop/activation") { + return Promise.resolve({ + status: 200, + body: JSON.stringify({ uid: "lab", email: "lab@example.com" }), + }); + } + return Promise.resolve({ status: 404, body: "" }); + }, + }; + }); + + // Seed the persisted Firebase user on a throwaway same-origin page and wait + // for the IndexedDB write to land BEFORE the app boots (avoids init race). + const origin = new URL(url).origin; + await page.goto(origin + "/robots.txt", { waitUntil: "domcontentloaded" }); + await page.evaluate((key) => { + const user = { + uid: "lab", + email: "lab@example.com", + emailVerified: true, + isAnonymous: false, + providerData: [], + stsTokenManager: { + refreshToken: "lab-refresh", + accessToken: "e30.e30.e30", + expirationTime: Date.now() + 3600e3, + }, + createdAt: "0", + lastLoginAt: "0", + apiKey: key, + appName: "[DEFAULT]", + }; + return new Promise((resolve, reject) => { + const open = indexedDB.open("firebaseLocalStorageDb", 1); + open.onupgradeneeded = () => + open.result.createObjectStore("firebaseLocalStorage", { + keyPath: "fbase_key", + }); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const tx = open.result.transaction("firebaseLocalStorage", "readwrite"); + tx.objectStore("firebaseLocalStorage").put({ + fbase_key: `firebase:authUser:${key}:[DEFAULT]`, + value: user, + }); + tx.oncomplete = () => resolve(true); + tx.onerror = () => reject(tx.error); + }; + }); + }, apiKey); + hosts.clear(); + + await page.goto(url, { waitUntil: "networkidle2", timeout: 90000 }); + const editorFound = await page + .waitForSelector(".monaco-editor", { timeout: 45000 }) + .then(() => true) + .catch(() => false); + await new Promise((r) => setTimeout(r, 3000)); + + const third = [...hosts.entries()].filter( + ([h]) => !h.startsWith("localhost") + ); + console.log( + JSON.stringify( + { + url, + finalUrl: page.url(), + editorFound, + thirdPartyHosts: Object.fromEntries(third), + jsdelivrRequests: [...hosts.entries()] + .filter(([h]) => h.includes("jsdelivr")) + .reduce((n, [, c]) => n + c, 0), + }, + null, + 1 + ) + ); + await browser.close(); +})(); diff --git a/scripts/audit/route-sweep.js b/scripts/audit/route-sweep.js new file mode 100644 index 00000000..43817327 --- /dev/null +++ b/scripts/audit/route-sweep.js @@ -0,0 +1,32 @@ +// First Load JS per route: fetch each route's HTML from a running `next start`, +// collect